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:
@@ -0,0 +1,614 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user