# 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)
