Files
cannamanage/cannamanage-frontend/src/app/(dashboard-layout)/distributions/new/page.tsx
T
Patrick Plate fe6e96dd3f feat: Sprint 4 complete — frontend MVP (admin dashboard + member portal)
Shadboard starter-kit (Next.js 15 + React 19 + shadcn/ui + Tailwind 4)

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

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

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

Tech: Next.js 15.2.8, React 19, Tailwind 4, NextAuth v5,
TanStack Table, Recharts, Zod, React Hook Form, Playwright
2026-06-12 17:18:38 +02:00

615 lines
21 KiB
TypeScript

"use client"
import { useCallback, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
AlertCircle,
ArrowLeft,
Check,
ChevronsUpDown,
Info,
Leaf,
ShieldAlert,
User,
} from "lucide-react"
import type { AvailableBatch, Member, QuotaStatus } from "@/types/api"
import { getMockQuota, mockAvailableBatches } from "@/data/mock/distributions"
import { mockMembers } from "@/data/mock/members"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
// Step indicator component
function StepIndicator({
currentStep,
steps,
}: {
currentStep: number
steps: string[]
}) {
return (
<div className="flex items-center gap-2">
{steps.map((step, i) => (
<div key={step} className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
i < currentStep
? "bg-primary text-primary-foreground"
: i === currentStep
? "bg-primary text-primary-foreground ring-primary/30 ring-4"
: "bg-muted text-muted-foreground"
}`}
>
{i < currentStep ? <Check className="h-4 w-4" /> : i + 1}
</div>
<span
className={`hidden text-sm sm:inline ${
i === currentStep ? "font-medium" : "text-muted-foreground"
}`}
>
{step}
</span>
{i < steps.length - 1 && (
<div className="bg-muted mx-2 h-px w-6 sm:w-12" />
)}
</div>
))}
</div>
)
}
// Quota bar with color coding
function QuotaBar({
label,
used,
limit,
unit,
}: {
label: string
used: number
limit: number
unit: string
}) {
const percent = (used / limit) * 100
const colorClass =
percent >= 80
? "bg-red-500"
: percent >= 50
? "bg-amber-500"
: "bg-green-500"
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-mono font-medium">
{used} / {limit}
{unit}
</span>
</div>
<Progress value={used} max={limit} indicatorClassName={colorClass} />
</div>
)
}
export default function NewDistributionPage() {
const t = useTranslations("distributions")
const router = useRouter()
const [step, setStep] = useState(0)
const [selectedMember, setSelectedMember] = useState<Member | null>(null)
const [quota, setQuota] = useState<QuotaStatus | null>(null)
const [selectedBatch, setSelectedBatch] = useState<AvailableBatch | null>(
null
)
const [amount, setAmount] = useState("")
const [memberSearch, setMemberSearch] = useState("")
const [showMemberList, setShowMemberList] = useState(false)
const steps = [t("step1"), t("step2"), t("step3"), t("step4")]
// Filter active members for the combobox
const activeMembers = useMemo(
() => mockMembers.filter((m) => m.status === "ACTIVE"),
[]
)
const filteredMembers = useMemo(() => {
if (!memberSearch) return activeMembers
const search = memberSearch.toLowerCase()
return activeMembers.filter(
(m) =>
`${m.firstName} ${m.lastName}`.toLowerCase().includes(search) ||
m.memberNumber.toLowerCase().includes(search)
)
}, [memberSearch, activeMembers])
// Check if member is blocked
const isMemberBlocked = useCallback((member: Member) => {
return member.status === "SUSPENDED" || member.status === "EXPELLED"
}, [])
// Check if member is under 21
const isUnder21 = useCallback((member: Member) => {
const birthDate = new Date(member.dateOfBirth)
const today = new Date()
const age = today.getFullYear() - birthDate.getFullYear()
const monthDiff = today.getMonth() - birthDate.getMonth()
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birthDate.getDate())
) {
return age - 1 < 21
}
return age < 21
}, [])
// Handle member selection
const handleSelectMember = useCallback(
(member: Member) => {
setSelectedMember(member)
setShowMemberList(false)
if (isMemberBlocked(member)) {
return // Stay on step 0, show error
}
// Load quota
const q = getMockQuota(member.id)
setQuota(q)
setStep(1)
},
[isMemberBlocked]
)
// Validation for amount
const amountNum = parseFloat(amount) || 0
const validationErrors = useMemo(() => {
const errors: string[] = []
if (!selectedBatch || amountNum <= 0) return errors
if (amountNum > selectedBatch.availableGrams) {
errors.push(t("exceedsBatch"))
}
if (quota && amountNum > quota.dailyLimitGrams - quota.dailyUsedGrams) {
errors.push(t("exceedsDaily", { limit: quota.dailyLimitGrams }))
}
if (quota && amountNum > quota.monthlyLimitGrams - quota.monthlyUsedGrams) {
errors.push(t("exceedsMonthly", { limit: quota.monthlyLimitGrams }))
}
return errors
}, [amountNum, selectedBatch, quota, t])
const canProceedToConfirm =
selectedBatch && amountNum > 0 && validationErrors.length === 0
// Confirm distribution
const handleConfirm = () => {
// Mock: log + toast + redirect
console.log("Distribution recorded:", {
memberId: selectedMember?.id,
memberName: `${selectedMember?.firstName} ${selectedMember?.lastName}`,
batchId: selectedBatch?.id,
strainName: selectedBatch?.strainName,
amountGrams: amountNum,
recordedBy: "Maria Schulz",
recordedAt: new Date().toISOString(),
status: "COMPLETED",
})
toast.success(t("success"))
router.push("/distributions")
}
return (
<div className="mx-auto max-w-3xl space-y-6 p-4 md:p-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push("/distributions")}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-2xl font-bold tracking-tight">
{t("newDistribution")}
</h1>
</div>
{/* Step indicator */}
<StepIndicator currentStep={step} steps={steps} />
{/* Step 1: Member Selection */}
{step === 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
{t("step1")}
</CardTitle>
<CardDescription>{t("selectMember")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Member search combobox */}
<div className="relative">
<div
className="border-input bg-background flex cursor-pointer items-center rounded-md border px-3 py-2"
onClick={() => setShowMemberList(!showMemberList)}
>
{selectedMember ? (
<span className="flex-1">
{selectedMember.firstName} {selectedMember.lastName} (
{selectedMember.memberNumber})
</span>
) : (
<span className="text-muted-foreground flex-1">
{t("selectMember")}
</span>
)}
<ChevronsUpDown className="text-muted-foreground h-4 w-4" />
</div>
{showMemberList && (
<div className="bg-popover border-border absolute z-50 mt-1 w-full rounded-md border shadow-md">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("searchMember")}
value={memberSearch}
onValueChange={setMemberSearch}
/>
<CommandList>
<CommandEmpty>Kein Mitglied gefunden.</CommandEmpty>
<CommandGroup>
{filteredMembers.slice(0, 8).map((member) => (
<CommandItem
key={member.id}
value={member.id}
onSelect={() => handleSelectMember(member)}
className="cursor-pointer"
>
<div className="flex flex-1 items-center justify-between">
<div>
<span className="font-medium">
{member.firstName} {member.lastName}
</span>
<span className="text-muted-foreground ml-2 text-xs">
{member.memberNumber}
</span>
</div>
{member.status !== "ACTIVE" && (
<Badge
variant="destructive"
className="text-xs"
>
{member.status}
</Badge>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
{/* Selected member card */}
{selectedMember && (
<Card className="border-border/50 bg-muted/30">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-lg font-medium">
{selectedMember.firstName} {selectedMember.lastName}
</p>
<p className="text-muted-foreground text-sm">
{selectedMember.memberNumber} · Mitglied seit{" "}
{new Date(selectedMember.joinedAt).toLocaleDateString(
"de-DE"
)}
</p>
</div>
<Badge
variant={
selectedMember.status === "ACTIVE"
? "default"
: "destructive"
}
>
{selectedMember.status === "ACTIVE"
? "Aktiv"
: selectedMember.status}
</Badge>
</div>
{/* Blocked member warning */}
{isMemberBlocked(selectedMember) && (
<div className="mt-4 flex items-center gap-2 rounded-md bg-red-100 p-3 text-red-800 dark:bg-red-900/30 dark:text-red-400">
<ShieldAlert className="h-5 w-5 flex-shrink-0" />
<p className="text-sm font-medium">
{t("memberBlocked")}
</p>
</div>
)}
{/* Under 21 info */}
{!isMemberBlocked(selectedMember) &&
isUnder21(selectedMember) && (
<div className="mt-4 flex items-center gap-2 rounded-md bg-blue-100 p-3 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
<Info className="h-5 w-5 flex-shrink-0" />
<p className="text-sm font-medium">
{t("under21Info")}
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* If member is active and selected, show "Next" button */}
{selectedMember &&
!isMemberBlocked(selectedMember) &&
step === 0 && (
<Button
className="w-full"
onClick={() => {
const q = getMockQuota(selectedMember.id)
setQuota(q)
setStep(1)
}}
>
Weiter
</Button>
)}
</CardContent>
</Card>
)}
{/* Step 2: Quota Check */}
{step === 1 && quota && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Info className="h-5 w-5" />
{t("step2")}
</CardTitle>
<CardDescription>
{selectedMember?.firstName} {selectedMember?.lastName}
{quota.isUnder21 && " (unter 21)"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<QuotaBar
label={t("dailyRemaining")}
used={quota.dailyUsedGrams}
limit={quota.dailyLimitGrams}
unit="g"
/>
<QuotaBar
label={t("monthlyRemaining")}
used={quota.monthlyUsedGrams}
limit={quota.monthlyLimitGrams}
unit="g"
/>
{quota.isUnder21 && (
<div className="flex items-center gap-2 rounded-md bg-blue-100 p-3 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
<Info className="h-4 w-4 flex-shrink-0" />
<p className="text-sm">{t("under21Info")}</p>
</div>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={() => setStep(0)}>
Zurück
</Button>
<Button className="flex-1" onClick={() => setStep(2)}>
Weiter
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Batch Selection & Amount */}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Leaf className="h-5 w-5" />
{t("step3")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Batch selection */}
<div className="space-y-2">
<Label>{t("selectBatch")}</Label>
<div className="grid gap-2">
{mockAvailableBatches.map((batch) => (
<div
key={batch.id}
onClick={() => setSelectedBatch(batch)}
className={`flex cursor-pointer items-center justify-between rounded-lg border p-3 transition-colors ${
selectedBatch?.id === batch.id
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-3">
<div
className={`h-3 w-3 rounded-full ${
selectedBatch?.id === batch.id
? "bg-primary"
: "bg-muted-foreground/30"
}`}
/>
<div>
<p className="font-medium">{batch.strainName}</p>
<p className="text-muted-foreground text-xs">
THC: {batch.thcPercent}%
</p>
</div>
</div>
<span className="text-muted-foreground text-sm">
{batch.availableGrams}g {t("available")}
</span>
</div>
))}
</div>
</div>
{/* Amount input */}
{selectedBatch && (
<div className="space-y-2">
<Label htmlFor="amount">{t("amountLabel")}</Label>
<Input
id="amount"
type="number"
min="0.1"
max={selectedBatch.availableGrams}
step="0.1"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="font-mono text-lg"
/>
{/* Validation errors */}
{validationErrors.length > 0 && (
<div className="space-y-1">
{validationErrors.map((error) => (
<div
key={error}
className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400"
>
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
))}
</div>
)}
{/* Show remaining after this distribution */}
{amountNum > 0 && validationErrors.length === 0 && quota && (
<div className="text-muted-foreground space-y-1 text-xs">
<p>
Tagesrest danach:{" "}
{(
quota.dailyLimitGrams -
quota.dailyUsedGrams -
amountNum
).toFixed(1)}
g
</p>
<p>
Monatsrest danach:{" "}
{(
quota.monthlyLimitGrams -
quota.monthlyUsedGrams -
amountNum
).toFixed(1)}
g
</p>
</div>
)}
</div>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={() => setStep(1)}>
Zurück
</Button>
<Button
className="flex-1"
disabled={!canProceedToConfirm}
onClick={() => setStep(3)}
>
Weiter
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Confirmation */}
{step === 3 && selectedMember && selectedBatch && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Check className="h-5 w-5" />
{t("step4")}
</CardTitle>
<CardDescription>{t("summary")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Summary card */}
<div className="bg-muted/50 divide-border divide-y rounded-lg border">
<div className="flex items-center justify-between p-4">
<span className="text-muted-foreground text-sm">
{t("member")}
</span>
<span className="font-medium">
{selectedMember.firstName} {selectedMember.lastName}
</span>
</div>
<div className="flex items-center justify-between p-4">
<span className="text-muted-foreground text-sm">
{t("strain")}
</span>
<span className="font-medium">{selectedBatch.strainName}</span>
</div>
<div className="flex items-center justify-between p-4">
<span className="text-muted-foreground text-sm">
{t("amount")}
</span>
<span className="font-mono text-lg font-bold">
{amountNum}g
</span>
</div>
<div className="flex items-center justify-between p-4">
<span className="text-muted-foreground text-sm">
{t("staff")}
</span>
<span className="font-medium">Maria Schulz</span>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => setStep(2)}>
Zurück
</Button>
<Button className="flex-1 gap-2" onClick={handleConfirm}>
<Check className="h-4 w-4" />
{t("confirm")}
</Button>
</div>
</CardContent>
</Card>
)}
</div>
)
}