Files
cannamanage/docs/sprint-4/cannamanage-sprint4-plan.md
Patrick Plate fe6e96dd3f feat: Sprint 4 complete — frontend MVP (admin dashboard + member portal)
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
2026-06-12 17:18:38 +02:00

1693 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 17: Admin Dashboard MVP (~7.5 days)
> - **Sprint 4.b** — Phases 89: 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 17 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 89 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)