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,20 @@
import { NextIntlClientProvider } from "next-intl"
import { getMessages } from "next-intl/server"
import type { ReactNode } from "react"
export default async function AuthLayout({
children,
}: {
children: ReactNode
}) {
const messages = await getMessages()
return (
<div className="fixed inset-0 z-50 flex min-h-screen items-center justify-center bg-background text-foreground p-4">
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</div>
)
}
@@ -0,0 +1,167 @@
"use client"
import { useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { signIn } from "next-auth/react"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Cannabis, Loader2 } from "lucide-react"
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
type LoginFormData = z.infer<typeof loginSchema>
export default function LoginPage() {
const t = useTranslations("auth")
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"
const [error, setError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
})
async function onSubmit(data: LoginFormData) {
setError(null)
try {
const result = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
})
if (result?.error) {
setError(t("invalidCredentials"))
return
}
router.push(callbackUrl)
router.refresh()
} catch {
setError(t("networkError"))
}
}
return (
<div className="w-full max-w-md space-y-8">
{/* Logo & Branding */}
<div className="flex flex-col items-center space-y-2">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<Cannabis className="h-8 w-8 text-primary" />
</div>
<h1 className="text-2xl font-bold tracking-tight">CannaManage</h1>
<p className="text-sm text-muted-foreground">{t("loginSubtitle")}</p>
</div>
{/* Login Card */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Error message */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* Session expired message */}
{searchParams.get("error") === "SessionRequired" && !error && (
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400">
{t("sessionExpired")}
</div>
)}
{/* Email field */}
<div className="space-y-2">
<label
htmlFor="email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("email")}
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder="name@verein.de"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" className="text-xs text-destructive">
{t("emailInvalid")}
</p>
)}
</div>
{/* Password field */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("password")}
</label>
<a
href="#"
className="text-xs text-muted-foreground hover:text-primary"
tabIndex={-1}
>
{t("forgotPassword")}
</a>
</div>
<input
id="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
{...register("password")}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
/>
{errors.password && (
<p id="password-error" className="text-xs text-destructive">
{t("passwordRequired")}
</p>
)}
</div>
{/* Submit button */}
<button
type="submit"
disabled={isSubmitting}
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t("loggingIn")}
</>
) : (
t("loginButton")
)}
</button>
</form>
</div>
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
{t("footerText")}
</p>
</div>
)
}
@@ -0,0 +1,233 @@
"use client"
import Link from "next/link"
import { useTranslations } from "next-intl"
import {
Bar,
BarChart,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { Leaf, Package, Plus, TrendingUp, UserPlus, Users } from "lucide-react"
import {
mockClubStats,
mockRecentDistributions,
mockStockByStrain,
} from "@/data/mock/dashboard"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export default function DashboardPage() {
const t = useTranslations("dashboard")
const chartData = mockStockByStrain.map((batch) => ({
name: batch.strainName,
grams: batch.availableGrams,
}))
return (
<div className="flex flex-col gap-6 p-4 md:p-6">
{/* KPI Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Active Members */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("activeMembers")}
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{mockClubStats.activeMembers}
</div>
<p className="text-xs text-muted-foreground">
{t("trend", { value: "12" })}
</p>
</CardContent>
</Card>
{/* Distributions Today */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("distributionsToday")}
</CardTitle>
<Leaf className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{mockClubStats.distributionsToday}
</div>
<p className="text-xs text-muted-foreground">
{t("distributionCount", {
count: mockClubStats.distributionsToday,
grams: mockClubStats.gramsDistributedToday,
})}
</p>
</CardContent>
</Card>
{/* Stock Level */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("stockLevel")}
</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{mockClubStats.totalStockGrams.toLocaleString("de-DE")}
{t("grams")}
</div>
<p className="text-xs text-muted-foreground">
{mockStockByStrain.length} Sorten verfügbar
</p>
</CardContent>
</Card>
{/* Monthly Quota */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("monthlyQuota")}
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{mockClubStats.monthlyQuotaUsagePercent}%
</div>
<p className="text-xs text-muted-foreground">
{t("quotaUsed", {
value: mockClubStats.monthlyQuotaUsagePercent,
})}
</p>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>{t("quickActions")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button
asChild
className="bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600"
>
<Link href="/distributions/new">
<Plus className="mr-2 h-4 w-4" />
{t("newDistribution")}
</Link>
</Button>
<Button
asChild
variant="outline"
className="border-green-600 text-green-700 hover:bg-green-50 dark:border-green-500 dark:text-green-400 dark:hover:bg-green-950"
>
<Link href="/members/new">
<UserPlus className="mr-2 h-4 w-4" />
{t("addMember")}
</Link>
</Button>
</CardContent>
</Card>
{/* Bottom section: Table + Chart */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Recent Distributions Table */}
<Card>
<CardHeader>
<CardTitle>{t("recentDistributions")}</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 font-medium">{t("date")}</th>
<th className="pb-2 font-medium">{t("member")}</th>
<th className="pb-2 font-medium">{t("strain")}</th>
<th className="pb-2 font-medium">{t("amount")}</th>
<th className="pb-2 font-medium">{t("staff")}</th>
</tr>
</thead>
<tbody>
{mockRecentDistributions.map((dist) => (
<tr key={dist.id} className="border-b last:border-0">
<td className="py-2">
{new Date(dist.recordedAt).toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
})}
</td>
<td className="py-2">{dist.memberName}</td>
<td className="py-2">{dist.strainName}</td>
<td className="py-2">{dist.amountGrams}g</td>
<td className="py-2">{dist.recordedBy}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Stock Level Chart */}
<Card>
<CardHeader>
<CardTitle>{t("stockByStrain")}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{ top: 5, right: 20, left: 10, bottom: 60 }}
>
<XAxis
dataKey="name"
angle={-45}
textAnchor="end"
height={80}
tick={{ fontSize: 12 }}
className="fill-muted-foreground"
/>
<YAxis
tick={{ fontSize: 12 }}
className="fill-muted-foreground"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
color: "hsl(var(--card-foreground))",
}}
formatter={(value) => [`${value}g`, "Bestand"]}
/>
<Bar dataKey="grams" radius={[4, 4, 0, 0]}>
{chartData.map((_, index) => (
<Cell
key={`cell-${index}`}
className="fill-green-600 dark:fill-green-500"
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,287 @@
"use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { format, isThisMonth, isThisWeek, isToday } from "date-fns"
import { de } from "date-fns/locale"
import { useTranslations } from "next-intl"
import { Lock, Plus, Search } from "lucide-react"
import type { DistributionRecord } from "@/types/api"
import type { ColumnDef, SortingState } from "@tanstack/react-table"
import { mockDistributions } from "@/data/mock/distributions"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type DateFilter = "all" | "today" | "week" | "month"
export default function DistributionsPage() {
const t = useTranslations("distributions")
const [sorting, setSorting] = useState<SortingState>([
{ id: "recordedAt", desc: true },
])
const [globalFilter, setGlobalFilter] = useState("")
const [dateFilter, setDateFilter] = useState<DateFilter>("all")
const filteredData = useMemo(() => {
let data = mockDistributions
if (dateFilter === "today") {
data = data.filter((d) => isToday(new Date(d.recordedAt)))
} else if (dateFilter === "week") {
data = data.filter((d) =>
isThisWeek(new Date(d.recordedAt), { weekStartsOn: 1 })
)
} else if (dateFilter === "month") {
data = data.filter((d) => isThisMonth(new Date(d.recordedAt)))
}
return data
}, [dateFilter])
const todayDistributions = useMemo(
() => mockDistributions.filter((d) => isToday(new Date(d.recordedAt))),
[]
)
const todayGrams = useMemo(
() => todayDistributions.reduce((sum, d) => sum + d.amountGrams, 0),
[todayDistributions]
)
const columns: ColumnDef<DistributionRecord>[] = useMemo(
() => [
{
accessorKey: "recordedAt",
header: t("dateTime"),
cell: ({ row }) =>
format(new Date(row.original.recordedAt), "dd.MM.yyyy HH:mm", {
locale: de,
}),
},
{
accessorKey: "memberName",
header: t("member"),
cell: ({ row }) => (
<span className="font-medium">{row.original.memberName}</span>
),
},
{
accessorKey: "strainName",
header: t("strain"),
cell: ({ row }) => (
<Badge variant="outline">{row.original.strainName}</Badge>
),
},
{
accessorKey: "amountGrams",
header: t("amount"),
cell: ({ row }) => (
<span className="font-mono">{row.original.amountGrams}g</span>
),
},
{
accessorKey: "recordedBy",
header: t("staff"),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.recordedBy}
</span>
),
},
{
accessorKey: "status",
header: t("status"),
cell: () => (
<div className="flex items-center gap-1.5">
<Lock className="text-muted-foreground h-3.5 w-3.5" />
<span className="text-muted-foreground text-xs">
{t("completed")}
</span>
</div>
),
},
],
[t]
)
const table = useReactTable({
data: filteredData,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
globalFilterFn: (row, _columnId, filterValue) => {
const search = filterValue.toLowerCase()
return row.original.memberName.toLowerCase().includes(search)
},
initialState: {
pagination: { pageSize: 10 },
},
})
return (
<div className="space-y-6 p-4 md:p-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground text-sm">
{t("todaySummary", {
count: todayDistributions.length,
grams: todayGrams,
})}
</p>
</div>
<Link href="/distributions/new">
<Button className="gap-2">
<Plus className="h-4 w-4" />
{t("newDistribution")}
</Button>
</Link>
</div>
{/* Filters */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* Search */}
<div className="relative flex-1">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder={t("searchMember")}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="pl-9"
/>
</div>
{/* Date filter buttons */}
<div className="flex gap-2">
{(
[
{ key: "all", label: "Alle" },
{ key: "today", label: t("filterToday") },
{ key: "week", label: t("filterWeek") },
{ key: "month", label: t("filterMonth") },
] as const
).map(({ key, label }) => (
<Button
key={key}
variant={dateFilter === key ? "default" : "outline"}
size="sm"
onClick={() => setDateFilter(key)}
>
{label}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="hidden sm:table-cell first:table-cell [&:nth-child(2)]:table-cell [&:nth-child(3)]:table-cell [&:nth-child(4)]:table-cell"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="hidden sm:table-cell first:table-cell [&:nth-child(2)]:table-cell [&:nth-child(3)]:table-cell [&:nth-child(4)]:table-cell"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
Keine Ausgaben gefunden.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{table.getFilteredRowModel().rows.length} Einträge
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Weiter
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,9 @@
import { Layout } from "@/components/layout"
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return <Layout>{children}</Layout>
}
@@ -0,0 +1,248 @@
"use client"
import { useMemo } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
import { mockMembers } from "@/data/mock/members"
import { useToast } from "@/hooks/use-toast"
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 memberSchema = z.object({
firstName: z.string().min(1, "Required"),
lastName: z.string().min(1, "Required"),
email: z.string().email("Invalid email"),
dateOfBirth: z.string().min(1, "Required"),
phone: z.string().optional(),
status: z.enum(["ACTIVE", "SUSPENDED", "EXPELLED"]),
memberNumber: z.string().min(1, "Required"),
joinedAt: z.string().min(1, "Required"),
notes: z.string().optional(),
})
type MemberFormData = z.infer<typeof memberSchema>
function isUnder21(dateOfBirth: string): boolean {
const dob = new Date(dateOfBirth)
const today = new Date()
const age = today.getFullYear() - dob.getFullYear()
const monthDiff = today.getMonth() - dob.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
return age - 1 < 21
}
return age < 21
}
export default function MemberDetailPage() {
const t = useTranslations("members")
const params = useParams()
const { toast } = useToast()
const memberId = params.id as string
const member = useMemo(
() => mockMembers.find((m) => m.id === memberId),
[memberId]
)
const {
register,
handleSubmit,
formState: { errors, isDirty },
watch,
} = useForm<MemberFormData>({
resolver: zodResolver(memberSchema),
defaultValues: member
? {
firstName: member.firstName,
lastName: member.lastName,
email: member.email,
dateOfBirth: member.dateOfBirth,
phone: member.phone || "",
status: member.status,
memberNumber: member.memberNumber,
joinedAt: member.joinedAt,
notes: member.notes || "",
}
: undefined,
})
const watchedDob = watch("dateOfBirth")
const showUnder21Warning = watchedDob ? isUnder21(watchedDob) : false
if (!member) {
return (
<div className="flex flex-col items-center justify-center gap-4 p-8">
<p className="text-muted-foreground">{t("notFound")}</p>
<Link href="/members">
<Button variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("back")}
</Button>
</Link>
</div>
)
}
const onSubmit = (_data: MemberFormData) => {
toast({
title: t("saved"),
})
}
return (
<div className="flex flex-col gap-6 p-4 md:p-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/members">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("back")}
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight">
{member.firstName} {member.lastName}
</h1>
</div>
{/* Under 21 warning */}
{showUnder21Warning && (
<div className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-900/20">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
{t("under21Warning")}
</p>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t("personalInfo")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">{t("firstName")}</Label>
<Input
id="firstName"
{...register("firstName")}
aria-invalid={!!errors.firstName}
/>
{errors.firstName && (
<p className="text-sm text-red-500">
{errors.firstName.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">{t("lastName")}</Label>
<Input
id="lastName"
{...register("lastName")}
aria-invalid={!!errors.lastName}
/>
{errors.lastName && (
<p className="text-sm text-red-500">
{errors.lastName.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("email")}</Label>
<Input
id="email"
type="email"
{...register("email")}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="dateOfBirth">{t("dateOfBirth")}</Label>
<Input
id="dateOfBirth"
type="date"
{...register("dateOfBirth")}
aria-invalid={!!errors.dateOfBirth}
/>
{errors.dateOfBirth && (
<p className="text-sm text-red-500">
{errors.dateOfBirth.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t("phone")}</Label>
<Input id="phone" type="tel" {...register("phone")} />
</div>
<div className="space-y-2">
<Label htmlFor="status">{t("status")}</Label>
<Select id="status" {...register("status")}>
<option value="ACTIVE">{t("active")}</option>
<option value="SUSPENDED">{t("suspended")}</option>
<option value="EXPELLED">{t("expelled")}</option>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("membershipInfo")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="memberNumber">{t("memberNumber")}</Label>
<Input id="memberNumber" {...register("memberNumber")} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="joinedAt">{t("joinedAt")}</Label>
<Input id="joinedAt" type="date" {...register("joinedAt")} />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea
id="notes"
rows={3}
{...register("notes")}
placeholder={t("notesPlaceholder")}
/>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Link href="/members">
<Button variant="outline" type="button">
{t("back")}
</Button>
</Link>
<Button type="submit" disabled={!isDirty}>
<Save className="mr-2 h-4 w-4" />
{t("save")}
</Button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,189 @@
"use client"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { ArrowLeft, UserPlus } from "lucide-react"
import { useToast } from "@/hooks/use-toast"
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 { Textarea } from "@/components/ui/textarea"
function getMinAgeDate(): string {
const today = new Date()
today.setFullYear(today.getFullYear() - 18)
return today.toISOString().split("T")[0]
}
const createMemberSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich"),
lastName: z.string().min(1, "Nachname ist erforderlich"),
email: z.string().email("Ungültige E-Mail-Adresse"),
dateOfBirth: z
.string()
.min(1, "Geburtsdatum ist erforderlich")
.refine(
(val) => {
const dob = new Date(val)
const today = new Date()
const age = today.getFullYear() - dob.getFullYear()
const monthDiff = today.getMonth() - dob.getMonth()
const actualAge =
monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())
? age - 1
: age
return actualAge >= 18
},
{ message: "ageError" }
),
phone: z.string().optional(),
notes: z.string().optional(),
})
type CreateMemberFormData = z.infer<typeof createMemberSchema>
export default function AddMemberPage() {
const t = useTranslations("members")
const router = useRouter()
const { toast } = useToast()
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateMemberFormData>({
resolver: zodResolver(createMemberSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
dateOfBirth: "",
phone: "",
notes: "",
},
})
const onSubmit = (_data: CreateMemberFormData) => {
toast({
title: t("created"),
})
router.push("/members")
}
return (
<div className="flex flex-col gap-6 p-4 md:p-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/members">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("back")}
</Button>
</Link>
<h1 className="text-2xl font-bold tracking-tight">{t("addMember")}</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t("personalInfo")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">{t("firstName")}</Label>
<Input
id="firstName"
{...register("firstName")}
aria-invalid={!!errors.firstName}
/>
{errors.firstName && (
<p className="text-sm text-red-500">
{errors.firstName.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">{t("lastName")}</Label>
<Input
id="lastName"
{...register("lastName")}
aria-invalid={!!errors.lastName}
/>
{errors.lastName && (
<p className="text-sm text-red-500">
{errors.lastName.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("email")}</Label>
<Input
id="email"
type="email"
{...register("email")}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="dateOfBirth">{t("dateOfBirth")}</Label>
<Input
id="dateOfBirth"
type="date"
max={getMinAgeDate()}
{...register("dateOfBirth")}
aria-invalid={!!errors.dateOfBirth}
/>
{errors.dateOfBirth && (
<p className="text-sm text-red-500">
{errors.dateOfBirth.message === "ageError"
? t("ageError")
: errors.dateOfBirth.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t("phone")}</Label>
<Input id="phone" type="tel" {...register("phone")} />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea
id="notes"
rows={3}
{...register("notes")}
placeholder={t("notesPlaceholder")}
/>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Link href="/members">
<Button variant="outline" type="button">
{t("back")}
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
<UserPlus className="mr-2 h-4 w-4" />
{t("create")}
</Button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,374 @@
"use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useTranslations } from "next-intl"
import { ArrowUpDown, Plus, Search } from "lucide-react"
import type { Member } from "@/types/api"
import type { ColumnDef, SortingState } from "@tanstack/react-table"
import { mockMembers } from "@/data/mock/members"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select } from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
function StatusBadge({
status,
t,
}: {
status: Member["status"]
t: ReturnType<typeof useTranslations>
}) {
const variants: Record<Member["status"], string> = {
ACTIVE:
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
SUSPENDED:
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400",
EXPELLED: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
}
const labels: Record<Member["status"], string> = {
ACTIVE: t("active"),
SUSPENDED: t("suspended"),
EXPELLED: t("expelled"),
}
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${variants[status]}`}
>
{labels[status]}
</span>
)
}
function QuotaBar({ percent }: { percent: number }) {
const color =
percent >= 90
? "bg-red-500"
: percent >= 70
? "bg-amber-500"
: "bg-green-500"
return (
<div className="flex items-center gap-2">
<div className="bg-muted h-2 w-16 rounded-full">
<div
className={`h-2 rounded-full ${color}`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
<span className="text-muted-foreground text-xs">{percent}%</span>
</div>
)
}
export default function MembersPage() {
const t = useTranslations("members")
const router = useRouter()
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState("")
const [pageSize, setPageSize] = useState(10)
const columns = useMemo<ColumnDef<Member>[]>(
() => [
{
accessorFn: (row) => `${row.firstName} ${row.lastName}`,
id: "name",
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-4"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div>
<div className="font-medium">
{row.original.firstName} {row.original.lastName}
</div>
<div className="text-muted-foreground text-sm md:hidden">
{row.original.email}
</div>
</div>
),
},
{
accessorKey: "email",
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-4"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{t("email")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ getValue }) => (
<span className="text-muted-foreground">{getValue() as string}</span>
),
},
{
accessorKey: "status",
header: t("status"),
cell: ({ getValue }) => (
<StatusBadge status={getValue() as Member["status"]} t={t} />
),
},
{
accessorKey: "joinedAt",
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-4"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{t("memberSince")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ getValue }) => {
const date = new Date(getValue() as string)
return date.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
},
},
{
accessorKey: "monthlyQuotaUsedPercent",
header: t("quota"),
cell: ({ getValue }) => <QuotaBar percent={getValue() as number} />,
},
{
id: "actions",
header: t("actions"),
cell: ({ row }) => (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/members/${row.original.id}`)}
>
{t("edit")}
</Button>
),
},
],
[t, router]
)
const table = useReactTable({
data: mockMembers,
columns,
state: {
sorting,
globalFilter,
pagination: { pageIndex: 0, pageSize },
},
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
globalFilterFn: (row, _columnId, filterValue) => {
const search = filterValue.toLowerCase()
const name =
`${row.original.firstName} ${row.original.lastName}`.toLowerCase()
const email = row.original.email.toLowerCase()
return name.includes(search) || email.includes(search)
},
})
return (
<div className="flex flex-col gap-6 p-4 md:p-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
<Link href="/members/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("addMember")}
</Button>
</Link>
</div>
{/* Search + Filter */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder={t("search")}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">{t("perPage")}</span>
<Select
value={String(pageSize)}
onChange={(e) => setPageSize(Number(e.target.value))}
className="w-20"
>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</Select>
</div>
</div>
{/* Desktop table */}
<div className="hidden md:block">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("noResults")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Mobile card layout */}
<div className="flex flex-col gap-3 md:hidden">
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<div
key={row.id}
className="bg-card rounded-lg border p-4 shadow-sm"
onClick={() => router.push(`/members/${row.original.id}`)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
router.push(`/members/${row.original.id}`)
}
}}
>
<div className="flex items-start justify-between">
<div>
<p className="font-medium">
{row.original.firstName} {row.original.lastName}
</p>
<p className="text-muted-foreground text-sm">
{row.original.email}
</p>
</div>
<StatusBadge status={row.original.status} t={t} />
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{t("memberSince")}:{" "}
{new Date(row.original.joinedAt).toLocaleDateString("de-DE")}
</span>
<QuotaBar percent={row.original.monthlyQuotaUsedPercent} />
</div>
</div>
))
) : (
<p className="text-muted-foreground py-8 text-center">
{t("noResults")}
</p>
)}
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{t("showing", {
from:
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1,
to: Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length
),
total: table.getFilteredRowModel().rows.length,
})}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{t("previous")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{t("next")}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function HomePage() {
redirect("/dashboard")
}
@@ -0,0 +1,515 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
AlertTriangle,
CalendarDays,
Download,
Eye,
FileText,
Info,
Users,
} from "lucide-react"
import {
mockMemberListPreview,
mockMonthlyReportPreview,
mockRecallReportPreview,
} from "@/data/mock/reports"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Select } from "@/components/ui/select"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type ReportType = "monthly" | "memberList" | "recall"
export default function ReportsPage() {
const t = useTranslations("reports")
// Controls state
const [selectedMonth, setSelectedMonth] = useState("2026-06")
const [statusFilter, setStatusFilter] = useState("all")
const [dateFrom, setDateFrom] = useState("2026-05-01")
const [dateTo, setDateTo] = useState("2026-06-12")
// Preview state
const [previewOpen, setPreviewOpen] = useState(false)
const [previewType, setPreviewType] = useState<ReportType>("monthly")
const handleDownload = (reportType: ReportType, format: "pdf" | "csv") => {
const names: Record<ReportType, string> = {
monthly: t("monthly"),
memberList: t("memberList"),
recall: t("recall"),
}
const monthLabel = selectedMonth.replace("-", " ")
const fileName = `${names[reportType]} ${monthLabel}.${format}`
toast.info(t("generating"))
setTimeout(() => {
toast.success(t("downloaded", { name: fileName }))
}, 1200)
}
const handlePreview = (type: ReportType) => {
setPreviewType(type)
setPreviewOpen(true)
}
const monthOptions = [
{ value: "2026-06", label: "Juni 2026" },
{ value: "2026-05", label: "Mai 2026" },
{ value: "2026-04", label: "April 2026" },
{ value: "2026-03", label: "März 2026" },
{ value: "2026-02", label: "Februar 2026" },
{ value: "2026-01", label: "Januar 2026" },
]
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
</div>
{/* Audit Trail Notice */}
<div className="bg-muted/50 border rounded-lg p-4 flex items-start gap-3">
<Info className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-sm text-muted-foreground">{t("auditTrail")}</p>
</div>
{/* Report Cards Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* Card 1: Monthly Report */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-primary" />
<CardTitle className="text-lg">{t("monthly")}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{t("monthlyDesc")}</p>
{/* Month picker */}
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("selectMonth")}</label>
<Select
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
>
{monthOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
{/* Action buttons */}
<div className="flex flex-col gap-2">
<Button
variant="default"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload("monthly", "pdf")}
>
<Download className="mr-2 h-4 w-4" />
{t("downloadPdf")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload("monthly", "csv")}
>
<FileText className="mr-2 h-4 w-4" />
{t("downloadCsv")}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => handlePreview("monthly")}
>
<Eye className="mr-2 h-4 w-4" />
{t("preview")}
</Button>
</div>
</CardContent>
</Card>
{/* Card 2: Member List Report */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<CardTitle className="text-lg">{t("memberList")}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("memberListDesc")}
</p>
{/* Status filter */}
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("selectStatus")}</label>
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">{t("allStatuses")}</option>
<option value="active">{t("activeOnly")}</option>
<option value="suspended">{t("suspendedOnly")}</option>
</Select>
</div>
{/* Action buttons */}
<div className="flex flex-col gap-2">
<Button
variant="default"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload("memberList", "pdf")}
>
<Download className="mr-2 h-4 w-4" />
{t("downloadPdf")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload("memberList", "csv")}
>
<FileText className="mr-2 h-4 w-4" />
{t("downloadCsv")}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => handlePreview("memberList")}
>
<Eye className="mr-2 h-4 w-4" />
{t("preview")}
</Button>
</div>
</CardContent>
</Card>
{/* Card 3: Recall Report */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
<CardTitle className="text-lg">{t("recall")}</CardTitle>
</div>
<Badge
variant="outline"
className="border-green-500 text-green-700 dark:text-green-400 text-xs"
>
{t("complianceBadge")}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{t("recallDesc")}</p>
{/* Date range */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("dateFrom")}</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="border-input bg-background ring-offset-background focus:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors focus:ring-1 focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("dateTo")}</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="border-input bg-background ring-offset-background focus:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors focus:ring-1 focus:outline-none"
/>
</div>
</div>
{/* Compliance note */}
<p className="text-xs text-muted-foreground italic">
{t("complianceNote")}
</p>
{/* Action buttons */}
<div className="flex flex-col gap-2">
<Button
variant="default"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload("recall", "pdf")}
>
<Download className="mr-2 h-4 w-4" />
{t("downloadPdf")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => handleDownload("recall", "csv")}
>
<FileText className="mr-2 h-4 w-4" />
{t("downloadCsv")}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => handlePreview("recall")}
>
<Eye className="mr-2 h-4 w-4" />
{t("preview")}
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Preview Sheet */}
<Sheet open={previewOpen} onOpenChange={setPreviewOpen}>
<SheetContent
side="right"
className="w-full sm:max-w-lg overflow-y-auto"
>
<SheetHeader>
<SheetTitle>{t("previewTitle")}</SheetTitle>
<SheetDescription>
{previewType === "monthly" && t("monthly")}
{previewType === "memberList" && t("memberList")}
{previewType === "recall" && t("recall")}
</SheetDescription>
</SheetHeader>
<div className="mt-6">
{previewType === "monthly" && <MonthlyPreview t={t} />}
{previewType === "memberList" && <MemberListPreview t={t} />}
{previewType === "recall" && <RecallPreview t={t} />}
</div>
<SheetFooter className="mt-6">
<SheetClose asChild>
<Button variant="outline">{t("close")}</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
/* ─── Preview Components ─── */
function MonthlyPreview({
t,
}: {
t: ReturnType<typeof useTranslations<"reports">>
}) {
const data = mockMonthlyReportPreview
return (
<div className="space-y-6">
{/* Summary stats */}
<div className="grid grid-cols-2 gap-4">
<StatCard
label={t("totalDistributions")}
value={String(data.totalDistributions)}
/>
<StatCard label={t("totalGrams")} value={`${data.totalGrams}g`} />
<StatCard
label={t("uniqueMembers")}
value={String(data.uniqueMembers)}
/>
<StatCard
label={t("averagePerMember")}
value={`${data.averagePerMember}g`}
/>
</div>
{/* Top strains table */}
<div>
<h4 className="text-sm font-semibold mb-2">{t("topStrains")}</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("strain")}</TableHead>
<TableHead className="text-right">{t("grams")}</TableHead>
<TableHead className="text-right">{t("percent")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.topStrains.map((strain) => (
<TableRow key={strain.name}>
<TableCell className="font-medium">{strain.name}</TableCell>
<TableCell className="text-right">{strain.grams}g</TableCell>
<TableCell className="text-right">{strain.percent}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
function MemberListPreview({
t,
}: {
t: ReturnType<typeof useTranslations<"reports">>
}) {
const data = mockMemberListPreview
const statusBadge = (status: string) => {
switch (status) {
case "ACTIVE":
return <Badge variant="default">Aktiv</Badge>
case "SUSPENDED":
return <Badge variant="secondary">Gesperrt</Badge>
case "EXPELLED":
return <Badge variant="destructive">Ausgeschlossen</Badge>
default:
return null
}
}
return (
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-3 gap-2">
<StatCard label={t("allStatuses")} value={String(data.totalMembers)} />
<StatCard label={t("activeOnly")} value={String(data.active)} />
<StatCard label={t("suspendedOnly")} value={String(data.suspended)} />
</div>
{/* Members table */}
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("memberNumber")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("usage")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.members.map((member) => (
<TableRow key={member.memberNumber}>
<TableCell className="font-mono text-xs">
{member.memberNumber}
</TableCell>
<TableCell className="font-medium">{member.name}</TableCell>
<TableCell>{statusBadge(member.status)}</TableCell>
<TableCell className="text-right">
{member.monthlyUsage}/{member.monthlyLimit}g
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
function RecallPreview({
t,
}: {
t: ReturnType<typeof useTranslations<"reports">>
}) {
const data = mockRecallReportPreview
return (
<div className="space-y-6">
{/* Summary stats */}
<div className="grid grid-cols-3 gap-2">
<StatCard
label={t("recalledBatches")}
value={String(data.recalledBatches)}
/>
<StatCard
label={t("affectedDistributions")}
value={String(data.affectedDistributions)}
/>
<StatCard
label={t("affectedMembers")}
value={String(data.affectedMembers)}
/>
</div>
{/* Batches detail */}
{data.batches.map((batch) => (
<Card key={batch.batchId}>
<CardContent className="pt-4 space-y-2">
<div className="flex items-center justify-between">
<span className="font-mono text-sm font-semibold">
{batch.batchId}
</span>
<Badge variant="destructive">{batch.strain}</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">{t("recalledAt")}:</span>
<span>{batch.recalledAt}</span>
<span className="text-muted-foreground">{t("reason")}:</span>
<span>{batch.reason}</span>
<span className="text-muted-foreground">{t("original")}:</span>
<span>{batch.originalGrams}g</span>
<span className="text-muted-foreground">{t("distributed")}:</span>
<span>{batch.distributedGrams}g</span>
<span className="text-muted-foreground">
{t("affectedMembers")}:
</span>
<span>{batch.affectedMembers}</span>
<span className="text-muted-foreground">
{t("affectedDistributions")}:
</span>
<span>{batch.affectedDistributions}</span>
</div>
</CardContent>
</Card>
))}
</div>
)
}
/* ─── Shared Components ─── */
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="bg-muted/50 rounded-lg p-3 text-center">
<p className="text-lg font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,473 @@
"use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { format } from "date-fns"
import { de } from "date-fns/locale"
import { useTranslations } from "next-intl"
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { toast } from "sonner"
import {
AlertTriangle,
BarChart3,
Box,
Leaf,
Package,
Plus,
} from "lucide-react"
import type { Batch } from "@/types/api"
import type { ColumnDef, SortingState } from "@tanstack/react-table"
import { mockBatches } from "@/data/mock/stock"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type StatusFilter = "all" | "available" | "recalled"
export default function StockPage() {
const t = useTranslations("stock")
const [batches, setBatches] = useState<Batch[]>(mockBatches)
const [sorting, setSorting] = useState<SortingState>([
{ id: "receivedAt", desc: true },
])
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [recallTarget, setRecallTarget] = useState<Batch | null>(null)
// Summary stats
const stats = useMemo(() => {
const available = batches.filter((b) => b.status === "AVAILABLE")
const recalled = batches.filter((b) => b.status === "RECALLED")
const strains = new Set(batches.map((b) => b.strainName))
const totalAvailableGrams = available.reduce(
(sum, b) => sum + b.availableGrams,
0
)
return {
totalBatches: batches.length,
availableGrams: totalAvailableGrams,
recalledCount: recalled.length,
strainCount: strains.size,
}
}, [batches])
// Chart data — aggregate by strain (only AVAILABLE)
const chartData = useMemo(() => {
const byStrain: Record<string, number> = {}
batches
.filter((b) => b.status === "AVAILABLE")
.forEach((b) => {
byStrain[b.strainName] =
(byStrain[b.strainName] || 0) + b.availableGrams
})
return Object.entries(byStrain)
.map(([name, grams]) => ({ name, grams }))
.sort((a, b) => b.grams - a.grams)
}, [batches])
// Filtered data for table
const filteredData = useMemo(() => {
if (statusFilter === "available") {
return batches.filter((b) => b.status === "AVAILABLE")
}
if (statusFilter === "recalled") {
return batches.filter((b) => b.status === "RECALLED")
}
return batches
}, [batches, statusFilter])
// Recall handler
function handleRecall() {
if (!recallTarget) return
setBatches((prev) =>
prev.map((b) =>
b.id === recallTarget.id ? { ...b, status: "RECALLED" as const } : b
)
)
toast.success(t("recallSuccess"))
setRecallTarget(null)
}
// Status badge
function StatusBadge({ status }: { status: Batch["status"] }) {
if (status === "AVAILABLE") {
return (
<Badge variant="default" className="bg-green-600 hover:bg-green-700">
{t("statusAvailable")}
</Badge>
)
}
if (status === "RECALLED") {
return <Badge variant="destructive">{t("statusRecalled")}</Badge>
}
return (
<Badge variant="secondary" className="text-muted-foreground">
{t("statusDepleted")}
</Badge>
)
}
// Bar color by available grams
function getBarColor(grams: number): string {
if (grams < 100) return "#f59e0b" // amber — low stock
return "#22c55e" // green — healthy
}
const columns: ColumnDef<Batch>[] = useMemo(
() => [
{
accessorKey: "id",
header: t("batchId"),
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.id}</span>
),
},
{
accessorKey: "strainName",
header: t("strain"),
cell: ({ row }) => (
<span className="font-medium">{row.original.strainName}</span>
),
},
{
accessorKey: "thcPercent",
header: t("thc"),
cell: ({ row }) => `${row.original.thcPercent.toFixed(1)}%`,
},
{
accessorKey: "status",
header: t("status"),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: "availableGrams",
header: t("availableGrams"),
cell: ({ row }) => {
const grams = row.original.availableGrams
const isLow =
grams > 0 && grams < 100 && row.original.status === "AVAILABLE"
return (
<span className={isLow ? "font-semibold text-amber-600" : ""}>
{grams}
{t("grams")}
{isLow && (
<span className="ml-1 text-xs"> {t("lowStock")}</span>
)}
</span>
)
},
},
{
accessorKey: "receivedAt",
header: t("receivedAt"),
cell: ({ row }) =>
format(new Date(row.original.receivedAt), "dd.MM.yyyy", {
locale: de,
}),
},
{
id: "actions",
header: t("actions"),
cell: ({ row }) => {
if (row.original.status !== "AVAILABLE") return null
return (
<Button
variant="destructive"
size="sm"
onClick={() => setRecallTarget(row.original)}
>
{t("recall")}
</Button>
)
},
},
],
[t]
)
const table = useReactTable({
data: filteredData,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 20 } },
})
return (
<div className="space-y-6 p-4 md:p-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("title")}</h1>
<Button asChild>
<Link href="/stock/new">
<Plus className="mr-2 h-4 w-4" />
{t("newBatch")}
</Link>
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Package className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">{stats.totalBatches}</p>
<p className="text-xs text-muted-foreground">
{t("totalBatches")}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Box className="h-8 w-8 text-green-600" />
<div>
<p className="text-2xl font-bold">
{stats.availableGrams}
{t("grams")}
</p>
<p className="text-xs text-muted-foreground">
{t("availableStock")}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<AlertTriangle className="h-8 w-8 text-destructive" />
<div>
<p className="text-2xl font-bold">{stats.recalledCount}</p>
<p className="text-xs text-muted-foreground">
{t("recalledBatches")}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Leaf className="h-8 w-8 text-green-600" />
<div>
<p className="text-2xl font-bold">{stats.strainCount}</p>
<p className="text-xs text-muted-foreground">
{t("strainCount")}
</p>
</div>
</CardContent>
</Card>
</div>
{/* Stock Chart */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
{t("stockOverview")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 30, left: 100, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" unit="g" />
<YAxis type="category" dataKey="name" width={95} />
<Tooltip formatter={(value) => [`${value}g`, t("available")]} />
<Bar dataKey="grams" radius={[0, 4, 4, 0]}>
{chartData.map((entry) => (
<Cell key={entry.name} fill={getBarColor(entry.grams)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Batch Table */}
<Card>
<CardHeader className="pb-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-base">{t("title")}</CardTitle>
<div className="flex gap-1">
{(["all", "available", "recalled"] as StatusFilter[]).map(
(filter) => (
<Button
key={filter}
variant={statusFilter === filter ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter(filter)}
>
{filter === "all" && t("filterAll")}
{filter === "available" && t("filterAvailable")}
{filter === "recalled" && t("filterRecalled")}
</Button>
)
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{/* Desktop table */}
<div className="hidden md:block">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="cursor-pointer select-none"
onClick={header.column.getToggleSortingHandler()}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
Keine Chargen gefunden.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Mobile card layout */}
<div className="space-y-3 p-4 md:hidden">
{filteredData.map((batch) => (
<div
key={batch.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="space-y-1">
<p className="font-medium">{batch.strainName}</p>
<div className="flex items-center gap-2">
<StatusBadge status={batch.status} />
<span className="text-sm text-muted-foreground">
{batch.availableGrams}
{t("grams")}
</span>
</div>
</div>
{batch.status === "AVAILABLE" && (
<Button
variant="destructive"
size="sm"
onClick={() => setRecallTarget(batch)}
>
{t("recall")}
</Button>
)}
</div>
))}
</div>
</CardContent>
</Card>
{/* Recall Confirmation Dialog */}
<AlertDialog
open={!!recallTarget}
onOpenChange={(open) => !open && setRecallTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("recallTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("recallConfirm")}
{recallTarget && (
<span className="mt-2 block font-medium text-foreground">
{recallTarget.strainName} ({recallTarget.id}) {" "}
{recallTarget.availableGrams}
{t("grams")}
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("filterAll") && "Abbrechen"}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleRecall}
>
{t("confirmRecall")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -0,0 +1,20 @@
import { NextIntlClientProvider } from "next-intl"
import { getMessages } from "next-intl/server"
import type { ReactNode } from "react"
export default async function PortalLayout({
children,
}: {
children: ReactNode
}) {
const messages = await getMessages()
return (
<NextIntlClientProvider messages={messages}>
<div className="min-h-screen flex flex-col bg-background text-foreground">
{children}
</div>
</NextIntlClientProvider>
)
}
@@ -0,0 +1,144 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Cannabis, Loader2 } from "lucide-react"
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
type LoginFormData = z.infer<typeof loginSchema>
export default function PortalLoginPage() {
const t = useTranslations("portal")
const router = useRouter()
const [error, setError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
})
async function onSubmit(_data: LoginFormData) {
setError(null)
try {
// Mock login — just redirect to portal dashboard
await new Promise((resolve) => setTimeout(resolve, 500))
router.push("/portal/dashboard")
} catch {
setError(t("networkError"))
}
}
return (
<div className="fixed inset-0 z-50 flex min-h-screen items-center justify-center bg-background text-foreground p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo & Branding */}
<div className="flex flex-col items-center space-y-2">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<Cannabis className="h-8 w-8 text-primary" />
</div>
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">{t("loginSubtitle")}</p>
</div>
{/* Login Card */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Error message */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* Email field */}
<div className="space-y-2">
<label
htmlFor="portal-email"
className="text-sm font-medium leading-none"
>
{t("email")}
</label>
<input
id="portal-email"
type="email"
autoComplete="email"
placeholder="max@beispiel.de"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
{...register("email")}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p className="text-xs text-destructive">
{t("invalidCredentials")}
</p>
)}
</div>
{/* Password field */}
<div className="space-y-2">
<label
htmlFor="portal-password"
className="text-sm font-medium leading-none"
>
{t("password")}
</label>
<input
id="portal-password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
{...register("password")}
aria-invalid={!!errors.password}
/>
{errors.password && (
<p className="text-xs text-destructive">
{t("invalidCredentials")}
</p>
)}
</div>
{/* Submit button */}
<button
type="submit"
disabled={isSubmitting}
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t("loggingIn")}
</>
) : (
t("loginButton")
)}
</button>
</form>
</div>
{/* Footer link to admin */}
<div className="text-center">
<Link
href="/login"
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
{t("adminLogin")}
</Link>
</div>
</div>
</div>
)
}
@@ -0,0 +1,236 @@
"use client"
import { format } from "date-fns"
import { de } from "date-fns/locale"
import { useTranslations } from "next-intl"
import { AlertTriangle, Calendar, Clock, Leaf } from "lucide-react"
import {
mockPortalHistory,
mockPortalQuota,
mockPortalUser,
} from "@/data/mock/portal"
import { cn } from "@/lib/utils"
import { PortalFooter } from "@/components/portal/portal-footer"
import { PortalNavbar } from "@/components/portal/portal-navbar"
function QuotaRing({
used,
limit,
label,
size = "lg",
}: {
used: number
limit: number
label: string
size?: "sm" | "lg"
}) {
const t = useTranslations("portal")
const percentage = Math.min((used / limit) * 100, 100)
const remaining = Math.max(limit - used, 0)
const circumference = 2 * Math.PI * 45
const strokeDashoffset = circumference - (percentage / 100) * circumference
const getColor = (pct: number) => {
if (pct >= 80) return "text-red-500"
if (pct >= 50) return "text-amber-500"
return "text-emerald-500"
}
const getTrackColor = (pct: number) => {
if (pct >= 80) return "stroke-red-500/20"
if (pct >= 50) return "stroke-amber-500/20"
return "stroke-emerald-500/20"
}
const ringSize = size === "lg" ? "w-40 h-40" : "w-28 h-28"
return (
<div className="flex flex-col items-center gap-2">
<div className={cn("relative", ringSize)}>
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="45"
fill="none"
strokeWidth="8"
className={getTrackColor(percentage)}
/>
<circle
cx="50"
cy="50"
r="45"
fill="none"
strokeWidth="8"
strokeLinecap="round"
className={cn("transition-all duration-700", getColor(percentage))}
style={{
strokeDasharray: circumference,
strokeDashoffset,
stroke: "currentColor",
}}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span
className={cn("font-bold", size === "lg" ? "text-2xl" : "text-lg")}
>
{remaining.toFixed(1)}
</span>
<span className="text-xs text-muted-foreground">
{t("grams")} {t("remaining")}
</span>
</div>
</div>
<div className="text-center">
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">
{used.toFixed(1)}
{t("grams")} / {limit}
{t("grams")}
</p>
</div>
</div>
)
}
export default function PortalDashboardPage() {
const t = useTranslations("portal")
const {
dailyUsedGrams,
dailyLimitGrams,
monthlyUsedGrams,
monthlyLimitGrams,
} = mockPortalQuota
const monthlyPercent = Math.round(
(monthlyUsedGrams / monthlyLimitGrams) * 100
)
const dailyLimitReached = dailyUsedGrams >= dailyLimitGrams
const lastDist = mockPortalHistory[0]
return (
<>
<PortalNavbar />
<main className="flex-1">
<div className="mx-auto max-w-4xl px-4 py-6 space-y-6">
{/* Welcome */}
<div>
<h1 className="text-xl font-bold sm:text-2xl">
{t("welcome", { name: mockPortalUser.firstName })}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{mockPortalUser.clubName} {t("memberNumber")}:{" "}
{mockPortalUser.memberNumber}
</p>
</div>
{/* Under-21 notice */}
{mockPortalQuota.isUnder21 && (
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{t("under21Notice")}</span>
</div>
)}
{/* Quota warning */}
{monthlyPercent >= 80 && (
<div className="rounded-lg border border-red-500/50 bg-red-500/10 p-3 text-sm text-red-700 dark:text-red-400 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>
{t("quotaWarning", { percent: String(monthlyPercent) })}
</span>
</div>
)}
{/* Quota Rings */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">{t("quota")}</h2>
<div className="flex flex-wrap items-center justify-center gap-8 sm:gap-12">
<QuotaRing
used={dailyUsedGrams}
limit={dailyLimitGrams}
label={t("dailyQuota")}
size="lg"
/>
<QuotaRing
used={monthlyUsedGrams}
limit={monthlyLimitGrams}
label={t("monthlyQuota")}
size="lg"
/>
</div>
{/* Next available */}
{dailyLimitReached && (
<div className="mt-4 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span>
{t("nextAvailable")}: {t("nextAvailableTomorrow")}
</span>
</div>
)}
</div>
{/* Last Distribution */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-3">
{t("lastDistribution")}
</h2>
{lastDist ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6 text-sm">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>
{format(new Date(lastDist.date), "dd.MM.yyyy, HH:mm", {
locale: de,
})}
</span>
</div>
<div className="flex items-center gap-2">
<Leaf className="h-4 w-4 text-muted-foreground" />
<span>{lastDist.strain}</span>
</div>
<div className="font-medium">
{lastDist.amountGrams}
{t("grams")}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t("noDistributions")}
</p>
)}
</div>
{/* Quick Info */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-3">{t("quickInfo")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">{t("memberNumber")}</p>
<p className="font-medium">{mockPortalUser.memberNumber}</p>
</div>
<div>
<p className="text-muted-foreground">{t("memberSince")}</p>
<p className="font-medium">
{format(new Date(mockPortalUser.joinedAt), "dd.MM.yyyy", {
locale: de,
})}
</p>
</div>
<div>
<p className="text-muted-foreground">{t("club")}</p>
<p className="font-medium">{mockPortalUser.clubName}</p>
</div>
</div>
</div>
</div>
</main>
<PortalFooter />
</>
)
}
@@ -0,0 +1,188 @@
"use client"
import { useState } from "react"
import { format } from "date-fns"
import { de } from "date-fns/locale"
import { useTranslations } from "next-intl"
import { Lock } from "lucide-react"
import type { PortalDistribution } from "@/data/mock/portal"
import { mockPortalHistory } from "@/data/mock/portal"
import { PortalFooter } from "@/components/portal/portal-footer"
import { PortalNavbar } from "@/components/portal/portal-navbar"
const ITEMS_PER_PAGE = 8
export default function PortalHistoryPage() {
const t = useTranslations("portal")
const [monthFilter, setMonthFilter] = useState<string>("all")
const [page, setPage] = useState(1)
// Get unique months from history for filter
const months = Array.from(
new Set(mockPortalHistory.map((d) => format(new Date(d.date), "yyyy-MM")))
).sort((a, b) => b.localeCompare(a))
// Filter by month
const filtered: PortalDistribution[] =
monthFilter === "all"
? mockPortalHistory
: mockPortalHistory.filter(
(d) => format(new Date(d.date), "yyyy-MM") === monthFilter
)
// Paginate
const totalPages = Math.ceil(filtered.length / ITEMS_PER_PAGE)
const paginated = filtered.slice(
(page - 1) * ITEMS_PER_PAGE,
page * ITEMS_PER_PAGE
)
return (
<>
<PortalNavbar />
<main className="flex-1">
<div className="mx-auto max-w-4xl px-4 py-6 space-y-6">
{/* Header + Filter */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-xl font-bold sm:text-2xl">{t("history")}</h1>
<select
value={monthFilter}
onChange={(e) => {
setMonthFilter(e.target.value)
setPage(1)
}}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("allMonths")}
>
<option value="all">{t("allMonths")}</option>
{months.map((m) => (
<option key={m} value={m}>
{format(new Date(m + "-01"), "MMMM yyyy", { locale: de })}
</option>
))}
</select>
</div>
{/* Desktop Table */}
<div className="hidden sm:block rounded-xl border bg-card shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">
{t("date")}
</th>
<th className="px-4 py-3 text-left font-medium">
{t("strain")}
</th>
<th className="px-4 py-3 text-right font-medium">
{t("amount")}
</th>
<th className="px-4 py-3 text-left font-medium">
{t("recordedBy")}
</th>
<th
className="px-4 py-3 text-center font-medium"
aria-label="Status"
>
<Lock className="h-3.5 w-3.5 mx-auto text-muted-foreground" />
</th>
</tr>
</thead>
<tbody className="divide-y">
{paginated.map((dist) => (
<tr key={dist.id} className="hover:bg-muted/30">
<td className="px-4 py-3">
{format(new Date(dist.date), "dd.MM.yyyy, HH:mm", {
locale: de,
})}
</td>
<td className="px-4 py-3">{dist.strain}</td>
<td className="px-4 py-3 text-right font-medium">
{dist.amountGrams}
{t("grams")}
</td>
<td className="px-4 py-3 text-muted-foreground">
{dist.recordedBy}
</td>
<td className="px-4 py-3 text-center">
<Lock className="h-3.5 w-3.5 mx-auto text-muted-foreground/50" />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Card Layout */}
<div className="sm:hidden space-y-3">
{paginated.map((dist) => (
<div
key={dist.id}
className="rounded-lg border bg-card p-4 shadow-sm space-y-2"
>
<div className="flex items-center justify-between">
<span className="font-medium">{dist.strain}</span>
<span className="font-bold text-primary">
{dist.amountGrams}
{t("grams")}
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{format(new Date(dist.date), "dd.MM.yyyy, HH:mm", {
locale: de,
})}
</span>
<div className="flex items-center gap-1">
<Lock className="h-3 w-3" />
<span>{dist.recordedBy}</span>
</div>
</div>
</div>
))}
</div>
{/* Empty state */}
{filtered.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
{t("noHistory")}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("pagination", {
from: String((page - 1) * ITEMS_PER_PAGE + 1),
to: String(Math.min(page * ITEMS_PER_PAGE, filtered.length)),
total: String(filtered.length),
})}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1.5 rounded-md border text-sm disabled:opacity-50 hover:bg-accent transition-colors"
>
{t("previous")}
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1.5 rounded-md border text-sm disabled:opacity-50 hover:bg-accent transition-colors"
>
{t("next")}
</button>
</div>
</div>
)}
</div>
</main>
<PortalFooter />
</>
)
}
@@ -0,0 +1,212 @@
"use client"
import { useState } from "react"
import { format } from "date-fns"
import { de } from "date-fns/locale"
import { useTranslations } from "next-intl"
import { Check, User } from "lucide-react"
import { mockPortalUser } from "@/data/mock/portal"
import { PortalFooter } from "@/components/portal/portal-footer"
import { PortalNavbar } from "@/components/portal/portal-navbar"
export default function PortalProfilePage() {
const t = useTranslations("portal")
const [passwordSuccess, setPasswordSuccess] = useState(false)
const [passwordError, setPasswordError] = useState<string | null>(null)
function handlePasswordChange(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = e.currentTarget
const formData = new FormData(form)
const newPass = formData.get("newPassword") as string
const confirmPass = formData.get("confirmPassword") as string
setPasswordError(null)
setPasswordSuccess(false)
if (newPass !== confirmPass) {
setPasswordError(t("passwordMismatch"))
return
}
// Mock success
setPasswordSuccess(true)
form.reset()
setTimeout(() => setPasswordSuccess(false), 3000)
}
return (
<>
<PortalNavbar />
<main className="flex-1">
<div className="mx-auto max-w-4xl px-4 py-6 space-y-6">
<h1 className="text-xl font-bold sm:text-2xl">{t("profile")}</h1>
{/* Personal Info (read-only) */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold">{t("personalInfo")}</h2>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
Name
</p>
<p className="font-medium">
{mockPortalUser.firstName} {mockPortalUser.lastName}
</p>
</div>
<div className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("email")}
</p>
<p className="font-medium">{mockPortalUser.email}</p>
</div>
<div className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("memberNumber")}
</p>
<p className="font-medium">{mockPortalUser.memberNumber}</p>
</div>
<div className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("memberSince")}
</p>
<p className="font-medium">
{format(new Date(mockPortalUser.joinedAt), "dd.MM.yyyy", {
locale: de,
})}
</p>
</div>
<div className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("club")}
</p>
<p className="font-medium">{mockPortalUser.clubName}</p>
</div>
</div>
</div>
{/* Change Password */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">
{t("changePassword")}
</h2>
{passwordSuccess && (
<div className="mb-4 rounded-lg border border-emerald-500/50 bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-400 flex items-center gap-2">
<Check className="h-4 w-4" />
{t("passwordChanged")}
</div>
)}
{passwordError && (
<div className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{passwordError}
</div>
)}
<form
onSubmit={handlePasswordChange}
className="space-y-4 max-w-sm"
>
<div className="space-y-2">
<label
htmlFor="currentPassword"
className="text-sm font-medium"
>
{t("currentPassword")}
</label>
<input
id="currentPassword"
name="currentPassword"
type="password"
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="••••••••"
/>
</div>
<div className="space-y-2">
<label htmlFor="newPassword" className="text-sm font-medium">
{t("newPassword")}
</label>
<input
id="newPassword"
name="newPassword"
type="password"
required
minLength={8}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="••••••••"
/>
</div>
<div className="space-y-2">
<label
htmlFor="confirmPassword"
className="text-sm font-medium"
>
{t("confirmPassword")}
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={8}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="••••••••"
/>
</div>
<button
type="submit"
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
>
{t("changePassword")}
</button>
</form>
</div>
{/* Preferences */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">{t("settings")}</h2>
<div className="space-y-4 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">{t("language")}</span>
<select
defaultValue="de"
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("language")}
>
<option value="de">{t("german")}</option>
<option value="en">{t("english")}</option>
</select>
</div>
<div className="flex items-center justify-between">
<span className="font-medium">{t("theme")}</span>
<div className="flex gap-1 rounded-md border p-0.5">
<button className="px-3 py-1 rounded text-xs bg-primary/10 text-primary font-medium">
{t("themeLight")}
</button>
<button className="px-3 py-1 rounded text-xs hover:bg-accent transition-colors">
{t("themeDark")}
</button>
<button className="px-3 py-1 rounded text-xs hover:bg-accent transition-colors">
{t("themeSystem")}
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<PortalFooter />
</>
)
}
@@ -0,0 +1,5 @@
import { NotFound404 } from "@/components/pages/not-found-404"
export default function NotFoundPage() {
return <NotFound404 />
}
@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,58 @@
"use client"
import { useEffect } from "react"
import { AlertTriangle, RefreshCw } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl font-bold text-center text-red-600">
Oops! Something went wrong
</CardTitle>
<CardDescription className="text-center">
We apologize for the inconvenience
</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error.message ||
"An unexpected error occurred. Please try again later."}
</AlertDescription>
</Alert>
</CardContent>
<CardFooter className="flex justify-center">
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" /> Try again
</Button>
</CardFooter>
</Card>
</div>
)
}
+256
View File
@@ -0,0 +1,256 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-lato:
var(--font-lato), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-cairo:
var(--font-cairo), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--color-sidebar: hsl(var(--sidebar-background));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-sidebar-primary: hsl(var(--sidebar-primary));
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-ring: hsl(var(--sidebar-ring));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-collapsible-down: collapsible-down 0.2s ease-out;
--animate-collapsible-up: collapsible-up 0.2s ease-out;
--animate-collapsible-right: collapsible-right 0.2s ease-out;
--animate-collapsible-left: collapsible-left 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes collapsible-down {
from {
height: 0;
}
to {
height: var(--radix-collapsible-content-height);
}
}
@keyframes collapsible-up {
from {
height: var(--radix-collapsible-content-height);
}
to {
height: 0;
}
}
@keyframes collapsible-right {
from {
width: 0;
}
to {
width: var(--radix-collapsible-content-width);
}
}
@keyframes collapsible-left {
from {
width: var(--radix-collapsible-content-width);
}
to {
width: 0;
}
}
}
@utility container {
margin-inline: auto;
padding-inline: 1rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-border, currentColor);
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
:root {
--background: 210 20% 98%;
--foreground: 215 14% 14%;
--card: 0 0% 100%;
--card-foreground: 215 14% 14%;
--popover: 0 0% 100%;
--popover-foreground: 215 14% 14%;
--primary: 145 63% 29%;
--primary-foreground: 0 0% 100%;
--secondary: 210 15% 93%;
--secondary-foreground: 215 14% 14%;
--muted: 210 15% 93%;
--muted-foreground: 215 10% 46%;
--accent: 210 15% 93%;
--accent-foreground: 215 14% 14%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--success: 145 63% 29%;
--success-foreground: 0 0% 100%;
--border: 210 15% 90%;
--input: 210 15% 90%;
--ring: 145 63% 29%;
--radius: 0.5rem;
--chart-1: 145 63% 29%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: var(--background);
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
/* Calendar vars */
--fc-small-font-size: 0.875em;
--fc-page-bg-color: hsl(var(--border));
--fc-neutral-bg-color: hsl(var(--border));
--fc-neutral-text-color: hsl(var(--accent-foreground));
--fc-border-color: hsl(var(--border));
--fc-button-text-color: hsl(var(--primary-foreground));
--fc-button-bg-color: hsl(var(--primary));
--fc-button-border-color: hsl(var(--primary));
--fc-button-hover-bg-color: hsl(150 64% 24%);
--fc-button-hover-border-color: hsl(var(--primary));
--fc-button-active-bg-color: hsl(150 64% 24%);
--fc-button-active-border-color: hsl(var(--primary) / 0);
--fc-event-bg-color: hsl(var(--primary));
--fc-event-border-color: hsl(var(--primary));
--fc-event-text-color: hsl(var(--primary-foreground));
--fc-event-selected-overlay-color: hsl(var(--muted));
--fc-more-link-bg-color: hsl(var(--muted));
--fc-more-link-text-color: inherit;
--fc-event-resizer-thickness: 8px;
--fc-event-resizer-dot-total-width: 8px;
--fc-event-resizer-dot-border-width: var(--radius);
--fc-non-business-color: rgba(215, 215, 215, 0.3);
--fc-bg-event-color: hsl(var(--success));
--fc-bg-event-opacity: 0.3;
--fc-highlight-color: rgba(188, 232, 241, 0.3);
--fc-today-bg-color: hsl(var(--primary) / 0.15);
--fc-now-indicator-color: hsl(var(--destructive));
}
.dark {
--background: 215 28% 7%;
--foreground: 210 29% 93%;
--card: 215 19% 11%;
--card-foreground: 210 29% 93%;
--popover: 215 19% 11%;
--popover-foreground: 210 29% 93%;
--primary: 145 63% 49%;
--primary-foreground: 215 28% 7%;
--secondary: 215 19% 16%;
--secondary-foreground: 210 29% 93%;
--muted: 215 19% 16%;
--muted-foreground: 215 15% 60%;
--accent: 215 19% 16%;
--accent-foreground: 210 29% 93%;
--destructive: 0 84% 45%;
--destructive-foreground: 210 29% 93%;
--success: 145 63% 42%;
--success-foreground: 215 28% 7%;
--border: 215 19% 18%;
--input: 215 19% 18%;
--ring: 145 63% 49%;
--chart-1: 145 63% 49%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
+62
View File
@@ -0,0 +1,62 @@
import { Cairo, Lato } from "next/font/google"
import { cn } from "@/lib/utils"
import "./globals.css"
import { Providers } from "@/providers"
import type { Metadata } from "next"
import type { ReactNode } from "react"
import { Toaster as Sonner } from "@/components/ui/sonner"
import { Toaster } from "@/components/ui/toaster"
// Define metadata for the application
// More info: https://nextjs.org/docs/app/building-your-application/optimizing/metadata
export const metadata: Metadata = {
title: {
template: "%s | CannaManage",
default: "CannaManage",
},
description: "Cannabis club management platform — CannaManage",
metadataBase: new URL(process.env.BASE_URL as string),
}
// Define fonts for the application
// More info: https://nextjs.org/docs/app/building-your-application/optimizing/fonts
const latoFont = Lato({
subsets: ["latin"],
weight: ["100", "300", "400", "700", "900"],
style: ["normal", "italic"],
variable: "--font-lato",
})
const cairoFont = Cairo({
subsets: ["arabic"],
weight: ["400", "700"],
style: ["normal"],
variable: "--font-cairo",
})
export default function RootLayout(props: { children: ReactNode }) {
const { children } = props
return (
<html lang="en" dir="ltr" suppressHydrationWarning>
<body
className={cn(
"[&:lang(en)]:font-lato [&:lang(ar)]:font-cairo", // Set font styles based on the language
"bg-background text-foreground antialiased overscroll-none", // Set background, text, , anti-aliasing styles, and overscroll behavior
latoFont.variable, // Include Lato font variable
cairoFont.variable // Include Cairo font variable
)}
>
<Providers locale="de">
{children}
<Toaster />
<Sonner />
</Providers>
</body>
</html>
)
}