fe6e96dd3f
Shadboard starter-kit (Next.js 15 + React 19 + shadcn/ui + Tailwind 4) Sprint 4.a — Admin Dashboard: - Auth: NextAuth.js v5, login page, middleware, token rotation - Dashboard: KPI cards, Recharts stock chart, quick actions - Members: TanStack Table (search/sort/paginate), add/edit forms - Distributions: multi-step form, real-time quota check, history - Stock: batch management, recall dialog, bar chart - Reports: monthly/member-list/recall, PDF/CSV download, preview Sprint 4.b — Member Portal: - Separate route group with top-nav layout (mobile-first) - Quota dashboard with radial SVG progress indicators - Distribution history with month filter - Profile/settings with password change Cross-cutting: - i18n: German (default) + English via next-intl - Dark + light mode (next-themes, user-togglable) - Playwright E2E tests (6/6 green) - Docker multi-stage build (node:22-alpine) - API proxy via Next.js rewrites Tech: Next.js 15.2.8, React 19, Tailwind 4, NextAuth v5, TanStack Table, Recharts, Zod, React Hook Form, Playwright
1693 lines
62 KiB
Markdown
1693 lines
62 KiB
Markdown
# CannaManage — Sprint 4 Implementation Plan
|
||
|
||
**Date:** 2026-06-12
|
||
**Author:** Patrick Plate / Lumen (Planner)
|
||
**Status:** Draft v3
|
||
**Base Branch:** `main`
|
||
**Sprint Branch:** `sprint/4-frontend`
|
||
**Sprint Goal:** Frontend MVP — Admin Dashboard + Member Portal using Shadboard starter-kit (Next.js 15 + shadcn/ui)
|
||
|
||
> **Sprint Structure:** Sprint 4 is split into two sub-sprints:
|
||
> - **Sprint 4.a** — Phases 1–7: Admin Dashboard MVP (~7.5 days)
|
||
> - **Sprint 4.b** — Phases 8–9: Member Portal (quota view, distribution history, profile/settings) (~2.5 days)
|
||
>
|
||
> Both are delivered within Sprint 4, organized as sequential sub-sprints (~10 days total).
|
||
|
||
---
|
||
|
||
## 0. Decisions (Confirmed by Patrick)
|
||
|
||
| # | Decision | Detail |
|
||
|---|----------|--------|
|
||
| D1 | Frontend framework | **Shadboard starter-kit** (MIT license). Next.js 15 + React 19 + Tailwind CSS 4 + shadcn/ui. Dark theme with green cannabis branding. |
|
||
| D2 | Auth strategy | **NextAuth.js CredentialsProvider** → hits our `/api/v1/auth/login` endpoint. Tokens stored server-side in NextAuth session. Client never sees raw JWT. |
|
||
| D3 | API proxy | **Next.js rewrites** in `next.config.ts` — `/api/backend/**` → `http://localhost:8080/api/v1/**`. No CORS needed in dev. Production: reverse proxy (nginx/caddy). |
|
||
| D4 | Brand theme | Dark mode default. Background `#0D1117`, card `#161B22`, accent `#2ECC71` / `#27AE60`. Matches VS Code dark theme. |
|
||
| D5 | Package manager | **pnpm** (Shadboard default). Lockfile committed. |
|
||
| D6 | Directory | **Separate pnpm project** in `cannamanage-frontend/` at monorepo root, sibling to `cannamanage-api/`. Not a workspace root — standalone project with its own `package.json` and `pnpm-lock.yaml`. |
|
||
| D7 | Deployment | Dev: `pnpm dev` on port 3000. Docker: multi-stage build (node:22-alpine → standalone output). Docker Compose service added. |
|
||
| D8 | i18n | **From Day 1** — German (default) + English. Uses `next-intl` (already bundled in Shadboard). All UI strings use translation keys via `useTranslations()` hook. Locale files: `messages/de.json`, `messages/en.json`. |
|
||
| D9 | Node.js version | **Node 22 LTS** (not Node 20). All Dockerfiles and CI use `node:22-alpine`. |
|
||
|
||
---
|
||
|
||
## 1. Sprint 3 Recap (Context)
|
||
|
||
| Delivered | Status |
|
||
|-----------|--------|
|
||
| Staff permission model (8 granular perms, JSONB) | ✅ |
|
||
| Token revocation (Caffeine cache + DB blacklist) | ✅ |
|
||
| Club settings controller (email whitelist, prevention officer limit) | ✅ |
|
||
| Staff management + invite flow (Spring Mail, 72h token) | ✅ |
|
||
| Report controller (PDF/CSV/JSON — monthly, member list, recall) | ✅ |
|
||
| Member portal (dual SecurityFilterChain, session auth, JSON API) | ✅ |
|
||
| Prevention officer capability (configurable limit, under-21 gate) | ✅ |
|
||
| Integration tests (Testcontainers PostgreSQL 16, 30+ tests) | ✅ |
|
||
|
||
**Deferred from Sprint 3:** React frontend SPA, Stripe payments, grow calendar, schema-per-tenant, DSGVO consent UI.
|
||
|
||
---
|
||
|
||
## 2. Sprint 4 Scope
|
||
|
||
### ✅ IN Scope — Sprint 4.a (Admin Dashboard MVP)
|
||
|
||
| # | Feature | Priority | Effort |
|
||
|---|---------|----------|--------|
|
||
| 1 | **Project setup + i18n** — Shadboard starter-kit scaffolding, Next.js config, pnpm, Docker, `next-intl` locale setup (de/en) | P0 | 0.5 days |
|
||
| 2 | **Auth pages** — Login, NextAuth.js integration, token rotation, role-based redirects | P0 | 1 day |
|
||
| 3 | **Admin dashboard** — Overview page with club stats, sidebar nav, quick actions | P0 | 1 day |
|
||
| 4 | **Member management** — List (TanStack Table), detail/edit form, add member | P1 | 1.5 days |
|
||
| 5 | **Distribution recording** — Form with quota check, member search, batch select, history | P1 | 1.5 days |
|
||
| 6 | **Stock/batch management** — Batch list, add batch form, stock chart (Recharts) | P1 | 1 day |
|
||
| 7 | **Reports** — Report triggers, PDF/CSV download, inline preview | P2 | 1 day |
|
||
|
||
**Sprint 4.a effort:** ~7.5 days
|
||
|
||
### ✅ IN Scope — Sprint 4.b (Member Portal)
|
||
|
||
| # | Feature | Priority | Effort |
|
||
|---|---------|----------|--------|
|
||
| 8 | **Member portal layout & auth** — Session-based auth for members, portal layout shell, locale support | P1 | 1 day |
|
||
| 9 | **Member portal pages** — Dashboard/quota view, distribution history, profile/settings | P1 | 1.5 days |
|
||
|
||
**Sprint 4.b effort:** ~2.5 days
|
||
|
||
**Total estimated effort:** ~10.5 days (single worker, sequential: 7.5 + 3)
|
||
|
||
### ❌ OUT of Scope (Sprint 5+)
|
||
|
||
- Staff management UI (invite, permissions editor)
|
||
- Stripe payment integration
|
||
- Grow calendar / cultivation tracking
|
||
- DSGVO consent management UI
|
||
- Push notifications (WebSocket)
|
||
- PWA / offline mode
|
||
|
||
---
|
||
|
||
## 3. Architecture Decisions
|
||
|
||
### 3.1 Shadboard Starter-Kit Integration
|
||
|
||
**Source:** `https://github.com/Qualiora/shadboard` (MIT license)
|
||
|
||
We use the `starter-kit/` directory (lean base without demo pages). Key directories:
|
||
|
||
```
|
||
cannamanage-frontend/
|
||
├── app/ # Next.js App Router
|
||
│ ├── [locale]/ # Locale segment (de/en)
|
||
│ │ ├── (auth)/ # Auth layout group (login page)
|
||
│ │ ├── (dashboard)/ # Dashboard layout group (authenticated)
|
||
│ │ │ ├── layout.tsx # Sidebar + header + breadcrumbs
|
||
│ │ │ ├── page.tsx # Overview dashboard
|
||
│ │ │ ├── members/ # Member management
|
||
│ │ │ ├── distributions/ # Distribution recording
|
||
│ │ │ ├── stock/ # Batch/stock management
|
||
│ │ │ └── reports/ # Report downloads
|
||
│ │ ├── (portal)/ # Member portal layout group (Sprint 4.b)
|
||
│ │ │ ├── layout.tsx # Portal layout shell
|
||
│ │ │ ├── page.tsx # Portal dashboard / quota view
|
||
│ │ │ ├── history/ # Distribution history
|
||
│ │ │ └── settings/ # Profile / settings
|
||
│ │ └── layout.tsx # Root locale layout (providers, theme)
|
||
│ ├── api/ # NextAuth route handlers
|
||
│ └── layout.tsx # Root layout (html, body)
|
||
├── messages/
|
||
│ ├── de.json # German translations (default locale)
|
||
│ └── en.json # English translations
|
||
├── components/
|
||
│ ├── ui/ # shadcn/ui components (Button, Card, Table, etc.)
|
||
│ ├── layout/ # Sidebar, Header, Breadcrumbs (from Shadboard)
|
||
│ └── cannamanage/ # Our custom components
|
||
├── lib/
|
||
│ ├── api-client.ts # Typed fetch wrapper for backend API
|
||
│ ├── auth.ts # NextAuth config
|
||
│ └── utils.ts # Shadboard utility (cn() helper)
|
||
├── hooks/ # Custom React hooks (useMembers, useDistributions, etc.)
|
||
├── types/ # TypeScript types mirroring backend DTOs
|
||
├── public/ # Static assets (logo, favicon)
|
||
├── i18n/
|
||
│ ├── request.ts # next-intl request config (getRequestConfig)
|
||
│ └── routing.ts # Locale routing config (defaultLocale, locales)
|
||
├── next.config.ts # API rewrites, standalone output, next-intl plugin
|
||
├── tailwind.config.ts # Brand colors, dark theme
|
||
├── .env.local # Local dev environment vars
|
||
└── pnpm-lock.yaml
|
||
```
|
||
|
||
### 3.2 NextAuth.js Configuration (Decision D2)
|
||
|
||
```typescript
|
||
// lib/auth.ts
|
||
import NextAuth from "next-auth"
|
||
import Credentials from "next-auth/providers/credentials"
|
||
|
||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||
providers: [
|
||
Credentials({
|
||
credentials: {
|
||
email: { label: "Email", type: "email" },
|
||
password: { label: "Password", type: "password" },
|
||
},
|
||
async authorize(credentials) {
|
||
const res = await fetch(`${process.env.BACKEND_URL}/api/v1/auth/login`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
email: credentials.email,
|
||
password: credentials.password,
|
||
}),
|
||
})
|
||
if (!res.ok) return null
|
||
const data = await res.json()
|
||
// data: { accessToken, refreshToken, expiresIn, role }
|
||
return {
|
||
id: data.accessToken, // we'll decode sub from JWT
|
||
accessToken: data.accessToken,
|
||
refreshToken: data.refreshToken,
|
||
expiresIn: data.expiresIn,
|
||
role: data.role,
|
||
}
|
||
},
|
||
}),
|
||
],
|
||
callbacks: {
|
||
async jwt({ token, user }) {
|
||
if (user) {
|
||
token.accessToken = user.accessToken
|
||
token.refreshToken = user.refreshToken
|
||
token.role = user.role
|
||
token.expiresAt = Date.now() + user.expiresIn * 1000
|
||
}
|
||
// Auto-refresh if expired
|
||
if (Date.now() > (token.expiresAt as number)) {
|
||
return await refreshAccessToken(token)
|
||
}
|
||
return token
|
||
},
|
||
async session({ session, token }) {
|
||
session.accessToken = token.accessToken as string
|
||
session.user.role = token.role as string
|
||
return session
|
||
},
|
||
},
|
||
pages: {
|
||
signIn: "/login",
|
||
},
|
||
})
|
||
```
|
||
|
||
**Token refresh logic:**
|
||
|
||
```typescript
|
||
async function refreshAccessToken(token: JWT) {
|
||
const res = await fetch(`${process.env.BACKEND_URL}/api/v1/auth/refresh`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ refreshToken: token.refreshToken }),
|
||
})
|
||
if (!res.ok) {
|
||
return { ...token, error: "RefreshAccessTokenError" }
|
||
}
|
||
const data = await res.json()
|
||
return {
|
||
...token,
|
||
accessToken: data.accessToken,
|
||
refreshToken: data.refreshToken,
|
||
expiresAt: Date.now() + data.expiresIn * 1000,
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.3 API Client Pattern
|
||
|
||
```typescript
|
||
// lib/api-client.ts
|
||
import { auth } from "@/lib/auth"
|
||
|
||
const BACKEND_BASE = process.env.BACKEND_URL || "http://localhost:8080"
|
||
|
||
export async function apiClient<T>(
|
||
path: string,
|
||
options: RequestInit = {}
|
||
): Promise<T> {
|
||
const session = await auth()
|
||
if (!session?.accessToken) throw new Error("Unauthorized")
|
||
|
||
const res = await fetch(`${BACKEND_BASE}/api/v1${path}`, {
|
||
...options,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${session.accessToken}`,
|
||
...options.headers,
|
||
},
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const error = await res.json().catch(() => ({ message: res.statusText }))
|
||
throw new ApiError(res.status, error.message)
|
||
}
|
||
|
||
return res.json()
|
||
}
|
||
```
|
||
|
||
### 3.4 Brand Theme (Decision D4)
|
||
|
||
```typescript
|
||
// tailwind.config.ts (extending Shadboard's config)
|
||
export default {
|
||
darkMode: "class",
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
background: "#0D1117",
|
||
foreground: "#E6EDF3",
|
||
card: { DEFAULT: "#161B22", foreground: "#E6EDF3" },
|
||
primary: { DEFAULT: "#2ECC71", foreground: "#0D1117" },
|
||
secondary: { DEFAULT: "#27AE60", foreground: "#FFFFFF" },
|
||
muted: { DEFAULT: "#21262D", foreground: "#8B949E" },
|
||
accent: { DEFAULT: "#2ECC71", foreground: "#0D1117" },
|
||
border: "#30363D",
|
||
input: "#21262D",
|
||
ring: "#2ECC71",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
```
|
||
|
||
### 3.5 Next.js API Proxy + Security Headers (Decisions D3 + Security)
|
||
|
||
```typescript
|
||
// next.config.ts
|
||
import createNextIntlPlugin from "next-intl/plugin"
|
||
const withNextIntl = createNextIntlPlugin()
|
||
|
||
const securityHeaders = [
|
||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||
{ key: "Referrer-Policy", value: "origin-when-cross-origin" },
|
||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||
{
|
||
key: "Content-Security-Policy",
|
||
value: [
|
||
"default-src 'self'",
|
||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval in dev
|
||
"style-src 'self' 'unsafe-inline'",
|
||
"img-src 'self' data: blob:",
|
||
"font-src 'self'",
|
||
"connect-src 'self'",
|
||
"frame-ancestors 'none'",
|
||
"base-uri 'self'",
|
||
"form-action 'self'",
|
||
].join("; "),
|
||
},
|
||
]
|
||
|
||
const nextConfig = {
|
||
output: "standalone",
|
||
async headers() {
|
||
return [{ source: "/(.*)", headers: securityHeaders }]
|
||
},
|
||
async rewrites() {
|
||
return [
|
||
{
|
||
source: "/api/backend/:path*",
|
||
destination: `${process.env.BACKEND_URL || "http://localhost:8080"}/api/v1/:path*`,
|
||
},
|
||
]
|
||
},
|
||
}
|
||
export default withNextIntl(nextConfig)
|
||
```
|
||
|
||
### 3.6 TypeScript Types (mirroring backend DTOs)
|
||
|
||
```typescript
|
||
// types/api.ts
|
||
export interface LoginResponse {
|
||
accessToken: string
|
||
refreshToken: string
|
||
expiresIn: number
|
||
role: "ADMIN" | "STAFF" | "MEMBER" | "PREVENTION_OFFICER"
|
||
}
|
||
|
||
export interface ClubStats {
|
||
totalMembers: number
|
||
activeMembers: number
|
||
totalStaff: number
|
||
activeStaff: number
|
||
totalDistributionsThisMonth: number
|
||
totalGramsDistributedThisMonth: number
|
||
activeBatches: number
|
||
preventionOfficerCount: number
|
||
}
|
||
|
||
export interface Member {
|
||
id: string
|
||
firstName: string
|
||
lastName: string
|
||
email: string
|
||
dateOfBirth: string // ISO date
|
||
membershipDate: string
|
||
membershipNumber: string
|
||
status: "ACTIVE" | "INACTIVE" | "SUSPENDED" | "PENDING"
|
||
under21: boolean
|
||
preventionOfficer: boolean
|
||
}
|
||
|
||
export interface QuotaStatus {
|
||
totalAllowed: number
|
||
totalUsed: number
|
||
remaining: number
|
||
under21: boolean
|
||
year: number
|
||
month: number
|
||
}
|
||
|
||
export interface Batch {
|
||
id: string
|
||
strainId: string
|
||
quantityGrams: number
|
||
harvestDate: string
|
||
batchCode: string
|
||
status: "AVAILABLE" | "RECALLED" | "DEPLETED"
|
||
contaminationFlag: boolean
|
||
}
|
||
|
||
export interface CreateDistributionRequest {
|
||
memberId: string
|
||
batchId: string
|
||
quantityGrams: number
|
||
notes?: string
|
||
}
|
||
|
||
export interface Distribution {
|
||
id: string
|
||
memberId: string
|
||
memberName: string
|
||
batchId: string
|
||
batchCode: string
|
||
quantityGrams: number
|
||
distributedAt: string
|
||
distributedBy: string
|
||
notes?: string
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Backend Changes Required
|
||
|
||
### 4.1 CORS Configuration
|
||
|
||
Add to `SecurityConfig.java` for development (production uses reverse proxy):
|
||
|
||
```java
|
||
@Bean
|
||
public CorsConfigurationSource corsConfigurationSource() {
|
||
CorsConfiguration config = new CorsConfiguration();
|
||
config.setAllowedOrigins(List.of(
|
||
"http://localhost:3000" // Next.js dev server
|
||
));
|
||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
|
||
config.setAllowedHeaders(List.of("*"));
|
||
config.setAllowCredentials(true);
|
||
config.setMaxAge(3600L);
|
||
|
||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||
source.registerCorsConfiguration("/api/**", config);
|
||
return source;
|
||
}
|
||
```
|
||
|
||
Enable in both `SecurityFilterChain` beans:
|
||
```java
|
||
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||
```
|
||
|
||
### 4.2 Docker Compose Update
|
||
|
||
```yaml
|
||
# Addition to docker-compose.yml
|
||
frontend:
|
||
build:
|
||
context: ./cannamanage-frontend
|
||
dockerfile: Dockerfile
|
||
container_name: cannamanage-frontend
|
||
ports:
|
||
- "3000:3000"
|
||
environment:
|
||
- BACKEND_URL=http://api:8080
|
||
- NEXTAUTH_URL=http://localhost:3000
|
||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||
depends_on:
|
||
- api
|
||
|
||
api:
|
||
build:
|
||
context: .
|
||
dockerfile: cannamanage-api/Dockerfile
|
||
container_name: cannamanage-api
|
||
ports:
|
||
- "8080:8080"
|
||
environment:
|
||
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/cannamanage
|
||
- SPRING_DATASOURCE_USERNAME=cannamanage
|
||
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD:-dev_password}
|
||
- SPRING_PROFILES_ACTIVE=local
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
```
|
||
|
||
### 4.3 Environment Variables (.env.local)
|
||
|
||
```bash
|
||
# cannamanage-frontend/.env.local
|
||
BACKEND_URL=http://localhost:8080
|
||
NEXTAUTH_URL=http://localhost:3000
|
||
# ⚠️ PRODUCTION: Must be a cryptographically random 32+ char string
|
||
# Generate with: openssl rand -base64 32
|
||
NEXTAUTH_SECRET=<generate-with-openssl-rand-base64-32>
|
||
```
|
||
|
||
> **⚠️ Security Note:** Never commit real secrets to version control. The `.env.local` file is gitignored. For production, inject `NEXTAUTH_SECRET` via your CI/CD pipeline's secrets manager (e.g., GitHub Actions secrets, AWS Secrets Manager, HashiCorp Vault).
|
||
|
||
---
|
||
|
||
## 5. Implementation Phases
|
||
|
||
---
|
||
|
||
### Phase 1: Project Setup + i18n (Sprint 4.a)
|
||
|
||
**Goal:** Scaffold Shadboard starter-kit, configure Next.js, establish brand theme, set up i18n with `next-intl` (German default + English).
|
||
|
||
**Files to create/modify:**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `cannamanage-frontend/` | Create | Entire directory from Shadboard starter-kit |
|
||
| `cannamanage-frontend/next.config.ts` | Modify | Add API rewrites, standalone output, `createNextIntlPlugin` |
|
||
| `cannamanage-frontend/tailwind.config.ts` | Modify | CannaManage brand colors (dark + green) |
|
||
| `cannamanage-frontend/.env.local` | Create | Local env vars (BACKEND_URL, NEXTAUTH) |
|
||
| `cannamanage-frontend/Dockerfile` | Create | Multi-stage build for production (node:22-alpine) |
|
||
| `cannamanage-frontend/.gitignore` | Modify | Ensure node_modules, .next, .env.local excluded |
|
||
| `cannamanage-frontend/eslint.config.mjs` | Create | ESLint flat config with Next.js + TypeScript rules |
|
||
| `cannamanage-frontend/.prettierrc` | Create | Prettier config (semi: false, singleQuote: false, tabWidth: 2) |
|
||
| `cannamanage-frontend/messages/de.json` | Create | German translation strings (default locale) |
|
||
| `cannamanage-frontend/messages/en.json` | Create | English translation strings |
|
||
| `cannamanage-frontend/i18n/request.ts` | Create | `next-intl` request configuration |
|
||
| `cannamanage-frontend/i18n/routing.ts` | Create | Locale routing config (locales, defaultLocale) |
|
||
| `cannamanage-frontend/middleware.ts` | Create | Locale detection + redirect middleware |
|
||
| `docker-compose.yml` | Modify | Add frontend + api services |
|
||
| `LICENSES` | Create | Shadboard MIT license attribution |
|
||
|
||
**Implementation details:**
|
||
|
||
1. Clone Shadboard starter-kit:
|
||
```bash
|
||
cd cannamanage-frontend
|
||
npx degit Qualiora/shadboard/starter-kit .
|
||
pnpm install
|
||
```
|
||
|
||
2. Configure Tailwind with CannaManage brand palette (section 3.4).
|
||
|
||
3. Set up `next-intl` for i18n (Shadboard already includes the dependency):
|
||
|
||
```typescript
|
||
// i18n/routing.ts
|
||
import { defineRouting } from "next-intl/routing"
|
||
|
||
export const routing = defineRouting({
|
||
locales: ["de", "en"],
|
||
defaultLocale: "de",
|
||
})
|
||
```
|
||
|
||
```typescript
|
||
// i18n/request.ts
|
||
import { getRequestConfig } from "next-intl/server"
|
||
import { routing } from "./routing"
|
||
|
||
export default getRequestConfig(async ({ requestLocale }) => {
|
||
let locale = await requestLocale
|
||
if (!locale || !routing.locales.includes(locale as any)) {
|
||
locale = routing.defaultLocale
|
||
}
|
||
return {
|
||
locale,
|
||
messages: (await import(`../messages/${locale}.json`)).default,
|
||
}
|
||
})
|
||
```
|
||
|
||
```typescript
|
||
// next.config.ts (updated with next-intl plugin)
|
||
import createNextIntlPlugin from "next-intl/plugin"
|
||
const withNextIntl = createNextIntlPlugin()
|
||
|
||
const nextConfig = {
|
||
output: "standalone",
|
||
async rewrites() {
|
||
return [
|
||
{
|
||
source: "/api/backend/:path*",
|
||
destination: `${process.env.BACKEND_URL || "http://localhost:8080"}/api/v1/:path*`,
|
||
},
|
||
]
|
||
},
|
||
}
|
||
export default withNextIntl(nextConfig)
|
||
```
|
||
|
||
4. Create initial locale files:
|
||
|
||
```json
|
||
// messages/de.json
|
||
{
|
||
"common": {
|
||
"appName": "CannaManage",
|
||
"loading": "Laden...",
|
||
"save": "Speichern",
|
||
"cancel": "Abbrechen",
|
||
"delete": "Löschen",
|
||
"edit": "Bearbeiten",
|
||
"add": "Hinzufügen",
|
||
"search": "Suchen...",
|
||
"noResults": "Keine Ergebnisse",
|
||
"error": "Ein Fehler ist aufgetreten",
|
||
"retry": "Erneut versuchen"
|
||
},
|
||
"nav": {
|
||
"overview": "Übersicht",
|
||
"members": "Mitglieder",
|
||
"distributions": "Ausgabe",
|
||
"stock": "Bestand",
|
||
"reports": "Berichte"
|
||
},
|
||
"auth": {
|
||
"login": "Anmelden",
|
||
"logout": "Abmelden",
|
||
"email": "E-Mail-Adresse",
|
||
"password": "Passwort",
|
||
"invalidCredentials": "Ungültige Anmeldedaten",
|
||
"sessionExpired": "Sitzung abgelaufen"
|
||
}
|
||
}
|
||
```
|
||
|
||
```json
|
||
// messages/en.json
|
||
{
|
||
"common": {
|
||
"appName": "CannaManage",
|
||
"loading": "Loading...",
|
||
"save": "Save",
|
||
"cancel": "Cancel",
|
||
"delete": "Delete",
|
||
"edit": "Edit",
|
||
"add": "Add",
|
||
"search": "Search...",
|
||
"noResults": "No results",
|
||
"error": "An error occurred",
|
||
"retry": "Retry"
|
||
},
|
||
"nav": {
|
||
"overview": "Overview",
|
||
"members": "Members",
|
||
"distributions": "Distributions",
|
||
"stock": "Stock",
|
||
"reports": "Reports"
|
||
},
|
||
"auth": {
|
||
"login": "Sign in",
|
||
"logout": "Sign out",
|
||
"email": "Email address",
|
||
"password": "Password",
|
||
"invalidCredentials": "Invalid credentials",
|
||
"sessionExpired": "Session expired"
|
||
}
|
||
}
|
||
```
|
||
|
||
5. Update `app/[locale]/layout.tsx` to force dark mode with locale:
|
||
```tsx
|
||
import { NextIntlClientProvider } from "next-intl"
|
||
import { getMessages } from "next-intl/server"
|
||
|
||
export default async function LocaleLayout({ children, params }) {
|
||
const { locale } = await params
|
||
const messages = await getMessages()
|
||
|
||
return (
|
||
<html lang={locale} className="dark">
|
||
<body>
|
||
<NextIntlClientProvider messages={messages}>
|
||
{children}
|
||
</NextIntlClientProvider>
|
||
</body>
|
||
</html>
|
||
)
|
||
}
|
||
```
|
||
|
||
6. **All UI text uses `useTranslations()` hook** — no hardcoded strings:
|
||
```tsx
|
||
import { useTranslations } from "next-intl"
|
||
|
||
export function SidebarNav() {
|
||
const t = useTranslations("nav")
|
||
return (
|
||
<nav>
|
||
<Link href="/">{t("overview")}</Link>
|
||
<Link href="/members">{t("members")}</Link>
|
||
{/* ... */}
|
||
</nav>
|
||
)
|
||
}
|
||
```
|
||
|
||
7. Create multi-stage Dockerfile:
|
||
```dockerfile
|
||
FROM node:22-alpine AS base
|
||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||
|
||
FROM base AS deps
|
||
WORKDIR /app
|
||
COPY package.json pnpm-lock.yaml ./
|
||
RUN pnpm install --frozen-lockfile
|
||
|
||
FROM base AS builder
|
||
WORKDIR /app
|
||
COPY --from=deps /app/node_modules ./node_modules
|
||
COPY . .
|
||
RUN pnpm build
|
||
|
||
FROM base AS runner
|
||
WORKDIR /app
|
||
ENV NODE_ENV=production
|
||
COPY --from=builder /app/.next/standalone ./
|
||
COPY --from=builder /app/.next/static ./.next/static
|
||
COPY --from=builder /app/public ./public
|
||
EXPOSE 3000
|
||
CMD ["node", "server.js"]
|
||
```
|
||
|
||
8. Configure ESLint flat config + Prettier:
|
||
```javascript
|
||
// eslint.config.mjs
|
||
import { dirname } from "path"
|
||
import { fileURLToPath } from "url"
|
||
import { FlatCompat } from "@eslint/eslintrc"
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||
const compat = new FlatCompat({ baseDirectory: __dirname })
|
||
|
||
export default [
|
||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||
{
|
||
rules: {
|
||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||
},
|
||
},
|
||
]
|
||
```
|
||
|
||
```json
|
||
// .prettierrc
|
||
{
|
||
"semi": false,
|
||
"singleQuote": false,
|
||
"tabWidth": 2,
|
||
"trailingComma": "es5",
|
||
"printWidth": 100
|
||
}
|
||
```
|
||
|
||
Add scripts to `package.json`:
|
||
```json
|
||
{
|
||
"scripts": {
|
||
"lint": "next lint",
|
||
"format": "prettier --write .",
|
||
"format:check": "prettier --check ."
|
||
}
|
||
}
|
||
```
|
||
|
||
9. Add MIT license attribution to `LICENSES` file at repo root.
|
||
|
||
**Acceptance criteria:**
|
||
- [ ] `pnpm dev` starts on port 3000 without errors
|
||
- [ ] Dark theme with green accents renders correctly
|
||
- [ ] German is default locale — navigating to `/` redirects to `/de`
|
||
- [ ] English locale works at `/en`
|
||
- [ ] All UI strings come from `messages/*.json` (no hardcoded text)
|
||
- [ ] `useTranslations()` hook works in client components
|
||
- [ ] `pnpm build` produces standalone output
|
||
- [ ] `pnpm lint` passes with no errors
|
||
- [ ] `pnpm format:check` passes (Prettier)
|
||
- [ ] Security headers (CSP, HSTS, X-Frame-Options) present in responses
|
||
- [ ] Docker build succeeds (node:22-alpine)
|
||
- [ ] `docker compose up` starts all 3 services (db, api, frontend)
|
||
|
||
---
|
||
|
||
### Phase 2: Auth Pages
|
||
|
||
**Goal:** Login page, NextAuth.js with CredentialsProvider, token rotation, role-based routing.
|
||
|
||
**Dependencies:** Phase 1 complete.
|
||
|
||
**Files to create/modify:**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `lib/auth.ts` | Create | NextAuth configuration (section 3.2) |
|
||
| `app/api/auth/[...nextauth]/route.ts` | Create | NextAuth route handler |
|
||
| `app/(auth)/login/page.tsx` | Create | Login form page |
|
||
| `app/(auth)/layout.tsx` | Create | Minimal centered layout for auth pages |
|
||
| `components/cannamanage/login-form.tsx` | Create | Login form component (React Hook Form + Zod) |
|
||
| `middleware.ts` | Create | Auth middleware — protect dashboard routes |
|
||
| `types/next-auth.d.ts` | Create | Extend NextAuth session/JWT types |
|
||
| `types/api.ts` | Create | Backend DTO type definitions |
|
||
| `lib/api-client.ts` | Create | Server-side API client with auth headers |
|
||
|
||
**Implementation details:**
|
||
|
||
1. Login form with React Hook Form + Zod validation:
|
||
```tsx
|
||
const loginSchema = z.object({
|
||
email: z.string().email("Ungültige E-Mail-Adresse"),
|
||
password: z.string().min(8, "Mindestens 8 Zeichen"),
|
||
})
|
||
```
|
||
|
||
2. NextAuth middleware for route protection:
|
||
```typescript
|
||
// middleware.ts
|
||
export { auth as middleware } from "@/lib/auth"
|
||
export const config = {
|
||
matcher: ["/(dashboard)(.*)"],
|
||
}
|
||
```
|
||
|
||
3. Role-based redirect after login:
|
||
- `ADMIN` → `/` (dashboard overview)
|
||
- `STAFF` → `/` (same dashboard, limited sidebar items)
|
||
- `MEMBER` → `/portal` (future — redirect to login with message for now)
|
||
|
||
4. Token refresh: automatic via NextAuth `jwt` callback (section 3.2). If refresh fails → redirect to `/login`.
|
||
|
||
5. Error handling: display toast on invalid credentials, show "Session expired" on 401 redirect.
|
||
|
||
**Backend change required:**
|
||
- Add CORS config to `SecurityConfig.java` (section 4.1) to allow `localhost:3000` during dev.
|
||
|
||
**Acceptance criteria:**
|
||
- [ ] Login page renders at `/login`
|
||
- [ ] Valid credentials → redirects to dashboard
|
||
- [ ] Invalid credentials → shows error toast
|
||
- [ ] Protected routes redirect to `/login` when unauthenticated
|
||
- [ ] Token auto-refreshes before expiry (no visible interruption)
|
||
- [ ] Role stored in session and accessible in components
|
||
- [ ] Logout clears session and redirects to `/login`
|
||
|
||
---
|
||
|
||
### Phase 3: Admin Dashboard
|
||
|
||
**Goal:** Overview page with club stats KPIs, sidebar navigation, quick actions.
|
||
|
||
**Dependencies:** Phase 2 complete.
|
||
|
||
**Files to create/modify:**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `app/(dashboard)/layout.tsx` | Modify | Configure Shadboard sidebar with CannaManage navigation |
|
||
| `app/(dashboard)/page.tsx` | Create | Dashboard overview (stats cards + quick actions) |
|
||
| `components/cannamanage/stats-cards.tsx` | Create | KPI cards (members, distributions, stock, quota) |
|
||
| `components/cannamanage/quick-actions.tsx` | Create | Quick action buttons (record distribution, add member) |
|
||
| `components/cannamanage/recent-distributions.tsx` | Create | Last 5 distributions mini-table |
|
||
| `hooks/use-club-stats.ts` | Create | SWR/fetch hook for `/clubs/me/stats` |
|
||
| `lib/navigation.ts` | Create | Sidebar nav items config |
|
||
|
||
**Sidebar navigation structure:**
|
||
|
||
```typescript
|
||
// lib/navigation.ts
|
||
export const sidebarItems = [
|
||
{ title: "Übersicht", href: "/", icon: LayoutDashboard },
|
||
{ title: "Mitglieder", href: "/members", icon: Users },
|
||
{ title: "Ausgabe", href: "/distributions", icon: Cannabis },
|
||
{ title: "Bestand", href: "/stock", icon: Package },
|
||
{ title: "Berichte", href: "/reports", icon: FileText },
|
||
]
|
||
```
|
||
|
||
**Dashboard overview fetches:**
|
||
- `GET /api/v1/clubs/me/stats` → `ClubStats` (8 KPI values)
|
||
- `GET /api/v1/distributions?page=0&size=5&sort=distributedAt,desc` → recent distributions
|
||
|
||
**Stats cards layout (2×4 grid on desktop, 1×4 on mobile):**
|
||
|
||
| Card | Value | Icon | Color |
|
||
|------|-------|------|-------|
|
||
| Aktive Mitglieder | `activeMembers` / `totalMembers` | Users | green |
|
||
| Ausgaben heute | `totalDistributionsThisMonth` | Cannabis | blue |
|
||
| Bestand (Chargen) | `activeBatches` | Package | amber |
|
||
| Monatliche Menge | `totalGramsDistributedThisMonth` g | Scale | purple |
|
||
|
||
**"Today's Distribution Summary" widget (below stats cards):**
|
||
|
||
```tsx
|
||
// components/cannamanage/today-summary.tsx
|
||
// Compact card showing:
|
||
// - Total distributions today (count)
|
||
// - Total grams distributed today
|
||
// - Which staff members recorded distributions
|
||
// - Comparison badge: "↑12% vs yesterday" or "↓5% vs last week"
|
||
interface TodaySummaryProps {
|
||
distributionCount: number
|
||
totalGrams: number
|
||
staffBreakdown: { name: string; count: number }[]
|
||
comparisonVsYesterday: number // percentage delta
|
||
}
|
||
```
|
||
|
||
**Quick member search (global, in header — Cmd+K):**
|
||
|
||
```tsx
|
||
// components/cannamanage/global-search.tsx
|
||
// Type-ahead search in the dashboard header (Cmd+K shortcut)
|
||
// Searches members by name/email/membership number
|
||
// Shows top 5 results with name, status badge
|
||
// Click → navigates to /members/{id}
|
||
// Uses: GET /api/v1/members?search={term}&size=5
|
||
```
|
||
|
||
**Low stock alert badge:**
|
||
- In sidebar nav item "Bestand": show red dot badge when any batch has < 100g remaining
|
||
- Badge count = number of low-stock batches
|
||
- API: `GET /api/v1/stock/batches?status=AVAILABLE` → filter client-side where qty < 100
|
||
|
||
**Acceptance criteria:**
|
||
- [ ] Dashboard loads with 4 stat cards from live backend data
|
||
- [ ] "Today's summary" widget shows distribution count, grams, and staff breakdown
|
||
- [ ] Daily comparison indicator shows delta percentage vs yesterday
|
||
- [ ] Global member search (Cmd+K) in header with type-ahead results
|
||
- [ ] Low stock alert badge on sidebar "Bestand" nav item when batches < 100g
|
||
- [ ] Sidebar navigation works (all links navigate correctly)
|
||
- [ ] Quick action "Neue Ausgabe" navigates to `/distributions/new`
|
||
- [ ] Quick action "Mitglied hinzufügen" navigates to `/members/new`
|
||
- [ ] Recent distributions table shows last 5 entries
|
||
- [ ] Loading states shown while data fetches
|
||
- [ ] Error state shown if API unreachable
|
||
- [ ] Responsive: mobile collapses sidebar to hamburger menu
|
||
|
||
---
|
||
|
||
### Phase 4: Member Management
|
||
|
||
**Goal:** Member list with TanStack Table, detail/edit view, add member form.
|
||
|
||
**Dependencies:** Phase 3 complete (sidebar navigation exists).
|
||
|
||
**Files to create/modify:**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `app/(dashboard)/members/page.tsx` | Create | Member list page |
|
||
| `app/(dashboard)/members/[id]/page.tsx` | Create | Member detail/edit page |
|
||
| `app/(dashboard)/members/new/page.tsx` | Create | Add member form |
|
||
| `components/cannamanage/members/member-table.tsx` | Create | TanStack Table with columns |
|
||
| `components/cannamanage/members/member-columns.tsx` | Create | Column definitions |
|
||
| `components/cannamanage/members/member-form.tsx` | Create | Shared create/edit form |
|
||
| `components/cannamanage/members/member-status-badge.tsx` | Create | Status badge component |
|
||
| `hooks/use-members.ts` | Create | Data fetching + pagination hook |
|
||
| `lib/validations/member.ts` | Create | Zod schemas for member forms |
|
||
|
||
**TanStack Table columns:**
|
||
|
||
| Column | Field | Features |
|
||
|--------|-------|----------|
|
||
| Name | `lastName, firstName` | Sortable, clickable (→ detail) |
|
||
| E-Mail | `email` | Sortable |
|
||
| Mitgliedsnr. | `membershipNumber` | Sortable |
|
||
| Status | `status` | Filter (dropdown), badge |
|
||
| Alter | calculated from `dateOfBirth` | — |
|
||
| U21 | `under21` | Badge (warning color) |
|
||
| Mitglied seit | `membershipDate` | Sortable |
|
||
|
||
**Features:**
|
||
- Server-side pagination: `?page=0&size=20&sort=lastName,asc`
|
||
- Client-side column visibility toggle
|
||
- Search: debounced filter by name/email (query param `?search=`)
|
||
- Status filter dropdown (ACTIVE, INACTIVE, SUSPENDED, PENDING)
|
||
|
||
**Member form (shared for create + edit):**
|
||
```typescript
|
||
const memberSchema = z.object({
|
||
firstName: z.string().min(2, "Mindestens 2 Zeichen"),
|
||
lastName: z.string().min(2, "Mindestens 2 Zeichen"),
|
||
email: z.string().email("Ungültige E-Mail"),
|
||
dateOfBirth: z.string().refine(isValidDate, "Ungültiges Datum"),
|
||
membershipDate: z.string().optional(),
|
||
})
|
||
```
|
||
|
||
**API endpoints consumed:**
|
||
- `GET /api/v1/members?page={p}&size={s}&sort={field},{dir}` — paginated list
|
||
- `GET /api/v1/members/{id}` — single member detail
|
||
- `POST /api/v1/members` — create member
|
||
- `PUT /api/v1/members/{id}` — update member
|
||
|
||
**Acceptance criteria:**
|
||
- [ ] Member list renders with pagination (20 per page)
|
||
- [ ] Sorting works on all sortable columns
|
||
- [ ] Search filters members by name/email
|
||
- [ ] Status filter dropdown works
|
||
- [ ] Clicking a row navigates to detail page
|
||
- [ ] Edit form pre-fills current values
|
||
- [ ] Add member form validates and submits
|
||
- [ ] Success/error toasts after create/update
|
||
- [ ] Under-21 badge renders in warning color
|
||
- [ ] Empty state shown when no members match filter
|
||
|
||
---
|
||
|
||
### Phase 5: Distribution Recording
|
||
|
||
**Goal:** Distribution form with real-time quota check, member/batch selection, history table.
|
||
|
||
**Dependencies:** Phase 4 complete (member lookup exists).
|
||
|
||
**Files to create/modify:**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `app/(dashboard)/distributions/page.tsx` | Create | Distribution history list |
|
||
| `app/(dashboard)/distributions/new/page.tsx` | Create | New distribution form |
|
||
| `components/cannamanage/distributions/distribution-form.tsx` | Create | Multi-step distribution form |
|
||
| `components/cannamanage/distributions/quota-indicator.tsx` | Create | Visual quota remaining widget |
|
||
| `components/cannamanage/distributions/member-search.tsx` | Create | Combobox member search |
|
||
| `components/cannamanage/distributions/batch-select.tsx` | Create | Batch selector (only AVAILABLE) |
|
||
| `components/cannamanage/distributions/distribution-table.tsx` | Create | History table |
|
||
| `hooks/use-distributions.ts` | Create | Distribution data hook |
|
||
| `hooks/use-quota.ts` | Create | Real-time quota fetch |
|
||
| `lib/validations/distribution.ts` | Create | Zod schema |
|
||
|
||
**Distribution form flow:**
|
||
|
||
```
|
||
Step 1: Select Member
|
||
→ Combobox with debounced search (GET /members?search=...)
|
||
→ On select: fetch quota (GET /compliance/quota/{memberId})
|
||
→ Show QuotaIndicator (remaining today + this month)
|
||
|
||
Step 2: Select Batch + Amount
|
||
→ Dropdown: available batches (GET /stock/batches?status=AVAILABLE)
|
||
→ Number input: quantityGrams
|
||
→ Real-time validation: amount ≤ remaining quota
|
||
→ Visual: green/yellow/red indicator based on remaining
|
||
|
||
Step 3: Confirm
|
||
→ Summary card showing: member name, batch code, amount, quota impact
|
||
→ Submit: POST /distributions
|
||
→ On success: toast + redirect to history
|
||
→ On quota violation (409): show specific error from backend
|
||
```
|
||
|
||
**Quota indicator component:**
|
||
|
||
```tsx
|
||
// Visual representation of quota usage
|
||
// Bar: [████████░░] 72g / 100g remaining (green)
|
||
// Bar: [██████████] 5g / 100g remaining (red/warning)
|
||
interface QuotaIndicatorProps {
|
||
totalAllowed: number
|
||
totalUsed: number
|
||
remaining: number
|
||
under21: boolean
|
||
}
|
||
```
|
||
|
||
**API endpoints consumed:**
|
||
- `GET /api/v1/members?search={term}` — member search
|
||
- `GET /api/v1/compliance/quota/{memberId}` — current quota
|
||
- `GET /api/v1/stock/batches?status=AVAILABLE` — available batches
|
||
- `POST /api/v1/distributions` — record distribution
|
||
- `GET /api/v1/distributions?page={p}&size={s}` — history
|
||
|
||
**Acceptance criteria:**
|
||
- [ ] Member search combobox shows filtered results
|
||
- [ ] Selecting a member triggers quota fetch
|
||
- [ ] Quota indicator shows remaining grams (color-coded)
|
||
- [ ] Under-21 members show reduced quota (25g/month) with warning
|
||
- [ ] Batch dropdown only shows AVAILABLE batches
|
||
- [ ] Amount input validates against remaining quota
|
||
- [ ] Form submission succeeds → toast + redirect
|
||
- [ ] Quota violation (409) shows meaningful error
|
||
- [ ] Distribution history table with pagination
|
||
- [ ] Distribution history shows member name, batch code, amount, date, staff
|
||
|
||
---
|
||
|
||
### Phase 6: Stock/Batch Management
|
||
|
||
**Goal:** Batch list with status badges, add batch form, stock level chart.
|
||
|
||
**Dependencies:** Phase 3 complete (can run independently of Phase 4-5).
|
||
|
||
**Files to create/modify:**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `app/(dashboard)/stock/page.tsx` | Create | Stock overview (chart + batch list) |
|
||
| `app/(dashboard)/stock/new/page.tsx` | Create | Add batch form |
|
||
| `components/cannamanage/stock/batch-table.tsx` | Create | Batch table |
|
||
| `components/cannamanage/stock/batch-status-badge.tsx` | Create | Status badge (AVAILABLE/RECALLED/DEPLETED) |
|
||
| `components/cannamanage/stock/stock-chart.tsx` | Create | Recharts bar chart |
|
||
| `components/cannamanage/stock/add-batch-form.tsx` | Create | New batch form |
|
||
| `hooks/use-batches.ts` | Create | Batch data hook |
|
||
| `lib/validations/batch.ts` | Create | Zod schema |
|
||
|
||
**Stock overview page layout:**
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ Stock Level Chart (Recharts BarChart)│
|
||
│ X: batch code, Y: quantity (grams) │
|
||
│ Color: green=AVAILABLE, red=RECALLED│
|
||
└─────────────────────────────────────┘
|
||
┌─────────────────────────────────────┐
|
||
│ Batch Table (TanStack Table) │
|
||
│ Columns: Code | Strain | Qty | │
|
||
│ Harvest | Status | Flag │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
**Recharts bar chart config:**
|
||
```tsx
|
||
<BarChart data={batches.filter(b => b.status !== "DEPLETED")}>
|
||
<Bar dataKey="quantityGrams" fill="#2ECC71" />
|
||
// RECALLED batches in red
|
||
</BarChart>
|
||
```
|
||
|
||
**Batch form (Zod schema):**
|
||
```typescript
|
||
const batchSchema = z.object({
|
||
strainId: z.string().uuid("Sorte auswählen"),
|
||
quantityGrams: z.number().positive("Menge muss positiv sein"),
|
||
harvestDate: z.string().refine(isValidDate, "Ungültiges Datum"),
|
||
batchCode: z.string().min(3, "Mindestens 3 Zeichen"),
|
||
})
|
||
```
|
||
|
||
**API endpoints consumed:**
|
||
- `GET /api/v1/stock/batches` — all batches
|
||
- `POST /api/v1/stock/batches` — create batch
|
||
- `GET /api/v1/stock/batches?status=AVAILABLE` — chart data
|
||
|
||
**Acceptance criteria:**
|
||
- [ ] Stock chart renders with batch quantities
|
||
- [ ] RECALLED batches shown in red on chart
|
||
- [ ] Batch table with status badges (green/red/gray)
|
||
- [ ] Contamination flag shown as warning icon
|
||
- [ ] Add batch form validates and submits
|
||
- [ ] Success toast after batch creation
|
||
- [ ] New batch appears in table without page refresh
|
||
- [ ] Responsive chart (readable on mobile)
|
||
|
||
---
|
||
|
||
### Phase 7: Reports
|
||
|
||
**Goal:** Report overview page with download buttons, PDF/CSV generation triggers.
|
||
|
||
**Dependencies:** Phase 3 complete (sidebar nav).
|
||
|
||
**Files to create/modify:**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `app/(dashboard)/reports/page.tsx` | Create | Report overview page |
|
||
| `components/cannamanage/reports/report-card.tsx` | Create | Report type card (trigger + download) |
|
||
| `components/cannamanage/reports/month-picker.tsx` | Create | Year/month selector |
|
||
| `components/cannamanage/reports/download-button.tsx` | Create | Download button with format selector |
|
||
| `hooks/use-reports.ts` | Create | Report generation/download hook |
|
||
|
||
**Report overview layout:**
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ Monatsbericht │
|
||
│ [Monat wählen ▼] [PDF] [CSV] [JSON]│
|
||
│ Monthly distribution summary, │
|
||
│ compliance status, quota usage │
|
||
├─────────────────────────────────────┤
|
||
│ Mitgliederliste │
|
||
│ [PDF] [CSV] [JSON] │
|
||
│ Complete member roster with status │
|
||
├─────────────────────────────────────┤
|
||
│ Rückruf-Bericht │
|
||
│ [PDF] [CSV] [JSON] │
|
||
│ Recalled batches + affected members │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
**Download implementation:**
|
||
|
||
```typescript
|
||
async function downloadReport(type: string, format: string, params?: Record<string, string>) {
|
||
const session = await auth()
|
||
const query = new URLSearchParams({ format, ...params }).toString()
|
||
const res = await fetch(`/api/backend/reports/${type}?${query}`, {
|
||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||
})
|
||
|
||
if (format === "json") {
|
||
// Display inline in a modal/sheet
|
||
const data = await res.json()
|
||
return data
|
||
}
|
||
|
||
// PDF/CSV: trigger browser download
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement("a")
|
||
a.href = url
|
||
a.download = `${type}-${new Date().toISOString().slice(0,10)}.${format}`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
```
|
||
|
||
**API endpoints consumed:**
|
||
- `GET /api/v1/reports/monthly?year={y}&month={m}&format=pdf|csv|json`
|
||
- `GET /api/v1/reports/members?format=pdf|csv|json`
|
||
- `GET /api/v1/reports/recall?format=pdf|csv|json`
|
||
|
||
**Acceptance criteria:**
|
||
- [ ] Report overview page shows 3 report types
|
||
- [ ] Month picker works for monthly report
|
||
- [ ] PDF download triggers browser save dialog
|
||
- [ ] CSV download triggers browser save dialog
|
||
- [ ] JSON format shows inline preview in sheet/modal
|
||
- [ ] Loading indicator during report generation
|
||
- [ ] Error toast if report generation fails
|
||
- [ ] Reports page only visible to users with `VIEW_COMPLIANCE_REPORT` permission or ADMIN role
|
||
|
||
---
|
||
|
||
### Phase 8: Member Portal Layout & Auth (Sprint 4.b)
|
||
|
||
**Goal:** Set up the member portal route group with session-based authentication, portal layout shell, and locale support.
|
||
|
||
**Dependencies:** Phase 1 complete (i18n setup), Phase 2 complete (NextAuth exists). Backend portal endpoints already exist from Sprint 3.
|
||
|
||
**Files to create/modify:**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `app/[locale]/(portal)/layout.tsx` | Create | Portal layout shell (simpler than admin — no sidebar, top nav only) |
|
||
| `app/[locale]/(portal)/page.tsx` | Create | Portal dashboard redirect (→ quota view) |
|
||
| `components/cannamanage/portal/portal-header.tsx` | Create | Portal header with member name, locale switcher, logout |
|
||
| `components/cannamanage/portal/portal-nav.tsx` | Create | Top navigation: Übersicht, Historie, Einstellungen |
|
||
| `lib/portal-auth.ts` | Create | Portal-specific auth helpers (session check, member role guard) |
|
||
| `middleware.ts` | Modify | Add portal route protection (member role required) |
|
||
| `messages/de.json` | Modify | Add `portal.*` translation keys |
|
||
| `messages/en.json` | Modify | Add `portal.*` translation keys |
|
||
|
||
**Implementation details:**
|
||
|
||
1. Portal uses the same NextAuth session but with role guard:
|
||
```typescript
|
||
// lib/portal-auth.ts
|
||
import { auth } from "@/lib/auth"
|
||
import { redirect } from "next/navigation"
|
||
|
||
export async function requireMemberRole() {
|
||
const session = await auth()
|
||
if (!session) redirect("/login")
|
||
if (session.user.role !== "MEMBER") redirect("/login?error=unauthorized")
|
||
return session
|
||
}
|
||
```
|
||
|
||
2. Portal layout — minimal, member-facing:
|
||
```tsx
|
||
// app/[locale]/(portal)/layout.tsx
|
||
import { requireMemberRole } from "@/lib/portal-auth"
|
||
import { PortalHeader } from "@/components/cannamanage/portal/portal-header"
|
||
import { PortalNav } from "@/components/cannamanage/portal/portal-nav"
|
||
|
||
export default async function PortalLayout({ children }) {
|
||
const session = await requireMemberRole()
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
<PortalHeader memberName={session.user.name} />
|
||
<PortalNav />
|
||
<main className="container mx-auto px-4 py-6">
|
||
{children}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
3. Portal navigation (top bar, not sidebar):
|
||
```tsx
|
||
const portalNavItems = [
|
||
{ title: t("portal.nav.quota"), href: "/portal", icon: Gauge },
|
||
{ title: t("portal.nav.history"), href: "/portal/history", icon: History },
|
||
{ title: t("portal.nav.settings"), href: "/portal/settings", icon: Settings },
|
||
]
|
||
```
|
||
|
||
4. Backend endpoints consumed (already exist from Sprint 3):
|
||
- `GET /api/v1/portal/dashboard` — quota summary
|
||
- `GET /api/v1/portal/distributions` — distribution history
|
||
- `GET /api/v1/portal/profile` — member profile
|
||
- `PUT /api/v1/portal/profile` — update profile (email, language preference)
|
||
|
||
**Acceptance criteria:**
|
||
- [ ] Portal layout renders at `/de/portal` (German) and `/en/portal` (English)
|
||
- [ ] Members can access portal, admin/staff cannot (role guard)
|
||
- [ ] Unauthenticated users redirected to `/login`
|
||
- [ ] Portal header shows member name and locale switcher
|
||
- [ ] Top navigation links work correctly
|
||
- [ ] All portal UI strings use translation keys
|
||
- [ ] Dark theme consistent with admin dashboard
|
||
|
||
---
|
||
|
||
### Phase 9: Member Portal Pages (Sprint 4.b)
|
||
|
||
**Goal:** Implement the member-facing pages: quota/dashboard view, distribution history, and profile/settings.
|
||
|
||
**Dependencies:** Phase 8 complete (portal layout + auth).
|
||
|
||
**Files to create/modify:**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `app/[locale]/(portal)/page.tsx` | Modify | Quota dashboard — current quota usage, monthly/daily remaining |
|
||
| `app/[locale]/(portal)/history/page.tsx` | Create | Distribution history table (read-only) |
|
||
| `app/[locale]/(portal)/settings/page.tsx` | Create | Profile view + settings (email, password change, language) |
|
||
| `components/cannamanage/portal/quota-dashboard.tsx` | Create | Visual quota display (progress ring/bar) |
|
||
| `components/cannamanage/portal/distribution-history-table.tsx` | Create | Read-only history table |
|
||
| `components/cannamanage/portal/profile-form.tsx` | Create | Profile edit form |
|
||
| `hooks/use-portal-data.ts` | Create | Portal-specific data fetching hooks |
|
||
| `lib/validations/portal.ts` | Create | Zod schemas for portal forms |
|
||
| `messages/de.json` | Modify | Add portal page translation keys |
|
||
| `messages/en.json` | Modify | Add portal page translation keys |
|
||
|
||
**Implementation details:**
|
||
|
||
1. **Quota Dashboard** (`/portal`):
|
||
```tsx
|
||
// Displays:
|
||
// - Monthly quota: progress ring (e.g., 35g / 50g used)
|
||
// - Daily limit status: X g remaining today
|
||
// - Under-21 indicator (if applicable): reduced quota warning
|
||
// - Last distribution: date, amount, batch info
|
||
```
|
||
|
||
Visual quota widget with progress bars and color coding:
|
||
```tsx
|
||
interface PortalQuotaProps {
|
||
monthlyAllowed: number // 50g or 25g (under-21)
|
||
monthlyUsed: number
|
||
dailyAllowed: number // ~25g per day
|
||
dailyUsed: number
|
||
under21: boolean
|
||
nextRefillDate: string // ISO date of next month start (quota reset)
|
||
}
|
||
// Renders:
|
||
// 1. Circular progress ring for monthly quota (large, centered)
|
||
// 2. Linear progress bar for daily quota (below ring)
|
||
// 3. Color coding:
|
||
// - Green (<50% used): safe zone
|
||
// - Amber (50-80% used): approaching limit
|
||
// - Red (>80% used): near limit, caution
|
||
// 4. "Next refill" indicator: "Kontingent wird am 01.07. zurückgesetzt"
|
||
// 5. Simple language, no technical jargon:
|
||
// - "Du hast noch 28g diesen Monat" (not "remaining quota: 28g")
|
||
// - "Heute verfügbar: 25g" (not "daily limit remaining: 25g")
|
||
```
|
||
|
||
Color thresholds (CSS classes):
|
||
```tsx
|
||
function getQuotaColor(used: number, allowed: number): string {
|
||
const pct = (used / allowed) * 100
|
||
if (pct < 50) return "text-green-500" // safe
|
||
if (pct < 80) return "text-amber-500" // approaching
|
||
return "text-red-500" // near limit
|
||
}
|
||
```
|
||
|
||
2. **Distribution History** (`/portal/history`):
|
||
```tsx
|
||
// Read-only table showing member's own distributions
|
||
// Columns: Date | Amount (g) | Batch Code | Staff Member
|
||
// Sorted by date descending
|
||
// Paginated (server-side, 20 per page)
|
||
```
|
||
|
||
API: `GET /api/v1/portal/distributions?page={p}&size=20`
|
||
|
||
3. **Profile/Settings** (`/portal/settings`):
|
||
```tsx
|
||
// Sections:
|
||
// - Personal info (read-only: name, membership number, member since)
|
||
// - Contact (editable: email)
|
||
// - Password change (current + new + confirm)
|
||
// - Language preference (de/en dropdown — updates locale)
|
||
```
|
||
|
||
Forms:
|
||
```typescript
|
||
const profileSchema = z.object({
|
||
email: z.string().email(),
|
||
})
|
||
|
||
const passwordSchema = z.object({
|
||
currentPassword: z.string().min(8),
|
||
newPassword: z.string().min(8),
|
||
confirmPassword: z.string().min(8),
|
||
}).refine(data => data.newPassword === data.confirmPassword, {
|
||
message: "Passwörter stimmen nicht überein",
|
||
path: ["confirmPassword"],
|
||
})
|
||
```
|
||
|
||
**API endpoints consumed:**
|
||
- `GET /api/v1/portal/dashboard` — quota status (monthly + daily)
|
||
- `GET /api/v1/portal/distributions?page={p}&size={s}` — distribution history
|
||
- `GET /api/v1/portal/profile` — current profile
|
||
- `PUT /api/v1/portal/profile` — update email
|
||
- `POST /api/v1/auth/change-password` — password change (new endpoint needed)
|
||
|
||
**Backend change required (minor):**
|
||
- Add `POST /api/v1/auth/change-password` endpoint (accepts `currentPassword` + `newPassword`). This is a small addition to the existing `AuthController`.
|
||
|
||
**Acceptance criteria:**
|
||
- [ ] Quota dashboard shows monthly + daily usage with visual progress
|
||
- [ ] Under-21 members see reduced quota (25g) with appropriate warning
|
||
- [ ] Distribution history table renders with pagination
|
||
- [ ] History shows date, amount, batch code, staff name
|
||
- [ ] Profile page shows personal info (read-only) + editable email
|
||
- [ ] Password change form validates and submits
|
||
- [ ] Language preference switch changes locale immediately
|
||
- [ ] All text uses translation keys (no hardcoded strings)
|
||
- [ ] Empty states shown when no distributions exist
|
||
- [ ] Loading skeletons during data fetch
|
||
|
||
---
|
||
|
||
## 6. Cross-Cutting Concerns
|
||
|
||
### 6.1 Error Handling & Empty States
|
||
|
||
Global error boundary + per-page error states:
|
||
|
||
```tsx
|
||
// app/(dashboard)/error.tsx — catches any unhandled error
|
||
"use client"
|
||
export default function DashboardError({ error, reset }) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-4">
|
||
<h2>Ein Fehler ist aufgetreten</h2>
|
||
<p className="text-muted-foreground">{error.message}</p>
|
||
<Button onClick={reset}>Erneut versuchen</Button>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
**Empty states** — every list page must have a friendly empty state:
|
||
|
||
| Page | Empty State Message | CTA |
|
||
|------|-------------------|-----|
|
||
| Members list | "Noch keine Mitglieder vorhanden" + illustration | "Erstes Mitglied anlegen" button |
|
||
| Distribution history | "Heute noch keine Ausgaben" + illustration | "Neue Ausgabe erfassen" button |
|
||
| Batch list | "Kein Bestand vorhanden" + illustration | "Erste Charge anlegen" button |
|
||
| Portal history | "Du hast noch keine Ausgaben erhalten" | — (no CTA, member can't create) |
|
||
| Reports | "Noch keine Berichte verfügbar" | — |
|
||
|
||
**Error boundaries with retry:**
|
||
- Each route group (`(dashboard)`, `(portal)`) has its own `error.tsx`
|
||
- Network errors show "Verbindung zum Server fehlgeschlagen" + retry button
|
||
- 403 errors show "Keine Berechtigung" + link to dashboard
|
||
|
||
**Offline indicator:**
|
||
- Detect `navigator.onLine` status
|
||
- Show subtle banner at top: "Keine Internetverbindung — Daten möglicherweise veraltet"
|
||
|
||
### 6.2 Loading States
|
||
|
||
Shadboard provides skeleton components. Use for:
|
||
- Dashboard stat cards → 4 skeleton cards (pulse animation)
|
||
- Tables → skeleton rows (5 rows with shimmer)
|
||
- Forms → spinner on submit button (disabled state)
|
||
- Quota ring → circular skeleton placeholder
|
||
- Charts → rectangular skeleton with shimmer
|
||
|
||
### 6.3 Toast Notifications
|
||
|
||
Use Shadboard's built-in toast system (based on sonner):
|
||
- Success: green accent for CRUD operations
|
||
- Error: red for API failures
|
||
- Warning: amber for quota warnings
|
||
- **Screen reader announcements:** toasts use `role="alert"` with `aria-live="polite"` for non-critical, `aria-live="assertive"` for errors
|
||
|
||
### 6.4 Responsive Design
|
||
|
||
Shadboard's layout handles:
|
||
- Desktop (≥1024px): fixed sidebar + content
|
||
- Tablet (768-1023px): collapsible sidebar
|
||
- Mobile (<768px): bottom nav or hamburger menu
|
||
|
||
### 6.5 Accessibility (WCAG 2.1 AA)
|
||
|
||
**Target:** WCAG 2.1 Level AA compliance across all pages.
|
||
|
||
| Requirement | Implementation |
|
||
|-------------|---------------|
|
||
| Focus management | Visible focus ring: `ring-2 ring-ring ring-offset-2 ring-offset-background` on all interactive elements |
|
||
| Skip navigation | `<a href="#main" className="sr-only focus:not-sr-only focus:absolute ...">Zum Hauptinhalt</a>` in root layout |
|
||
| Color contrast | Minimum 4.5:1 for normal text, 3:1 for large text. Verified: `#E6EDF3` on `#0D1117` = 13.5:1 ✅, `#2ECC71` on `#0D1117` = 7.5:1 ✅ |
|
||
| Keyboard navigation | All flows completable via keyboard. Tab order logical. Escape closes modals/dropdowns. |
|
||
| aria-labels | All icon-only buttons have `aria-label`. All form inputs have associated `<label>`. |
|
||
| Screen reader announcements | Toast notifications, quota updates, form submission results announced via `aria-live` regions |
|
||
| Reduced motion | Respect `prefers-reduced-motion` — disable animations/transitions when set |
|
||
| Images/icons | All decorative icons have `aria-hidden="true"`. Informational icons have `aria-label`. |
|
||
|
||
### 6.6 Security Hardening
|
||
|
||
**CSP headers:** Configured in `next.config.ts` (section 3.5) — `Content-Security-Policy`, `X-Frame-Options`, `HSTS`, `X-Content-Type-Options`.
|
||
|
||
**Auth security (already in backend, frontend must respect):**
|
||
- Rate limiting on auth endpoints: backend enforces 5 attempts / 15 minutes per IP
|
||
- Frontend shows "Zu viele Anmeldeversuche" message on 429 response
|
||
- CSRF protection: NextAuth.js v5 includes built-in CSRF token verification (via `csrfToken` in forms)
|
||
|
||
**Cookie security (NextAuth.js defaults, verified):**
|
||
- `HttpOnly: true` — session cookie not accessible via JavaScript
|
||
- `Secure: true` — only sent over HTTPS (in production)
|
||
- `SameSite: Lax` — prevents CSRF from third-party sites
|
||
- JWT never exposed to client-side JavaScript — stored server-side in encrypted session
|
||
|
||
**Secret management:**
|
||
- `.env.local` contains `NEXTAUTH_SECRET` — **must** be a random 32+ character string in production
|
||
- Docker Compose uses `${NEXTAUTH_SECRET}` variable reference (not hardcoded in `docker-compose.yml`)
|
||
- Production deployment: inject via CI/CD secrets or cloud secret manager (AWS Secrets Manager / Vault)
|
||
|
||
**Input sanitization:**
|
||
- All form inputs validated with Zod schemas before submission
|
||
- Server-side validation remains the authoritative check (backend already enforces)
|
||
- No `dangerouslySetInnerHTML` usage anywhere — all content rendered via React's default XSS protection
|
||
|
||
### 6.7 Testing Strategy
|
||
|
||
| Layer | Tool | Target Coverage | Convention |
|
||
|-------|------|----------------|------------|
|
||
| Unit tests | **Vitest** | 80% component logic | `*.test.ts` / `*.test.tsx` |
|
||
| Component tests | **React Testing Library** | All interactive components | `*.test.tsx` alongside component |
|
||
| E2E tests | **Playwright** | Critical user flows | `e2e/*.spec.ts` |
|
||
| API mocking | **MSW (Mock Service Worker)** | All API calls in tests | `mocks/handlers.ts` |
|
||
|
||
**Critical E2E flows (Playwright):**
|
||
1. Login → Dashboard → verify stats load
|
||
2. Record distribution → quota enforcement → success toast
|
||
3. Member portal login → view quota → check history
|
||
4. Report download (PDF)
|
||
|
||
**Test file conventions:**
|
||
```
|
||
cannamanage-frontend/
|
||
├── __tests__/ # Unit/component tests (mirrors src structure)
|
||
├── e2e/ # Playwright E2E tests
|
||
│ ├── auth.spec.ts
|
||
│ ├── distribution.spec.ts
|
||
│ ├── portal.spec.ts
|
||
│ └── reports.spec.ts
|
||
├── mocks/ # MSW handlers
|
||
│ ├── handlers.ts
|
||
│ └── server.ts
|
||
├── vitest.config.ts
|
||
└── playwright.config.ts
|
||
```
|
||
|
||
**Vitest config:**
|
||
```typescript
|
||
// vitest.config.ts
|
||
import { defineConfig } from "vitest/config"
|
||
import react from "@vitejs/plugin-react"
|
||
import path from "path"
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
test: {
|
||
environment: "jsdom",
|
||
setupFiles: ["./vitest.setup.ts"],
|
||
globals: true,
|
||
},
|
||
resolve: {
|
||
alias: { "@": path.resolve(__dirname, ".") },
|
||
},
|
||
})
|
||
```
|
||
|
||
**Package additions for testing:**
|
||
```json
|
||
{
|
||
"devDependencies": {
|
||
"vitest": "^3.x",
|
||
"@testing-library/react": "^16.x",
|
||
"@testing-library/jest-dom": "^6.x",
|
||
"msw": "^2.x",
|
||
"@playwright/test": "^1.x",
|
||
"jsdom": "^25.x"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.8 Immutable Audit Trail
|
||
|
||
**Backend enforcement (already exists):**
|
||
- Distribution records cannot be edited or deleted after creation (`DISTRIBUTION_IMMUTABLE` error code)
|
||
- All distributions are append-only — no UPDATE/DELETE on `distributions` table
|
||
- Server-generated timestamps (`distributedAt` set by backend, not client-submitted)
|
||
|
||
**Frontend enforcement:**
|
||
- Distribution history shows **no edit/delete buttons** on past records
|
||
- "Locked" indicator (🔒 icon) on each distribution row in admin view
|
||
- Timestamp displayed is always the server timestamp in `Europe/Berlin` timezone with TZ indicator
|
||
|
||
**Audit Log page (admin dashboard):**
|
||
|
||
| File | Action | Description |
|
||
|------|--------|-------------|
|
||
| `app/(dashboard)/audit/page.tsx` | Create | Read-only audit log viewer |
|
||
| `components/cannamanage/audit/audit-table.tsx` | Create | Paginated event log table |
|
||
|
||
```tsx
|
||
// Audit log table columns:
|
||
// Zeitpunkt | Aktion | Mitglied | Menge | Charge | Mitarbeiter
|
||
// All read-only, no editing capabilities
|
||
// Filters: date range, staff member, action type
|
||
// API: GET /api/v1/distributions?page={p}&size={s}&sort=distributedAt,desc
|
||
// (Reuses existing distribution endpoint — distributions ARE the audit trail)
|
||
```
|
||
|
||
**Compliance export (PDF audit report for Behörde inspections):**
|
||
- New button on reports page: "Audit-Bericht exportieren"
|
||
- Generates PDF with all distributions for a given date range
|
||
- Includes: timestamp, member (anonymized ID), quantity, batch code, staff name
|
||
- All timestamps in `Europe/Berlin` timezone with explicit `(MEZ/MESZ)` indicator
|
||
- Footer: "Generiert am {date} — Alle Zeitstempel in Europe/Berlin"
|
||
|
||
### 6.9 Compliance Timestamp Verification
|
||
|
||
**Server-authoritative timestamps:**
|
||
- Distribution `distributedAt` is always set by the backend (`LocalDateTime.now(ZoneId.of("Europe/Berlin"))`)
|
||
- Frontend displays server timestamp, never `new Date()` from client
|
||
- All API responses include timestamps in ISO 8601 with timezone offset
|
||
|
||
**Timezone display rules:**
|
||
- All dates/times shown to users include timezone: `"12.06.2026, 14:32 MEZ"`
|
||
- Monthly report date ranges are timezone-aware: `01.06.2026 00:00 MEZ` — `30.06.2026 23:59 MEZ`
|
||
- Use `Intl.DateTimeFormat` with `timeZone: "Europe/Berlin"` for all date formatting
|
||
|
||
```typescript
|
||
// lib/date-utils.ts
|
||
export function formatDateTime(isoString: string): string {
|
||
return new Intl.DateTimeFormat("de-DE", {
|
||
dateStyle: "medium",
|
||
timeStyle: "short",
|
||
timeZone: "Europe/Berlin",
|
||
}).format(new Date(isoString))
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Open Questions (All RESOLVED)
|
||
|
||
- [x] **Q1:** Should we add a client-side data cache (SWR/React Query) or keep it simple with plain fetch + server components?
|
||
- **RESOLVED:** Start simple with server components + plain fetch. Add SWR/React Query only if UX demands real-time updates (defer to Sprint 5).
|
||
- [x] **Q2:** Shadboard starter-kit may bundle its own auth example — do we strip it and replace with our NextAuth config, or adapt what's there?
|
||
- **RESOLVED:** Strip Shadboard's auth example and replace with our NextAuth CredentialsProvider config. Shadboard's auth is demo-only.
|
||
- [x] **Q3:** Do we need an API health check endpoint on the backend for the frontend to verify connectivity on startup?
|
||
- **RESOLVED:** Not needed for MVP. Next.js will show error states naturally when API is unreachable. Add health endpoint in Sprint 5 if needed for Docker healthchecks.
|
||
- [x] **Q4:** German language — hardcode all UI strings in Phase 1 or set up `next-intl` from the start for future i18n?
|
||
- **RESOLVED:** **i18n from Day 1.** Set up `next-intl` in Phase 1 with German (default) + English. All UI strings use `useTranslations()` hook. No hardcoded strings.
|
||
|
||
---
|
||
|
||
## 8. Risk Assessment
|
||
|
||
| Risk | Probability | Impact | Mitigation |
|
||
|------|------------|--------|------------|
|
||
| Shadboard starter-kit version mismatch (Next.js/React) | Low | High | Pin versions in package.json, test build immediately |
|
||
| NextAuth.js v5 breaking changes (still in beta) | Medium | Medium | Use stable patterns from docs, avoid experimental features |
|
||
| Backend CORS issues in dev | Low | Low | Fallback: use Next.js rewrites (no CORS needed) |
|
||
| TanStack Table learning curve | Low | Low | Shadboard may already include table examples |
|
||
| Token refresh race condition | Medium | Medium | NextAuth handles this internally; add retry logic |
|
||
| Dark theme contrast issues | Low | Low | Test with Lighthouse accessibility audit |
|
||
|
||
---
|
||
|
||
## 9. Definition of Done (Sprint 4)
|
||
|
||
### Sprint 4.a (Admin Dashboard)
|
||
- [ ] Phases 1–7 implemented and functional
|
||
- [ ] Login → Dashboard → CRUD flows work end-to-end
|
||
- [ ] i18n working: German default, English available, no hardcoded strings
|
||
- [ ] `pnpm build` succeeds without errors (Node 22 LTS)
|
||
- [ ] `docker compose up` starts all services (node:22-alpine)
|
||
- [ ] No TypeScript errors (`pnpm type-check`)
|
||
- [ ] Responsive on desktop + mobile viewport
|
||
- [ ] Dark theme with green accents consistent across all pages
|
||
- [ ] README.md in `cannamanage-frontend/` with setup instructions
|
||
|
||
### Sprint 4.b (Member Portal)
|
||
- [ ] Phases 8–9 implemented and functional
|
||
- [ ] Member login → Portal quota view → History → Settings flows work
|
||
- [ ] Portal uses same i18n setup (de/en), all strings translated
|
||
- [ ] Role guard: only MEMBER role can access portal routes
|
||
- [ ] Portal layout distinct from admin (top nav, no sidebar)
|