# 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( path: string, options: RequestInit = {} ): Promise { 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= ``` > **⚠️ 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 ( {children} ) } ``` 6. **All UI text uses `useTranslations()` hook** — no hardcoded strings: ```tsx import { useTranslations } from "next-intl" export function SidebarNav() { const t = useTranslations("nav") return ( ) } ``` 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 b.status !== "DEPLETED")}> // RECALLED batches in red ``` **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) { 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 (
{children}
) } ``` 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 (

Ein Fehler ist aufgetreten

{error.message}

) } ``` **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 | `Zum Hauptinhalt` 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 `