fe6e96dd3f
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
615 lines
21 KiB
TypeScript
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>
|
|
)
|
|
}
|