feat: Sprint 4 complete — frontend MVP (admin dashboard + member portal)

Shadboard starter-kit (Next.js 15 + React 19 + shadcn/ui + Tailwind 4)

Sprint 4.a — Admin Dashboard:
- Auth: NextAuth.js v5, login page, middleware, token rotation
- Dashboard: KPI cards, Recharts stock chart, quick actions
- Members: TanStack Table (search/sort/paginate), add/edit forms
- Distributions: multi-step form, real-time quota check, history
- Stock: batch management, recall dialog, bar chart
- Reports: monthly/member-list/recall, PDF/CSV download, preview

Sprint 4.b — Member Portal:
- Separate route group with top-nav layout (mobile-first)
- Quota dashboard with radial SVG progress indicators
- Distribution history with month filter
- Profile/settings with password change

Cross-cutting:
- i18n: German (default) + English via next-intl
- Dark + light mode (next-themes, user-togglable)
- Playwright E2E tests (6/6 green)
- Docker multi-stage build (node:22-alpine)
- API proxy via Next.js rewrites

Tech: Next.js 15.2.8, React 19, Tailwind 4, NextAuth v5,
TanStack Table, Recharts, Zod, React Hook Form, Playwright
This commit is contained in:
Patrick Plate
2026-06-12 17:18:38 +02:00
parent a1d4ba44e3
commit fe6e96dd3f
143 changed files with 23568 additions and 0 deletions
@@ -0,0 +1,222 @@
"use client"
import { useRouter } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { ArrowLeft } from "lucide-react"
import { mockStrains } from "@/data/mock/stock"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
const batchSchema = z.object({
strainName: z.string().min(1, "Strain name is required"),
amount: z.coerce.number().positive("Amount must be greater than 0"),
thcPercent: z.coerce
.number()
.min(0, "THC must be at least 0%")
.max(30, "THC cannot exceed 30%"),
cbdPercent: z.coerce
.number()
.min(0, "CBD must be at least 0%")
.max(30, "CBD cannot exceed 30%"),
supplier: z.string().min(1, "Supplier is required"),
harvestDate: z.string().min(1, "Harvest date is required"),
notes: z.string().optional(),
})
type BatchFormValues = z.infer<typeof batchSchema>
export default function NewBatchPage() {
const t = useTranslations("stock")
const router = useRouter()
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<BatchFormValues>({
resolver: zodResolver(batchSchema),
defaultValues: {
strainName: "",
amount: undefined,
thcPercent: undefined,
cbdPercent: undefined,
supplier: "",
harvestDate: "",
notes: "",
},
})
function handleStrainChange(e: React.ChangeEvent<HTMLSelectElement>) {
const strainName = e.target.value
setValue("strainName", strainName)
const strain = mockStrains.find((s) => s.name === strainName)
if (strain) {
setValue("thcPercent", strain.defaultThcPercent)
setValue("cbdPercent", strain.defaultCbdPercent)
}
}
function onSubmit(_data: BatchFormValues) {
// Mock: just show toast and redirect
toast.success(t("created"))
router.push("/stock")
}
return (
<div className="mx-auto max-w-2xl space-y-6 p-4 md:p-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => router.push("/stock")}>
<ArrowLeft className="mr-1 h-4 w-4" />
{t("title")}
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>{t("addBatch")}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Strain Name */}
<div className="space-y-2">
<Label htmlFor="strainName">{t("strainName")}</Label>
<Select
id="strainName"
{...register("strainName")}
onChange={handleStrainChange}
>
<option value="">{t("strainName")}...</option>
{mockStrains.map((strain) => (
<option key={strain.id} value={strain.name}>
{strain.name}
</option>
))}
</Select>
{errors.strainName && (
<p className="text-sm text-destructive">
{errors.strainName.message}
</p>
)}
</div>
{/* Amount */}
<div className="space-y-2">
<Label htmlFor="amount">{t("amount")}</Label>
<Input
id="amount"
type="number"
step="1"
min="1"
placeholder="500"
{...register("amount")}
/>
{errors.amount && (
<p className="text-sm text-destructive">
{errors.amount.message}
</p>
)}
</div>
{/* THC and CBD side by side */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="thcPercent">{t("thc")}</Label>
<Input
id="thcPercent"
type="number"
step="0.1"
min="0"
max="30"
placeholder="20.0"
{...register("thcPercent")}
/>
{errors.thcPercent && (
<p className="text-sm text-destructive">
{errors.thcPercent.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="cbdPercent">{t("cbd")}</Label>
<Input
id="cbdPercent"
type="number"
step="0.1"
min="0"
max="30"
placeholder="2.0"
{...register("cbdPercent")}
/>
{errors.cbdPercent && (
<p className="text-sm text-destructive">
{errors.cbdPercent.message}
</p>
)}
</div>
</div>
{/* Supplier */}
<div className="space-y-2">
<Label htmlFor="supplier">{t("supplier")}</Label>
<Input
id="supplier"
placeholder="GreenGrow GmbH"
{...register("supplier")}
/>
{errors.supplier && (
<p className="text-sm text-destructive">
{errors.supplier.message}
</p>
)}
</div>
{/* Harvest Date */}
<div className="space-y-2">
<Label htmlFor="harvestDate">{t("harvestDate")}</Label>
<Input
id="harvestDate"
type="date"
{...register("harvestDate")}
/>
{errors.harvestDate && (
<p className="text-sm text-destructive">
{errors.harvestDate.message}
</p>
)}
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea
id="notes"
placeholder={t("notesPlaceholder")}
rows={3}
{...register("notes")}
/>
</div>
{/* Submit */}
<div className="flex justify-end pt-4">
<Button type="submit" disabled={isSubmitting}>
{t("addBatch")}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}