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>
)
}
@@ -0,0 +1,51 @@
"use client"
import { signOut } from "next-auth/react"
import { useTranslations } from "next-intl"
import { LogOut } from "lucide-react"
interface LogoutButtonProps {
variant?: "icon" | "full"
className?: string
}
export function LogoutButton({
variant = "full",
className = "",
}: LogoutButtonProps) {
const t = useTranslations("auth")
async function handleLogout() {
// Call backend to revoke the token (best-effort)
try {
await fetch("/api/backend/auth/logout", { method: "POST" })
} catch {
// Ignore — sign out client-side regardless
}
await signOut({ callbackUrl: "/login" })
}
if (variant === "icon") {
return (
<button
onClick={handleLogout}
className={`inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground ${className}`}
title={t("logout")}
aria-label={t("logout")}
>
<LogOut className="h-4 w-4" />
</button>
)
}
return (
<button
onClick={handleLogout}
className={`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground ${className}`}
>
<LogOut className="h-4 w-4" />
<span>{t("logout")}</span>
</button>
)
}
@@ -0,0 +1,19 @@
// Refer to Lucide documentation for more details https://lucide.dev/guide/packages/lucide-react
import { icons } from "lucide-react"
import type { DynamicIconNameType } from "@/types"
import type { LucideProps } from "lucide-react"
interface DynamicIconProps extends LucideProps {
name: DynamicIconNameType
}
// Component to render a dynamic Lucide icon based on its name.
export function DynamicIcon({ name, ...props }: DynamicIconProps) {
const LucideIcon = icons[name] // Dynamically retrieve the icon by name.
// Return null if the icon name is invalid.
if (!LucideIcon) return null
return <LucideIcon {...props} />
}
@@ -0,0 +1,160 @@
"use client"
import { Fragment, useCallback, useEffect, useState } from "react"
import { usePathname, useRouter } from "next/navigation"
import { ChevronDown, Search } from "lucide-react"
import type { NavigationNestedItem, NavigationRootItem } from "@/types"
import type { DialogProps } from "@radix-ui/react-dialog"
import { navigationsData } from "@/data/navigations"
import { cn, isActivePathname } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { DialogTitle } from "@/components/ui/dialog"
import { Keyboard } from "@/components/ui/keyboard"
import { ScrollArea } from "@/components/ui/scroll-area"
import { DynamicIcon } from "@/components/dynamic-icon"
interface CommandMenuProps extends DialogProps {
buttonClassName?: string
}
export function CommandMenu({ buttonClassName, ...props }: CommandMenuProps) {
const [open, setOpen] = useState(false)
const pathname = usePathname()
const router = useRouter()
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return
}
e.preventDefault()
setOpen((open) => !open)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
const runCommand = useCallback((command: () => unknown) => {
setOpen(false)
command()
}, [])
const renderMenuItem = (item: NavigationRootItem | NavigationNestedItem) => {
// If the item has nested items, render it with a collapsible dropdown.
if (item.items) {
return (
<Collapsible key={item.title} className="group/collapsible">
<CommandItem asChild>
<CollapsibleTrigger className="w-full flex justify-between items-center gap-2 px-2 py-1.5 [&[data-state=open]>svg]:rotate-180">
<span className="flex items-center gap-2">
{"iconName" in item && (
<DynamicIcon name={item.iconName} className="h-4 w-4" />
)}
<span>{item.title}</span>
{"label" in item && (
<Badge variant="secondary">{item.label}</Badge>
)}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</CollapsibleTrigger>
</CommandItem>
<CollapsibleContent className="space-y-1 overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
{item.items.map((subItem: NavigationNestedItem) =>
renderMenuItem(subItem)
)}
</CollapsibleContent>
</Collapsible>
)
}
// Otherwise, render the item with a link.
if ("href" in item) {
const isActive = isActivePathname(item.href, pathname)
return (
<CommandItem
key={item.title}
onSelect={() => runCommand(() => router.push(item.href))}
className={cn(
"flex items-center gap-2 px-2 py-1.5",
isActive && "bg-accent"
)}
>
{"iconName" in item ? (
<DynamicIcon name={item.iconName} />
) : (
<DynamicIcon name="Circle" />
)}
<span>{item.title}</span>
{item.label && <Badge variant="secondary">{item.label}</Badge>}
</CommandItem>
)
}
}
return (
<>
<Button
variant="outline"
size="lg"
className={cn(
"max-w-64 w-full justify-start px-3 rounded-md bg-muted/50 text-muted-foreground",
buttonClassName
)}
onClick={() => setOpen(true)}
{...props}
>
<Search className="me-2 h-4 w-4" />
<span>Search...</span>
<Keyboard className="ms-auto">K</Keyboard>
</Button>
<CommandDialog open={open} onOpenChange={setOpen} {...props}>
<DialogTitle className="sr-only">Search Menu</DialogTitle>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<ScrollArea className="h-[300px] max-h-[300px]">
{navigationsData.map((nav) => (
<CommandGroup
key={nav.title}
heading={nav.title}
className="[&_[cmdk-group-items]]:space-y-1"
>
{nav.items.map((item) => (
<Fragment key={item.title}>{renderMenuItem(item)}</Fragment>
))}
</CommandGroup>
))}
</ScrollArea>
</CommandList>
</CommandDialog>
</>
)
}
@@ -0,0 +1,38 @@
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export function Footer() {
const currentYear = new Date().getFullYear()
return (
<footer className="bg-background border-t border-sidebar-border">
<div className="container flex justify-between items-center p-4 md:px-6">
<p className="text-xs text-muted-foreground md:text-sm">
© {currentYear}{" "}
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "link" }), "inline p-0")}
>
Shadboard
</a>
.
</p>
<p className="text-xs text-muted-foreground md:text-sm">
Designed & Developed by{" "}
<a
href="https://github.com/Qualiora"
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "link" }), "inline p-0")}
>
Qualiora
</a>
.
</p>
</div>
</footer>
)
}
@@ -0,0 +1,99 @@
"use client"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { DynamicIcon } from "@/components/dynamic-icon"
// Extend the global `Document` and `HTMLElement` interfaces to handle fullscreen API variations across browsers
declare global {
interface Document {
webkitExitFullscreen?: () => Promise<void>
msExitFullscreen?: () => Promise<void>
webkitFullscreenElement?: Element | null
msFullscreenElement?: Element | null
}
interface HTMLElement {
webkitRequestFullscreen?: () => Promise<void>
msRequestFullscreen?: () => Promise<void>
}
}
export function FullscreenToggle() {
const [isFullscreen, setIsFullscreen] = useState(false)
const toggleFullscreen = () => {
const element = document.documentElement
// If fullscreen mode is not active, activate it
if (!isFullscreen) {
if (element.requestFullscreen) {
// Standard fullscreen API
element.requestFullscreen()
} else if (element.webkitRequestFullscreen) {
// For Safari
element.webkitRequestFullscreen()
} else if (element.msRequestFullscreen) {
// For IE/Edge
element.msRequestFullscreen()
} else {
alert("Fullscreen mode is not supported in this browser.")
}
// If fullscreen mode is active, deactivate it
} else {
if (document.exitFullscreen) {
// Standard fullscreen API
document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
// For Safari
document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
// For IE/Edge
document.msExitFullscreen()
}
}
}
const handleFullscreenChange = () => {
// Update the fullscreen state when fullscreen changes
setIsFullscreen(
!!document.fullscreenElement ||
!!document.webkitFullscreenElement ||
!!document.msFullscreenElement
)
}
useEffect(() => {
// Add event listeners for fullscreen changes across various browsers
document.addEventListener("fullscreenchange", handleFullscreenChange)
document.addEventListener("webkitfullscreenchange", handleFullscreenChange)
document.addEventListener("msfullscreenchange", handleFullscreenChange)
// Cleanup event listeners to avoid memory leaks
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange)
document.removeEventListener(
"webkitfullscreenchange",
handleFullscreenChange
)
document.removeEventListener("msfullscreenchange", handleFullscreenChange)
}
}, [])
return (
<Button
variant="ghost"
size="icon"
onClick={toggleFullscreen}
aria-label="Toggle Fullscreen"
className="hidden md:inline-flex"
>
<DynamicIcon
name={isFullscreen ? "Shrink" : "Expand"}
className="size-4"
/>
</Button>
)
}
@@ -0,0 +1,32 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { FullscreenToggle } from "@/components/layout/full-screen-toggle"
import { ModeDropdown } from "@/components/layout/mode-dropdown"
import { UserDropdown } from "@/components/layout/user-dropdown"
import { ToggleMobileSidebar } from "../toggle-mobile-sidebar"
export function BottomBarHeader() {
return (
<div className="container flex h-14 justify-between items-center gap-4">
<ToggleMobileSidebar />
<Link href="/" className="hidden text-foreground font-black lg:flex">
<Image
src="/images/icons/shadboard.svg"
alt=""
height={24}
width={24}
className="dark:invert"
/>
<span>Shadboard</span>
</Link>
<div className="flex gap-2">
<FullscreenToggle />
<ModeDropdown />
<UserDropdown />
</div>
</div>
)
}
@@ -0,0 +1,15 @@
"use client"
import { Separator } from "@/components/ui/separator"
import { BottomBarHeader } from "./bottom-bar-header"
import { TopBarHeader } from "./top-bar-header"
export function HorizontalLayoutHeader() {
return (
<header className="sticky top-0 z-50 w-full bg-background border-b border-sidebar-border">
<TopBarHeader />
<Separator className="hidden bg-sidebar-border h-[0.5px] md:block" />
<BottomBarHeader />
</header>
)
}
@@ -0,0 +1,20 @@
import type { ReactNode } from "react"
import { Footer } from "../footer"
import { Sidebar } from "../sidebar"
import { HorizontalLayoutHeader } from "./horizontal-layout-header"
export function HorizontalLayout({ children }: { children: ReactNode }) {
return (
<>
<Sidebar />
<div className="w-full">
<HorizontalLayoutHeader />
<main className="min-h-[calc(100svh-9.85rem)] bg-muted/40">
{children}
</main>
<Footer />
</div>
</>
)
}
@@ -0,0 +1,91 @@
"use client"
import { Fragment } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import type { NavigationNestedItem, NavigationRootItem } from "@/types"
import { navigationsData } from "@/data/navigations"
import { cn, isActivePathname } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from "@/components/ui/menubar"
import { DynamicIcon } from "@/components/dynamic-icon"
export function TopBarHeaderMenubar() {
const pathname = usePathname()
const renderMenuItem = (item: NavigationRootItem | NavigationNestedItem) => {
// If the item has nested items, render it with a MenubarSub.
if (item.items) {
return (
<MenubarSub>
<MenubarSubTrigger className="gap-2">
{"iconName" in item && (
<DynamicIcon name={item.iconName} className="me-2 h-4 w-4" />
)}
<span>{item.title}</span>
{"label" in item && <Badge variant="secondary">{item.label}</Badge>}
</MenubarSubTrigger>
<MenubarSubContent className="max-h-[90vh] flex flex-col flex-wrap gap-1">
{item.items.map((subItem: NavigationNestedItem) => {
return (
<MenubarItem key={subItem.title} className="p-0">
{renderMenuItem(subItem)}
</MenubarItem>
)
})}
</MenubarSubContent>
</MenubarSub>
)
}
// Otherwise, render the item with a link.
if ("href" in item) {
const isActive = isActivePathname(item.href, pathname)
return (
<MenubarItem asChild>
<Link
href={item.href}
className={cn("w-full gap-2", isActive && "bg-accent")}
>
{"iconName" in item ? (
<DynamicIcon name={item.iconName} className="h-4 w-4" />
) : (
<DynamicIcon name="Circle" className="h-2 w-2" />
)}
<span>{item.title}</span>
{"label" in item && <Badge variant="secondary">{item.label}</Badge>}
</Link>
</MenubarItem>
)
}
}
return (
<Menubar className="border-0">
{navigationsData.map((nav) => (
<MenubarMenu key={nav.title}>
<MenubarTrigger>{nav.title}</MenubarTrigger>
<MenubarContent className="space-y-1">
{nav.items.map((item) => (
<Fragment key={item.title}>{renderMenuItem(item)}</Fragment>
))}
</MenubarContent>
</MenubarMenu>
))}
</Menubar>
)
}
@@ -0,0 +1,13 @@
"use client"
import { CommandMenu } from "@/components/layout/command-menu"
import { TopBarHeaderMenubar } from "./top-bar-header-menubar"
export function TopBarHeader() {
return (
<div className="container hidden justify-between items-center py-1 lg:flex">
<TopBarHeaderMenubar />
<CommandMenu buttonClassName="h-8" />
</div>
)
}
@@ -0,0 +1,17 @@
"use client"
import type { ReactNode } from "react"
import { useIsVertical } from "@/hooks/use-is-vertical"
import { HorizontalLayout } from "./horizontal-layout"
import { VerticalLayout } from "./vertical-layout"
export function Layout({ children }: { children: ReactNode }) {
const isVertical = useIsVertical()
return isVertical ? (
<VerticalLayout>{children}</VerticalLayout>
) : (
<HorizontalLayout>{children}</HorizontalLayout>
)
}
@@ -0,0 +1,66 @@
"use client"
import { useCallback } from "react"
import { MoonStar, Sun, SunMoon } from "lucide-react"
import type { ModeType } from "@/types"
import { useSettings } from "@/hooks/use-settings"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const modeIcons = {
light: Sun,
dark: MoonStar,
system: SunMoon,
}
export function ModeDropdown() {
const { settings, updateSettings } = useSettings()
const mode = settings.mode
const ModeIcon = modeIcons[mode]
const setMode = useCallback(
(modeName: ModeType) => {
updateSettings({ ...settings, mode: modeName })
},
[settings, updateSettings]
)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Mode">
<ModeIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Mode</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={mode}>
<DropdownMenuRadioItem value="light" onClick={() => setMode("light")}>
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark" onClick={() => setMode("dark")}>
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="system"
onClick={() => setMode("system")}
>
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,143 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ChevronDown } from "lucide-react"
import type { NavigationNestedItem, NavigationRootItem } from "@/types"
import { navigationsData } from "@/data/navigations"
import { isActivePathname } from "@/lib/utils"
import { useSettings } from "@/hooks/use-settings"
import { Badge } from "@/components/ui/badge"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
Sidebar as SidebarWrapper,
useSidebar,
} from "@/components/ui/sidebar"
import { DynamicIcon } from "@/components/dynamic-icon"
import { CommandMenu } from "./command-menu"
export function Sidebar() {
const pathname = usePathname()
const { openMobile, setOpenMobile, isMobile } = useSidebar()
const { settings } = useSettings()
const isHoizontalAndDesktop = settings.layout === "horizontal" && !isMobile
// If the layout is horizontal and not on mobile, don't render the sidebar. (We use a menubar for horizontal layout navigation.)
if (isHoizontalAndDesktop) return null
const renderMenuItem = (item: NavigationRootItem | NavigationNestedItem) => {
// If the item has nested items, render it with a collapsible dropdown.
if (item.items) {
return (
<Collapsible className="group/collapsible">
<CollapsibleTrigger asChild>
<SidebarMenuButton className="w-full justify-between [&[data-state=open]>svg]:rotate-180">
<span className="flex items-center">
{"iconName" in item && (
<DynamicIcon name={item.iconName} className="me-2 h-4 w-4" />
)}
<span>{item.title}</span>
{"label" in item && (
<Badge variant="secondary" className="me-2">
{item.label}
</Badge>
)}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<SidebarMenuSub>
{item.items.map((subItem: NavigationNestedItem) => (
<SidebarMenuItem key={subItem.title}>
{renderMenuItem(subItem)}
</SidebarMenuItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
)
}
// Otherwise, render the item with a link.
if ("href" in item) {
const isActive = isActivePathname(item.href, pathname)
return (
<SidebarMenuButton
isActive={isActive}
onClick={() => setOpenMobile(!openMobile)}
asChild
>
<Link href={item.href}>
{"iconName" in item && (
<DynamicIcon name={item.iconName} className="h-4 w-4" />
)}
<span>{item.title}</span>
{"label" in item && <Badge variant="secondary">{item.label}</Badge>}
</Link>
</SidebarMenuButton>
)
}
}
return (
<SidebarWrapper side="left">
<SidebarHeader>
<Link
href="/"
className="w-fit flex text-foreground font-black p-2 pb-0 mb-2"
onClick={() => isMobile && setOpenMobile(!openMobile)}
>
<Image
src="/images/icons/shadboard.svg"
alt=""
height={24}
width={24}
className="dark:invert"
/>
<span>Shadboard</span>
</Link>
<CommandMenu buttonClassName="max-w-full" />
</SidebarHeader>
<ScrollArea>
<SidebarContent className="gap-0">
{navigationsData.map((nav) => (
<SidebarGroup key={nav.title}>
<SidebarGroupLabel>{nav.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{nav.items.map((item) => (
<SidebarMenuItem key={item.title}>
{renderMenuItem(item)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
</ScrollArea>
</SidebarWrapper>
)
}
@@ -0,0 +1,24 @@
"use client"
import { PanelLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useSidebar } from "@/components/ui/sidebar"
export function ToggleMobileSidebar() {
const { isMobile, openMobile, setOpenMobile } = useSidebar()
if (isMobile) {
return (
<Button
data-sidebar="trigger"
variant="ghost"
size="icon"
onClick={() => setOpenMobile(!openMobile)}
aria-label="Toggle Sidebar"
>
<PanelLeft className="h-4 w-4" />
</Button>
)
}
}
@@ -0,0 +1,76 @@
import Link from "next/link"
import { LogOut, User, UserCog } from "lucide-react"
import { userData } from "@/data/user"
import { getInitials } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function UserDropdown() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="rounded-lg"
aria-label="User"
>
<Avatar className="size-9">
<AvatarImage src={userData?.avatar} alt="" />
<AvatarFallback className="bg-transparent">
{userData?.name && getInitials(userData.name)}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent forceMount>
<DropdownMenuLabel className="flex gap-2">
<Avatar>
<AvatarImage src={userData?.avatar} alt="Avatar" />
<AvatarFallback className="bg-transparent">
{userData?.name && getInitials(userData.name)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col overflow-hidden">
<p className="text-sm font-medium truncate">John Doe</p>
<p className="text-xs text-muted-foreground font-semibold truncate">
{userData?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className="max-w-48">
<DropdownMenuItem asChild>
<Link href="/">
<User className="me-2 size-4" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/">
<UserCog className="me-2 size-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut className="me-2 size-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,20 @@
import type { ReactNode } from "react"
import { Footer } from "../footer"
import { Sidebar } from "../sidebar"
import { VerticalLayoutHeader } from "./vertical-layout-header"
export function VerticalLayout({ children }: { children: ReactNode }) {
return (
<>
<Sidebar />
<div className="w-full">
<VerticalLayoutHeader />
<main className="min-h-[calc(100svh-6.82rem)] bg-muted/40">
{children}
</main>
<Footer />
</div>
</>
)
}
@@ -0,0 +1,23 @@
"use client"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { FullscreenToggle } from "@/components/layout/full-screen-toggle"
import { ModeDropdown } from "@/components/layout/mode-dropdown"
import { UserDropdown } from "@/components/layout/user-dropdown"
import { ToggleMobileSidebar } from "../toggle-mobile-sidebar"
export function VerticalLayoutHeader() {
return (
<header className="sticky top-0 z-50 w-full bg-background border-b border-sidebar-border">
<div className="container flex h-14 justify-between items-center gap-4">
<ToggleMobileSidebar />
<div className="grow flex justify-end gap-2">
<SidebarTrigger className="hidden lg:flex lg:me-auto" />
<FullscreenToggle />
<ModeDropdown />
<UserDropdown />
</div>
</div>
</header>
)
}
@@ -0,0 +1,31 @@
import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui/button"
export function NotFound404() {
return (
<div className="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 text-center text-foreground bg-background p-4">
<div className="flex flex-col-reverse justify-center items-center gap-y-6 md:flex-row md:text-start">
<Image
src="/images/illustrations/characters/character-02.svg"
alt=""
height={232}
width={249}
priority
/>
<h1 className="inline-grid text-6xl font-black">
404 <span className="text-3xl font-semibold">Page Not Found</span>
</h1>
</div>
<p className="max-w-prose text-xl text-muted-foreground">
We couldn&apos;t find the page you&apos;re looking for. It might have
been moved or doesn&apos;t exist.
</p>
<Button size="lg" asChild>
<Link href="/">Home Page</Link>
</Button>
</div>
)
}
@@ -0,0 +1,22 @@
"use client"
import { useTranslations } from "next-intl"
import { Cannabis } from "lucide-react"
export function PortalFooter() {
const t = useTranslations("portal")
return (
<footer className="mt-auto border-t bg-muted/30">
<div className="mx-auto flex max-w-4xl items-center justify-between px-4 py-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Cannabis className="h-3 w-3" />
<span>{t("footerText")}</span>
</div>
<div className="text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} CannaManage
</div>
</div>
</footer>
)
}
@@ -0,0 +1,79 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl"
import { Cannabis, History, LayoutDashboard, LogOut, User } from "lucide-react"
import { mockPortalUser } from "@/data/mock/portal"
import { cn } from "@/lib/utils"
import { ModeDropdown } from "@/components/layout/mode-dropdown"
const navItems = [
{ href: "/portal/dashboard", icon: LayoutDashboard, labelKey: "dashboard" },
{ href: "/portal/history", icon: History, labelKey: "history" },
{ href: "/portal/profile", icon: User, labelKey: "profile" },
] as const
export function PortalNavbar() {
const t = useTranslations("portal")
const pathname = usePathname()
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-4">
{/* Logo + Club Name */}
<Link
href="/portal/dashboard"
className="flex items-center gap-2 font-semibold"
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
<Cannabis className="h-4 w-4 text-primary" />
</div>
<span className="hidden sm:inline-block text-sm">
{mockPortalUser.clubName}
</span>
</Link>
{/* Navigation Links */}
<nav className="flex items-center gap-1">
{navItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<item.icon className="h-4 w-4" />
<span className="hidden sm:inline-block">
{t(item.labelKey)}
</span>
</Link>
)
})}
</nav>
{/* Right Side: Theme + Logout */}
<div className="flex items-center gap-1">
<ModeDropdown />
<Link
href="/portal-login"
className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
aria-label={t("logout")}
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline-block">{t("logout")}</span>
</Link>
</div>
</div>
</header>
)
}
@@ -0,0 +1,142 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
@@ -0,0 +1,60 @@
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
export function Alert({
className,
variant,
...props
}: ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
export function AlertTitle({ className, ...props }: ComponentProps<"div">) {
return (
<h5
data-slot="alert-title"
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
)
}
export function AlertDescription({
className,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
)
}
@@ -0,0 +1,155 @@
"use client"
import Link from "next/link"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import type { ComponentProps, MouseEvent } from "react"
import { cn, getInitials } from "@/lib/utils"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export function Avatar({
className,
...props
}: ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn("relative flex h-10 w-10 shrink-0", className)}
{...props}
/>
)
}
export function AvatarImage({
className,
...props
}: ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square h-full w-full bg-muted rounded-lg object-cover",
className
)}
{...props}
/>
)
}
export function AvatarFallback({
className,
...props
}: ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
className={cn(
"flex h-full w-full items-center justify-center bg-muted rounded-lg",
className
)}
{...props}
/>
)
}
export const avatarStackVariants = cva(
"transition duration-300 hover:scale-105 hover:z-10",
{
variants: {
size: {
default: "h-10 w-10",
sm: "h-9 w-9 text-sm",
lg: "h-11 w-11",
},
},
defaultVariants: {
size: "default",
},
}
)
interface AvatarStackProps
extends ComponentProps<"div">,
VariantProps<typeof avatarStackVariants> {
avatars: { src?: string; alt: string; href?: string }[]
limit?: number
onMoreButtonClick?: (event: MouseEvent<HTMLButtonElement>) => void
}
export function AvatarStack({
avatars,
limit = 4,
size,
onMoreButtonClick,
className,
...props
}: AvatarStackProps) {
const limitedAvatars = avatars.slice(0, limit)
const remainingCount = avatars.length - limitedAvatars.length
return (
<div className={cn("flex", className)} {...props}>
{limitedAvatars.slice(0, limit).map((avatar) => (
<TooltipProvider
key={`${avatar.alt}-${avatar.src}`}
delayDuration={200}
>
<Tooltip>
<TooltipTrigger className="-ms-1 -me-1">
{avatar.href ? (
<Link href={avatar.href}>
<Avatar className={avatarStackVariants({ size })}>
<AvatarImage
src={avatar.src}
className="border-2 border-background"
/>
<AvatarFallback className="border-2 border-background">
{getInitials(avatar.alt)}
</AvatarFallback>
</Avatar>
</Link>
) : (
<Avatar className={avatarStackVariants({ size })}>
<AvatarImage
src={avatar.src}
className="border-2 border-background"
/>
<AvatarFallback className="border-2 border-background">
{getInitials(avatar.alt)}
</AvatarFallback>
</Avatar>
)}
</TooltipTrigger>
<TooltipContent className="capitalize -me-[1.23rem]">
<p>{avatar.alt}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
{/* Show "+N" button if avatars exceed the limit */}
{remainingCount > 0 && (
<button
type="button"
onClick={onMoreButtonClick}
className="-ms-1 -me-1"
aria-label="Show more"
>
<Avatar className={avatarStackVariants({ size })}>
<AvatarFallback className="border-2 border-background">
+{remainingCount}
</AvatarFallback>
</Avatar>
</button>
)}
</div>
)
}
@@ -0,0 +1,47 @@
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
type BadgeProps = ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & {
asChild?: boolean
}
export function Badge({
className,
variant,
asChild = false,
...props
}: BadgeProps) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
@@ -0,0 +1,107 @@
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority"
import { LoaderCircle } from "lucide-react"
import type { IconType } from "@/types"
import type { VariantProps } from "class-variance-authority"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors cursor-pointer focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
interface ButtonProps
extends ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
interface ButtonLoadingProps extends ButtonProps {
isLoading: boolean
loadingIconClassName?: string
iconClassName?: string
icon?: IconType
}
export function ButtonLoading({
isLoading,
disabled,
children,
loadingIconClassName,
iconClassName,
icon: Icon,
...props
}: ButtonLoadingProps) {
let RenderedIcon
if (isLoading) {
RenderedIcon = (
<LoaderCircle
className={cn("me-2 size-4 animate-spin", loadingIconClassName)}
aria-hidden
/>
)
} else if (Icon) {
RenderedIcon = (
<Icon className={cn("me-2 size-4", iconClassName)} aria-hidden />
)
}
return (
<Button
data-slot="button-loading"
type="submit"
disabled={isLoading || disabled}
aria-live="assertive"
aria-label={isLoading ? "Loading" : props["aria-label"]}
{...props}
>
{RenderedIcon}
{children}
</Button>
)
}
@@ -0,0 +1,73 @@
"use client"
import { DayPicker } from "react-day-picker"
import { ChevronLeft, ChevronRight } from "lucide-react"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = ComponentProps<typeof DayPicker>
export function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
month: "space-y-4",
month_caption: "flex justify-center pt-1 items-center",
caption_label: "text-sm font-medium",
nav: "relative gap-x-1 flex items-center",
button_previous: cn(
buttonVariants({ variant: "outline" }),
"absolute top-0 start-0 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
button_next: cn(
buttonVariants({ variant: "outline" }),
"absolute top-0 end-0 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
month_grid: "w-full border-collapse space-y-1",
weekdays: "flex",
weekday:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
week: "flex w-full mt-2",
day: cn(
buttonVariants({ variant: "ghost" }),
"relative h-8 w-8 p-0 font-normal text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-e-md"
),
day_button: "cursor-pointer h-full w-full aria-selected:opacity-100",
range_start: "rounded-md!",
range_end: "rounded-md!",
selected: cn(
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
props.mode === "range" && "rounded-none"
),
today: "bg-accent text-accent-foreground",
outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
disabled: "text-muted-foreground opacity-50",
range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
hidden: "invisible",
...classNames,
}}
components={{
Chevron: (props) => {
if (props.orientation === "left") {
return <ChevronLeft className="h-4 w-4" />
}
return <ChevronRight className="h-4 w-4" />
},
}}
{...props}
/>
)
}
@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
interface CardProps extends ComponentProps<"div"> {
asChild?: boolean
}
export function Card({ className, asChild, ...props }: CardProps) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="card"
className={cn(
"rounded-lg border bg-card text-card-foreground",
className
)}
{...props}
/>
)
}
export function CardHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
)
}
interface CardTitleProps extends ComponentProps<"div"> {
asChild?: boolean
}
export function CardTitle({ className, asChild, ...props }: CardTitleProps) {
const Comp = asChild ? Slot : "h2"
return (
<Comp
data-slot="card-title"
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
}
export function CardDescription({
className,
...props
}: ComponentProps<"div">) {
return (
<p
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export function CardContent({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("p-6 pt-0", className)}
{...props}
/>
)
}
export function CardFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
)
}
@@ -0,0 +1,37 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function Collapsible({
...props
}: ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
export function CollapsibleTrigger({
className,
...props
}: ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
)
}
export function CollapsibleContent({
...props
}: ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
@@ -0,0 +1,157 @@
"use client"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
export function Command({
className,
...props
}: ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
)
}
type CommandDialogProps = ComponentProps<typeof Dialog> & {
title?: string
description?: string
}
export function CommandDialog({ children, ...props }: CommandDialogProps) {
return (
<Dialog {...props}>
<DialogContent
className="overflow-hidden p-0 rounded-md"
aria-describedby={undefined}
>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
export function CommandInput({
className,
...props
}: ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex items-center border-b px-3"
>
<Search className="me-2 size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
export function CommandList({
className,
...props
}: ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] overflow-y-auto overflow-x-hidden",
className
)}
{...props}
/>
)
}
export function CommandEmpty({
...props
}: ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
export function CommandGroup({
className,
...props
}: ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
export function CommandSeparator({
className,
...props
}: ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
export function CommandItem({
className,
...props
}: ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"cursor-pointer relative flex gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
)
}
export function CommandShortcut({
className,
...props
}: ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ms-auto text-sm tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
@@ -0,0 +1,138 @@
"use client"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function Dialog({
...props
}: ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
export function DialogTrigger({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Trigger>) {
return (
<DialogPrimitive.Trigger
data-slot="dialog-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
)
}
export function DialogPortal({
...props
}: ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
export function DialogClose({
...props
}: ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
export function DialogOverlay({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
export function DialogContent({
className,
children,
...props
}: ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%-2rem)] grid translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg bg-background duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close
className="cursor-pointer absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
aria-label="Close"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
export function DialogHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-start",
className
)}
{...props}
/>
)
}
export function DialogFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2",
className
)}
{...props}
/>
)
}
export function DialogTitle({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
)
}
export function DialogDescription({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
@@ -0,0 +1,130 @@
"use client"
import { Drawer as DrawerPrimitive } from "vaul"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function Drawer({
shouldScaleBackground = true,
...props
}: ComponentProps<typeof DrawerPrimitive.Root>) {
return (
<DrawerPrimitive.Root
data-slot="drawer"
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
}
export function DrawerTrigger({
className,
...props
}: ComponentProps<typeof DrawerPrimitive.Trigger>) {
return (
<DrawerPrimitive.Trigger
data-slot="drawer-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
)
}
export function DrawerPortal({
...props
}: ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
export function DrawerClose({
...props
}: ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
export function DrawerOverlay({
className,
...props
}: ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
)
}
export function DrawerContent({
className,
children,
...props
}: ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
export function DrawerHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("grid gap-1.5 p-4 text-center sm:text-start", className)}
{...props}
/>
)
}
export function DrawerFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
export function DrawerTitle({
className,
...props
}: ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
)
}
export function DrawerDescription({
className,
...props
}: ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
@@ -0,0 +1,252 @@
"use client"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Dot } from "lucide-react"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function DropdownMenu({
...props
}: ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
export function DropdownMenuPortal({
...props
}: ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
export function DropdownMenuTrigger({
className,
...props
}: ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
)
}
export function DropdownMenuGroup({
...props
}: ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
export function DropdownMenuRadioGroup({
...props
}: ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
export function DropdownMenuSub({
...props
}: ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
type DropdownMenuSubTriggerProps = ComponentProps<
typeof DropdownMenuPrimitive.SubTrigger
> & {
inset?: boolean
}
export function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: DropdownMenuSubTriggerProps) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"cursor-pointer flex items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:ps-8 focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
>
{children}
<ChevronRight className="ms-auto h-4 w-4 rtl:-scale-100" />
</DropdownMenuPrimitive.SubTrigger>
)
}
export function DropdownMenuSubContent({
className,
...props
}: ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
)
}
export function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
type DropdownMenuItemProps = ComponentProps<
typeof DropdownMenuPrimitive.Item
> & {
inset?: boolean
variant?: "default" | "destructive"
}
export function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: DropdownMenuItemProps) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
export function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute start-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
export function DropdownMenuRadioItem({
className,
children,
...props
}: ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<span className="absolute start-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Dot className="h-8 w-8 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
type DropdownMenuLabelProps = ComponentProps<
typeof DropdownMenuPrimitive.Label
> & {
inset?: boolean
}
export function DropdownMenuLabel({
className,
inset,
...props
}: DropdownMenuLabelProps) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:ps-8",
className
)}
{...props}
/>
)
}
export function DropdownMenuSeparator({
className,
...props
}: ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
)
}
export function DropdownMenuShortcut({
className,
...props
}: ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("ms-auto text-sm tracking-widest opacity-60", className)}
{...props}
/>
)
}
@@ -0,0 +1,17 @@
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function Input({ className, type, ...props }: ComponentProps<"input">) {
return (
<input
data-slot="input"
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
@@ -0,0 +1,23 @@
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function Keyboard({
className,
children,
...props
}: ComponentProps<"kbd">) {
return (
<kbd
data-slot="keyboard"
className={cn(
"pointer-events-none select-none h-5 inline-flex items-center gap-x-1 px-1.5 bg-muted text-sm text-muted-foreground font-mono border rounded-sm",
"before:content-['⌘']",
className
)}
{...props}
>
{children}
</kbd>
)
}
@@ -0,0 +1,25 @@
"use client"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
export function Label({
className,
...props
}: ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(labelVariants(), className)}
{...props}
/>
)
}
@@ -0,0 +1,262 @@
"use client"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Dot } from "lucide-react"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function MenubarMenu({
...props
}: ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
export function MenubarGroup({
...props
}: ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
export function MenubarPortal({
...props
}: ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
export function MenubarRadioGroup({
...props
}: ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
export function Menubar({
className,
...props
}: ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"flex h-9 items-center gap-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
)
}
export function MenubarTrigger({
className,
...props
}: ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"flex cursor-pointer select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
)
}
export function MenubarSubTrigger({
className,
inset,
children,
...props
}: ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground cursor-pointer flex items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:ps-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ms-auto h-4 w-4 rtl:-scale-x-100" />
</MenubarPrimitive.SubTrigger>
)
}
export function MenubarSub({
...props
}: ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
export function MenubarSubContent({
className,
...props
}: ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
)
}
export function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPortal>
)
}
type MenubarItemProps = ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}
export function MenubarItem({
className,
inset,
variant = "default",
...props
}: MenubarItemProps) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
export function MenubarCheckboxItem({
className,
children,
checked,
...props
}: ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute start-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
export function MenubarRadioItem({
className,
children,
...props
}: ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<span className="absolute start-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Dot className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
type MenubarLabelProps = ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
export function MenubarLabel({
className,
inset,
...props
}: MenubarLabelProps) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "ps-8",
className
)}
{...props}
/>
)
}
export function MenubarSeparator({
className,
...props
}: ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
)
}
export function MenubarShortcut({
className,
...props
}: ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"ms-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
@@ -0,0 +1,43 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value: number
max?: number
indicatorClassName?: string
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value, max = 100, indicatorClassName, ...props }, ref) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
return (
<div
ref={ref}
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn(
"bg-muted relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<div
className={cn(
"h-full rounded-full transition-all duration-300",
indicatorClassName
)}
style={{ width: `${percentage}%` }}
/>
</div>
)
}
)
Progress.displayName = "Progress"
export { Progress }
@@ -0,0 +1,61 @@
"use client"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
type ScrollAreaProps = ComponentProps<typeof ScrollAreaPrimitive.Root> &
Pick<
ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
"orientation"
>
export function ScrollArea({
orientation,
className,
children,
...props
}: ScrollAreaProps) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="h-full w-full rounded-[inherit]"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar orientation={orientation} />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
export function ScrollBar({
className,
orientation = "vertical",
...props
}: ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = React.forwardRef<
HTMLSelectElement,
React.SelectHTMLAttributes<HTMLSelectElement> & {
label?: string
}
>(({ className, children, ...props }, ref) => (
<div className="relative">
<select
ref={ref}
className={cn(
"border-input bg-background ring-offset-background focus:ring-ring flex h-9 w-full appearance-none rounded-md border px-3 py-1 pe-8 text-sm shadow-sm transition-colors focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute end-2 top-1/2 h-4 w-4 -translate-y-1/2 opacity-50" />
</div>
))
Select.displayName = "Select"
export { Select }
@@ -0,0 +1,68 @@
"use client"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
}
export function SeparatorWithText({
className,
orientation = "horizontal",
decorative = true,
children,
...props
}: ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<div
data-slot="separator-with-text"
className={cn(
"flex justify-between items-center",
orientation === "horizontal" ? "w-full" : "flex-col h-full",
className
)}
>
<Separator
decorative={decorative}
orientation={orientation}
className="shrink"
{...props}
/>
<span
className={cn(
"shrink-0 px-2 text-sm text-muted-foreground uppercase",
orientation === "vertical" && "-rotate-90 rtl:rotate-90"
)}
>
{children}
</span>
<Separator
decorative={decorative}
orientation={orientation}
className="shrink"
{...props}
/>
</div>
)
}
@@ -0,0 +1,150 @@
"use client"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva } from "class-variance-authority"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function Sheet({
...props
}: ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
export function SheetTrigger({
className,
...props
}: ComponentProps<typeof SheetPrimitive.Trigger>) {
return (
<SheetPrimitive.Trigger
data-slot="sheet-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
)
}
export function SheetClose({
...props
}: ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
export function SheetPortal({
...props
}: ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
export function SheetOverlay({
className,
...props
}: ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
export const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-72 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-72 border-s data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
start:
"inset-y-0 start-0 h-full w-72 border-e data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left data-[state=closed]:rtl:slide-out-to-right data-[state=open]:rtl:slide-in-from-right sm:max-w-sm",
end: "inset-y-0 end-0 h-full w-72 border-s data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right data-[state=closed]:rtl:slide-out-to-left data-[state=open]:rtl:slide-in-from-left sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
type SheetContentProps = ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" | "start" | "end"
}
export function SheetContent({
className,
children,
side = "right",
...props
}: SheetContentProps) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
</SheetPrimitive.Content>
</SheetPortal>
)
}
export function SheetHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col space-y-1", className)}
{...props}
/>
)
}
export function SheetFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2",
className
)}
{...props}
/>
)
}
export function SheetTitle({
className,
...props
}: ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
)
}
export function SheetDescription({
className,
...props
}: ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
@@ -0,0 +1,706 @@
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import type { VariantProps } from "class-variance-authority"
import type { CSSProperties, ComponentProps } from "react"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export const SIDEBAR_COOKIE_NAME = "sidebar:state"
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
export const SIDEBAR_WIDTH = "16rem"
export const SIDEBAR_WIDTH_MOBILE = "18rem"
export const SIDEBAR_WIDTH_ICON = "3rem"
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextType = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = createContext<SidebarContextType | null>(null)
export function useSidebar() {
const context = useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
type SidebarProviderProps = ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: SidebarProviderProps) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = useState(defaultOpen)
const open = openProp ?? _open
const setOpen = useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = useMemo<SidebarContextType>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
type SidebarProps = ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
export function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: SidebarProps) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-slot="sidebar"
data-sidebar="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar text-sidebar-foreground p-0"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as CSSProperties
}
side={side}
aria-describedby={undefined}
>
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
return (
<div
data-slot="sidebar"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
className="group peer hidden md:block text-sidebar-foreground"
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-(--sidebar-width) bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
export function SidebarTrigger({
className,
onClick,
...props
}: ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-slot="sidebar-trigger"
data-sidebar="trigger"
variant="ghost"
size="icon"
className={className}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft className="h-4 w-4" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
export function SidebarRail({ className, ...props }: ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-slot="sidebar-rail"
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
export function SidebarInset({ className, ...props }: ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className
)}
{...props}
/>
)
}
export function SidebarInput({
className,
...props
}: ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
}
export function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
export function SidebarFooter({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
export function SidebarSeparator({
className,
...props
}: ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
export function SidebarContent({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
export function SidebarGroup({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
type SidebarGroupLabelProps = ComponentProps<"div"> & { asChild?: boolean }
export function SidebarGroupLabel({
className,
asChild = false,
...props
}: SidebarGroupLabelProps) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-sm text-sidebar-foreground/70 outline-hidden ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
type SidebarGroupActionProps = ComponentProps<"button"> & { asChild?: boolean }
export function SidebarGroupAction({
className,
asChild = false,
...props
}: SidebarGroupActionProps) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export function SidebarGroupContent({
className,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
export function SidebarMenu({ className, ...props }: ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
export function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
export const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
type SidebarMenuButtonProps = ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
export function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: SidebarMenuButtonProps) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
type SidebarMenuActionProps = ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
export function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: SidebarMenuActionProps) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
}
export function SidebarMenuBadge({
className,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-semibold tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
type SidebarMenuSkeletonProps = ComponentProps<"div"> & {
showIcon?: boolean
}
export function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: SidebarMenuSkeletonProps) {
// Random width between 50 to 90%.
const width = useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-(--skeleton-width)"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as CSSProperties
}
/>
</div>
)
}
export function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-s border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export function SidebarMenuSubItem({ ...props }: ComponentProps<"li">) {
return <li data-slot="sidebar-menu-sub-item" {...props} />
}
type SidebarMenuSubButtonProps = ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
export function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: SidebarMenuSubButtonProps) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
@@ -0,0 +1,13 @@
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function Skeleton({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
@@ -0,0 +1,34 @@
"use client"
import { Toaster as Sonner } from "sonner"
import type { ComponentProps } from "react"
import { useSettings } from "@/hooks/use-settings"
type ToasterProps = ComponentProps<typeof Sonner>
export function Toaster({ ...props }: ToasterProps) {
const { settings } = useSettings()
const mode = settings.mode
return (
<Sonner
theme={mode as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
@@ -0,0 +1,128 @@
"use client"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva } from "class-variance-authority"
import { X } from "lucide-react"
import type { VariantProps } from "class-variance-authority"
import type {
ComponentProps,
ComponentPropsWithoutRef,
ReactElement,
} from "react"
import { cn } from "@/lib/utils"
export const ToastProvider = ToastPrimitives.Provider
export function ToastViewport({
className,
...props
}: ComponentProps<typeof ToastPrimitives.Viewport>) {
return (
<ToastPrimitives.Viewport
data-slot="toast-viewport"
className={cn(
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
)
}
export const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between gap-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export function Toast({
className,
variant,
...props
}: ComponentProps<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>) {
return (
<ToastPrimitives.Root
data-slot="toast-root"
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
}
export function ToastAction({
className,
...props
}: ComponentProps<typeof ToastPrimitives.Action>) {
return (
<ToastPrimitives.Action
data-slot="toast-action"
className={cn(
"cursor-pointer inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-hidden focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive",
className
)}
{...props}
/>
)
}
export function ToastClose({
className,
...props
}: ComponentProps<typeof ToastPrimitives.Close>) {
return (
<ToastPrimitives.Close
data-slot="toast-close"
className={cn(
"cursor-pointer absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
)
}
export function ToastTitle({
className,
...props
}: ComponentProps<typeof ToastPrimitives.Title>) {
return (
<ToastPrimitives.Title
data-slot="toast-title"
className={cn("font-semibold [&+div]:text-sm", className)}
{...props}
/>
)
}
export function ToastDescription({
className,
...props
}: ComponentProps<typeof ToastPrimitives.Title>) {
return (
<ToastPrimitives.Description
data-slot="toast-description"
className={cn("text-sm opacity-90", className)}
{...props}
/>
)
}
export type ToastProps = ComponentPropsWithoutRef<typeof Toast>
export type ToastActionElement = ReactElement<typeof ToastAction>
@@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
@@ -0,0 +1,61 @@
"use client"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export function TooltipProvider({
delayDuration = 0,
...props
}: ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
export function Tooltip({
...props
}: ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
export function TooltipTrigger({
className,
...props
}: ComponentProps<typeof TooltipPrimitive.Trigger>) {
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
)
}
export function TooltipContent({
className,
sideOffset = 0,
...props
}: ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
)
}
@@ -0,0 +1,70 @@
"use client"
import { createContext, useCallback, useEffect, useState } from "react"
import { useCookie } from "react-use"
import type { LocaleType, SettingsType } from "@/types"
import type { ReactNode } from "react"
export const defaultSettings: SettingsType = {
theme: "zinc",
mode: "dark",
radius: 0.5,
layout: "vertical",
locale: "de",
}
export const SettingsContext = createContext<
| {
settings: SettingsType
updateSettings: (newSettings: SettingsType) => void
resetSettings: () => void
}
| undefined
>(undefined)
export function SettingsProvider({
locale,
children,
}: {
locale: LocaleType
children: ReactNode
}) {
const [storedSettings, setStoredSettings, deleteStoredSettings] =
useCookie("settings")
const [settings, setSettings] = useState<SettingsType | null>(null)
useEffect(() => {
if (storedSettings) {
setSettings(JSON.parse(storedSettings))
} else {
setSettings({ ...defaultSettings, locale })
}
}, [storedSettings, locale])
const updateSettings = useCallback(
(newSettings: SettingsType) => {
setStoredSettings(JSON.stringify(newSettings))
setSettings(newSettings)
},
[setStoredSettings]
)
const resetSettings = useCallback(() => {
deleteStoredSettings()
setSettings(defaultSettings)
}, [deleteStoredSettings])
// Render children only when settings are ready
if (!settings) {
return null
}
return (
<SettingsContext.Provider
value={{ settings, updateSettings, resetSettings }}
>
{children}
</SettingsContext.Provider>
)
}
@@ -0,0 +1,97 @@
import type { BatchSummary, ClubStats, Distribution } from "@/types/api"
export const mockClubStats: ClubStats = {
totalMembers: 52,
activeMembers: 45,
distributionsToday: 8,
gramsDistributedToday: 142,
totalStockGrams: 2350,
monthlyQuotaUsagePercent: 63,
}
export const mockRecentDistributions: Distribution[] = [
{
id: "d-001",
memberId: "m-012",
memberName: "Max Müller",
strainName: "Amnesia Haze",
amountGrams: 25,
recordedBy: "Anna Schmidt",
recordedAt: "2026-06-12T14:30:00Z",
},
{
id: "d-002",
memberId: "m-007",
memberName: "Lisa Weber",
strainName: "White Widow",
amountGrams: 15,
recordedBy: "Anna Schmidt",
recordedAt: "2026-06-12T13:15:00Z",
},
{
id: "d-003",
memberId: "m-023",
memberName: "Jonas Fischer",
strainName: "Northern Lights",
amountGrams: 20,
recordedBy: "Tom Bauer",
recordedAt: "2026-06-12T11:45:00Z",
},
{
id: "d-004",
memberId: "m-031",
memberName: "Sarah Braun",
strainName: "Jack Herer",
amountGrams: 30,
recordedBy: "Anna Schmidt",
recordedAt: "2026-06-12T10:20:00Z",
},
{
id: "d-005",
memberId: "m-045",
memberName: "Kai Hoffmann",
strainName: "Blue Dream",
amountGrams: 22,
recordedBy: "Tom Bauer",
recordedAt: "2026-06-12T09:00:00Z",
},
]
export const mockStockByStrain: BatchSummary[] = [
{
id: "b-001",
strainName: "Amnesia Haze",
availableGrams: 520,
status: "AVAILABLE",
},
{
id: "b-002",
strainName: "White Widow",
availableGrams: 430,
status: "AVAILABLE",
},
{
id: "b-003",
strainName: "Northern Lights",
availableGrams: 380,
status: "AVAILABLE",
},
{
id: "b-004",
strainName: "Jack Herer",
availableGrams: 450,
status: "AVAILABLE",
},
{
id: "b-005",
strainName: "Blue Dream",
availableGrams: 340,
status: "AVAILABLE",
},
{
id: "b-006",
strainName: "Girl Scout Cookies",
availableGrams: 230,
status: "AVAILABLE",
},
]
@@ -0,0 +1,530 @@
import type {
AvailableBatch,
DistributionRecord,
QuotaStatus,
} from "@/types/api"
// Generate dates spread across last 30 days
function daysAgo(days: number, hours: number, minutes: number): string {
const d = new Date()
d.setDate(d.getDate() - days)
d.setHours(hours, minutes, 0, 0)
return d.toISOString()
}
export const mockDistributions: DistributionRecord[] = [
// Today (8 records)
{
id: "dist-001",
memberId: "m-001",
memberName: "Thomas Müller",
batchId: "b-001",
strainName: "Amnesia Haze",
amountGrams: 5,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(0, 16, 10),
status: "COMPLETED",
},
{
id: "dist-002",
memberId: "m-002",
memberName: "Anna Schmidt",
batchId: "b-003",
strainName: "Northern Lights",
amountGrams: 3,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(0, 15, 30),
status: "COMPLETED",
},
{
id: "dist-003",
memberId: "m-003",
memberName: "Markus Weber",
batchId: "b-002",
strainName: "White Widow",
amountGrams: 7,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(0, 14, 45),
status: "COMPLETED",
},
{
id: "dist-004",
memberId: "m-006",
memberName: "Lisa Hoffmann",
batchId: "b-004",
strainName: "Jack Herer",
amountGrams: 10,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(0, 13, 20),
status: "COMPLETED",
},
{
id: "dist-005",
memberId: "m-008",
memberName: "Sabine Koch",
batchId: "b-005",
strainName: "Blue Dream",
amountGrams: 4,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(0, 12, 0),
status: "COMPLETED",
},
{
id: "dist-006",
memberId: "m-009",
memberName: "Christian Braun",
batchId: "b-001",
strainName: "Amnesia Haze",
amountGrams: 8,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(0, 11, 15),
status: "COMPLETED",
},
{
id: "dist-007",
memberId: "m-010",
memberName: "Petra Richter",
batchId: "b-006",
strainName: "Girl Scout Cookies",
amountGrams: 6,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(0, 10, 30),
status: "COMPLETED",
},
{
id: "dist-008",
memberId: "m-004",
memberName: "Julia Fischer",
batchId: "b-002",
strainName: "White Widow",
amountGrams: 2.5,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(0, 9, 45),
status: "COMPLETED",
},
// Yesterday (6 records)
{
id: "dist-009",
memberId: "m-011",
memberName: "Michael Wagner",
batchId: "b-003",
strainName: "Northern Lights",
amountGrams: 12,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(1, 16, 0),
status: "COMPLETED",
},
{
id: "dist-010",
memberId: "m-012",
memberName: "Sandra Klein",
batchId: "b-001",
strainName: "Amnesia Haze",
amountGrams: 5,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(1, 14, 30),
status: "COMPLETED",
},
{
id: "dist-011",
memberId: "m-001",
memberName: "Thomas Müller",
batchId: "b-004",
strainName: "Jack Herer",
amountGrams: 8,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(1, 13, 0),
status: "COMPLETED",
},
{
id: "dist-012",
memberId: "m-003",
memberName: "Markus Weber",
batchId: "b-005",
strainName: "Blue Dream",
amountGrams: 3.5,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(1, 11, 45),
status: "COMPLETED",
},
{
id: "dist-013",
memberId: "m-006",
memberName: "Lisa Hoffmann",
batchId: "b-002",
strainName: "White Widow",
amountGrams: 6,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(1, 10, 20),
status: "COMPLETED",
},
{
id: "dist-014",
memberId: "m-007",
memberName: "Daniel Schäfer",
batchId: "b-006",
strainName: "Girl Scout Cookies",
amountGrams: 4,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(1, 9, 15),
status: "COMPLETED",
},
// 2 days ago (5 records)
{
id: "dist-015",
memberId: "m-002",
memberName: "Anna Schmidt",
batchId: "b-001",
strainName: "Amnesia Haze",
amountGrams: 10,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(2, 15, 30),
status: "COMPLETED",
},
{
id: "dist-016",
memberId: "m-008",
memberName: "Sabine Koch",
batchId: "b-003",
strainName: "Northern Lights",
amountGrams: 7,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(2, 14, 0),
status: "COMPLETED",
},
{
id: "dist-017",
memberId: "m-009",
memberName: "Christian Braun",
batchId: "b-004",
strainName: "Jack Herer",
amountGrams: 15,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(2, 12, 30),
status: "COMPLETED",
},
{
id: "dist-018",
memberId: "m-010",
memberName: "Petra Richter",
batchId: "b-005",
strainName: "Blue Dream",
amountGrams: 5,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(2, 11, 0),
status: "COMPLETED",
},
{
id: "dist-019",
memberId: "m-004",
memberName: "Julia Fischer",
batchId: "b-002",
strainName: "White Widow",
amountGrams: 3,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(2, 9, 30),
status: "COMPLETED",
},
// 3-5 days ago
{
id: "dist-020",
memberId: "m-011",
memberName: "Michael Wagner",
batchId: "b-001",
strainName: "Amnesia Haze",
amountGrams: 8,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(3, 15, 0),
status: "COMPLETED",
},
{
id: "dist-021",
memberId: "m-012",
memberName: "Sandra Klein",
batchId: "b-006",
strainName: "Girl Scout Cookies",
amountGrams: 6,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(3, 13, 30),
status: "COMPLETED",
},
{
id: "dist-022",
memberId: "m-001",
memberName: "Thomas Müller",
batchId: "b-003",
strainName: "Northern Lights",
amountGrams: 10,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(4, 16, 0),
status: "COMPLETED",
},
{
id: "dist-023",
memberId: "m-002",
memberName: "Anna Schmidt",
batchId: "b-004",
strainName: "Jack Herer",
amountGrams: 4,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(4, 14, 15),
status: "COMPLETED",
},
{
id: "dist-024",
memberId: "m-006",
memberName: "Lisa Hoffmann",
batchId: "b-001",
strainName: "Amnesia Haze",
amountGrams: 9,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(5, 15, 30),
status: "COMPLETED",
},
{
id: "dist-025",
memberId: "m-008",
memberName: "Sabine Koch",
batchId: "b-005",
strainName: "Blue Dream",
amountGrams: 11,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(5, 12, 0),
status: "COMPLETED",
},
// 6-14 days ago
{
id: "dist-026",
memberId: "m-009",
memberName: "Christian Braun",
batchId: "b-002",
strainName: "White Widow",
amountGrams: 7,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(7, 14, 0),
status: "COMPLETED",
},
{
id: "dist-027",
memberId: "m-003",
memberName: "Markus Weber",
batchId: "b-006",
strainName: "Girl Scout Cookies",
amountGrams: 5,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(8, 11, 30),
status: "COMPLETED",
},
{
id: "dist-028",
memberId: "m-010",
memberName: "Petra Richter",
batchId: "b-003",
strainName: "Northern Lights",
amountGrams: 13,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(10, 15, 45),
status: "COMPLETED",
},
{
id: "dist-029",
memberId: "m-004",
memberName: "Julia Fischer",
batchId: "b-001",
strainName: "Amnesia Haze",
amountGrams: 6,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(12, 13, 20),
status: "COMPLETED",
},
{
id: "dist-030",
memberId: "m-007",
memberName: "Daniel Schäfer",
batchId: "b-004",
strainName: "Jack Herer",
amountGrams: 3,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(14, 10, 0),
status: "COMPLETED",
},
// 15-30 days ago
{
id: "dist-031",
memberId: "m-011",
memberName: "Michael Wagner",
batchId: "b-005",
strainName: "Blue Dream",
amountGrams: 8,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(16, 14, 30),
status: "COMPLETED",
},
{
id: "dist-032",
memberId: "m-012",
memberName: "Sandra Klein",
batchId: "b-002",
strainName: "White Widow",
amountGrams: 10,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(20, 11, 0),
status: "COMPLETED",
},
{
id: "dist-033",
memberId: "m-001",
memberName: "Thomas Müller",
batchId: "b-006",
strainName: "Girl Scout Cookies",
amountGrams: 4,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(22, 16, 0),
status: "COMPLETED",
},
{
id: "dist-034",
memberId: "m-006",
memberName: "Lisa Hoffmann",
batchId: "b-003",
strainName: "Northern Lights",
amountGrams: 12,
recordedBy: "Thomas Klein",
recordedAt: daysAgo(25, 13, 0),
status: "COMPLETED",
},
{
id: "dist-035",
memberId: "m-009",
memberName: "Christian Braun",
batchId: "b-001",
strainName: "Amnesia Haze",
amountGrams: 7,
recordedBy: "Maria Schulz",
recordedAt: daysAgo(28, 15, 0),
status: "COMPLETED",
},
]
// Quota mock for different members
export function getMockQuota(memberId: string): QuotaStatus {
const quotas: Record<string, QuotaStatus> = {
"m-001": {
dailyUsedGrams: 5,
dailyLimitGrams: 25,
monthlyUsedGrams: 23,
monthlyLimitGrams: 50,
isUnder21: false,
},
"m-002": {
dailyUsedGrams: 3,
dailyLimitGrams: 25,
monthlyUsedGrams: 36,
monthlyLimitGrams: 50,
isUnder21: false,
},
"m-003": {
dailyUsedGrams: 7,
dailyLimitGrams: 25,
monthlyUsedGrams: 15,
monthlyLimitGrams: 50,
isUnder21: false,
},
"m-004": {
dailyUsedGrams: 2.5,
dailyLimitGrams: 25,
monthlyUsedGrams: 44,
monthlyLimitGrams: 50,
isUnder21: false,
},
"m-006": {
dailyUsedGrams: 10,
dailyLimitGrams: 25,
monthlyUsedGrams: 27.5,
monthlyLimitGrams: 50,
isUnder21: false,
},
"m-007": {
dailyUsedGrams: 4,
dailyLimitGrams: 25,
monthlyUsedGrams: 7,
monthlyLimitGrams: 30,
isUnder21: true,
},
"m-008": {
dailyUsedGrams: 4,
dailyLimitGrams: 25,
monthlyUsedGrams: 20,
monthlyLimitGrams: 50,
isUnder21: false,
},
"m-009": {
dailyUsedGrams: 8,
dailyLimitGrams: 25,
monthlyUsedGrams: 30,
monthlyLimitGrams: 50,
isUnder21: false,
},
"m-010": {
dailyUsedGrams: 6,
dailyLimitGrams: 25,
monthlyUsedGrams: 18,
monthlyLimitGrams: 50,
isUnder21: false,
},
}
return (
quotas[memberId] ?? {
dailyUsedGrams: 0,
dailyLimitGrams: 25,
monthlyUsedGrams: 0,
monthlyLimitGrams: 50,
isUnder21: false,
}
)
}
export const mockAvailableBatches: AvailableBatch[] = [
{
id: "b-001",
strainName: "Amnesia Haze",
availableGrams: 520,
thcPercent: 22.5,
status: "AVAILABLE",
},
{
id: "b-002",
strainName: "White Widow",
availableGrams: 430,
thcPercent: 18.0,
status: "AVAILABLE",
},
{
id: "b-003",
strainName: "Northern Lights",
availableGrams: 380,
thcPercent: 16.5,
status: "AVAILABLE",
},
{
id: "b-004",
strainName: "Jack Herer",
availableGrams: 450,
thcPercent: 20.0,
status: "AVAILABLE",
},
{
id: "b-005",
strainName: "Blue Dream",
availableGrams: 340,
thcPercent: 21.0,
status: "AVAILABLE",
},
{
id: "b-006",
strainName: "Girl Scout Cookies",
availableGrams: 230,
thcPercent: 24.0,
status: "AVAILABLE",
},
]
@@ -0,0 +1,297 @@
import type { Member } from "@/types/api"
export const mockMembers: Member[] = [
{
id: "m-001",
firstName: "Thomas",
lastName: "Müller",
email: "thomas.mueller@email.de",
dateOfBirth: "1985-03-15",
phone: "+49 170 1234567",
memberNumber: "CM-2024-001",
status: "ACTIVE",
joinedAt: "2024-07-01",
monthlyQuotaUsedPercent: 45,
notes: "Gründungsmitglied",
},
{
id: "m-002",
firstName: "Anna",
lastName: "Schmidt",
email: "anna.schmidt@email.de",
dateOfBirth: "1990-08-22",
phone: "+49 171 2345678",
memberNumber: "CM-2024-002",
status: "ACTIVE",
joinedAt: "2024-07-01",
monthlyQuotaUsedPercent: 72,
},
{
id: "m-003",
firstName: "Markus",
lastName: "Weber",
email: "markus.weber@email.de",
dateOfBirth: "1978-11-03",
phone: "+49 172 3456789",
memberNumber: "CM-2024-003",
status: "ACTIVE",
joinedAt: "2024-07-15",
monthlyQuotaUsedPercent: 30,
},
{
id: "m-004",
firstName: "Julia",
lastName: "Fischer",
email: "julia.fischer@email.de",
dateOfBirth: "1995-05-10",
memberNumber: "CM-2024-004",
status: "ACTIVE",
joinedAt: "2024-08-01",
monthlyQuotaUsedPercent: 88,
notes: "Bevorzugt CBD-reiche Sorten",
},
{
id: "m-005",
firstName: "Stefan",
lastName: "Becker",
email: "stefan.becker@email.de",
dateOfBirth: "1982-01-28",
phone: "+49 173 4567890",
memberNumber: "CM-2024-005",
status: "SUSPENDED",
joinedAt: "2024-08-15",
monthlyQuotaUsedPercent: 0,
notes: "Beitrag seit 2 Monaten ausstehend",
},
{
id: "m-006",
firstName: "Lisa",
lastName: "Hoffmann",
email: "lisa.hoffmann@email.de",
dateOfBirth: "1988-09-17",
phone: "+49 174 5678901",
memberNumber: "CM-2024-006",
status: "ACTIVE",
joinedAt: "2024-09-01",
monthlyQuotaUsedPercent: 55,
},
{
id: "m-007",
firstName: "Daniel",
lastName: "Schäfer",
email: "daniel.schaefer@email.de",
dateOfBirth: "2005-04-12",
phone: "+49 175 6789012",
memberNumber: "CM-2024-007",
status: "ACTIVE",
joinedAt: "2024-09-15",
monthlyQuotaUsedPercent: 20,
notes: "Unter 21 — reduziertes Kontingent",
},
{
id: "m-008",
firstName: "Sabine",
lastName: "Koch",
email: "sabine.koch@email.de",
dateOfBirth: "1975-12-05",
memberNumber: "CM-2024-008",
status: "ACTIVE",
joinedAt: "2024-10-01",
monthlyQuotaUsedPercent: 40,
},
{
id: "m-009",
firstName: "Andreas",
lastName: "Richter",
email: "andreas.richter@email.de",
dateOfBirth: "1992-06-30",
phone: "+49 176 7890123",
memberNumber: "CM-2024-009",
status: "EXPELLED",
joinedAt: "2024-10-01",
monthlyQuotaUsedPercent: 0,
notes: "Verstoß gegen Vereinssatzung — Weitergabe an Dritte",
},
{
id: "m-010",
firstName: "Katharina",
lastName: "Wolf",
email: "katharina.wolf@email.de",
dateOfBirth: "1998-02-14",
phone: "+49 177 8901234",
memberNumber: "CM-2024-010",
status: "ACTIVE",
joinedAt: "2024-10-15",
monthlyQuotaUsedPercent: 62,
},
{
id: "m-011",
firstName: "Patrick",
lastName: "Neumann",
email: "patrick.neumann@email.de",
dateOfBirth: "1987-07-21",
memberNumber: "CM-2024-011",
status: "ACTIVE",
joinedAt: "2024-11-01",
monthlyQuotaUsedPercent: 33,
},
{
id: "m-012",
firstName: "Claudia",
lastName: "Schwarz",
email: "claudia.schwarz@email.de",
dateOfBirth: "1993-10-08",
phone: "+49 178 9012345",
memberNumber: "CM-2025-012",
status: "ACTIVE",
joinedAt: "2025-01-01",
monthlyQuotaUsedPercent: 78,
},
{
id: "m-013",
firstName: "Florian",
lastName: "Braun",
email: "florian.braun@email.de",
dateOfBirth: "2006-03-25",
phone: "+49 179 0123456",
memberNumber: "CM-2025-013",
status: "ACTIVE",
joinedAt: "2025-01-15",
monthlyQuotaUsedPercent: 15,
notes: "Unter 21 — reduziertes Kontingent",
},
{
id: "m-014",
firstName: "Monika",
lastName: "Zimmermann",
email: "monika.zimmermann@email.de",
dateOfBirth: "1970-04-02",
memberNumber: "CM-2025-014",
status: "SUSPENDED",
joinedAt: "2025-02-01",
monthlyQuotaUsedPercent: 0,
notes: "Ruhendes Mitglied auf eigenen Wunsch",
},
{
id: "m-015",
firstName: "Jens",
lastName: "Krüger",
email: "jens.krueger@email.de",
dateOfBirth: "1983-08-19",
phone: "+49 160 1234567",
memberNumber: "CM-2025-015",
status: "ACTIVE",
joinedAt: "2025-02-15",
monthlyQuotaUsedPercent: 50,
},
{
id: "m-016",
firstName: "Melanie",
lastName: "Hartmann",
email: "melanie.hartmann@email.de",
dateOfBirth: "1996-11-11",
phone: "+49 161 2345678",
memberNumber: "CM-2025-016",
status: "ACTIVE",
joinedAt: "2025-03-01",
monthlyQuotaUsedPercent: 25,
},
{
id: "m-017",
firstName: "Christian",
lastName: "Lange",
email: "christian.lange@email.de",
dateOfBirth: "1989-05-07",
memberNumber: "CM-2025-017",
status: "ACTIVE",
joinedAt: "2025-03-15",
monthlyQuotaUsedPercent: 95,
notes: "Kontingent fast ausgeschöpft",
},
{
id: "m-018",
firstName: "Nina",
lastName: "Werner",
email: "nina.werner@email.de",
dateOfBirth: "2005-09-30",
phone: "+49 162 3456789",
memberNumber: "CM-2025-018",
status: "ACTIVE",
joinedAt: "2025-04-01",
monthlyQuotaUsedPercent: 10,
notes: "Unter 21 — reduziertes Kontingent",
},
{
id: "m-019",
firstName: "Oliver",
lastName: "Schmitt",
email: "oliver.schmitt@email.de",
dateOfBirth: "1980-12-24",
phone: "+49 163 4567890",
memberNumber: "CM-2025-019",
status: "EXPELLED",
joinedAt: "2025-04-15",
monthlyQuotaUsedPercent: 0,
notes: "Wiederholte Verstöße gegen Jugendschutzauflagen",
},
{
id: "m-020",
firstName: "Sandra",
lastName: "Meyer",
email: "sandra.meyer@email.de",
dateOfBirth: "1991-07-03",
memberNumber: "CM-2025-020",
status: "ACTIVE",
joinedAt: "2025-05-01",
monthlyQuotaUsedPercent: 38,
},
{
id: "m-021",
firstName: "Michael",
lastName: "Schulz",
email: "michael.schulz@email.de",
dateOfBirth: "1976-02-18",
phone: "+49 164 5678901",
memberNumber: "CM-2025-021",
status: "ACTIVE",
joinedAt: "2025-05-15",
monthlyQuotaUsedPercent: 60,
},
{
id: "m-022",
firstName: "Lena",
lastName: "Huber",
email: "lena.huber@email.de",
dateOfBirth: "2007-01-09",
phone: "+49 165 6789012",
memberNumber: "CM-2025-022",
status: "ACTIVE",
joinedAt: "2025-06-01",
monthlyQuotaUsedPercent: 5,
notes: "Unter 21 — reduziertes Kontingent",
},
{
id: "m-023",
firstName: "Ralf",
lastName: "Keller",
email: "ralf.keller@email.de",
dateOfBirth: "1968-06-15",
memberNumber: "CM-2025-023",
status: "SUSPENDED",
joinedAt: "2025-06-01",
monthlyQuotaUsedPercent: 0,
notes: "Ärztliches Attest ausstehend",
},
{
id: "m-024",
firstName: "Petra",
lastName: "Frank",
email: "petra.frank@email.de",
dateOfBirth: "1994-04-20",
phone: "+49 166 7890123",
memberNumber: "CM-2026-024",
status: "ACTIVE",
joinedAt: "2026-01-15",
monthlyQuotaUsedPercent: 42,
},
]
@@ -0,0 +1,115 @@
export const mockPortalUser = {
id: "member-001",
firstName: "Max",
lastName: "Mustermann",
email: "max@example.de",
memberNumber: "GD-2024-0042",
joinedAt: "2024-08-15",
dateOfBirth: "1995-03-22",
isUnder21: false,
clubName: "Grüner Daumen e.V.",
}
export const mockPortalQuota = {
dailyUsedGrams: 5.0,
dailyLimitGrams: 25,
monthlyUsedGrams: 32.5,
monthlyLimitGrams: 50,
isUnder21: false,
lastDistributionAt: "2026-06-12T14:30:00Z",
}
export interface PortalDistribution {
id: string
date: string
strain: string
amountGrams: number
recordedBy: string
}
export const mockPortalHistory: PortalDistribution[] = [
{
id: "dist-001",
date: "2026-06-12T14:30:00Z",
strain: "Blue Dream",
amountGrams: 5.0,
recordedBy: "Anna M.",
},
{
id: "dist-002",
date: "2026-06-10T10:15:00Z",
strain: "Northern Lights",
amountGrams: 3.5,
recordedBy: "Thomas K.",
},
{
id: "dist-003",
date: "2026-06-07T16:45:00Z",
strain: "Blue Dream",
amountGrams: 5.0,
recordedBy: "Anna M.",
},
{
id: "dist-004",
date: "2026-06-04T11:00:00Z",
strain: "Amnesia Haze",
amountGrams: 4.0,
recordedBy: "Thomas K.",
},
{
id: "dist-005",
date: "2026-06-01T09:30:00Z",
strain: "White Widow",
amountGrams: 5.0,
recordedBy: "Anna M.",
},
{
id: "dist-006",
date: "2026-05-28T15:20:00Z",
strain: "OG Kush",
amountGrams: 5.0,
recordedBy: "Thomas K.",
},
{
id: "dist-007",
date: "2026-05-25T12:00:00Z",
strain: "Blue Dream",
amountGrams: 3.0,
recordedBy: "Anna M.",
},
{
id: "dist-008",
date: "2026-05-21T10:45:00Z",
strain: "Northern Lights",
amountGrams: 5.0,
recordedBy: "Thomas K.",
},
{
id: "dist-009",
date: "2026-05-17T14:30:00Z",
strain: "Amnesia Haze",
amountGrams: 4.5,
recordedBy: "Anna M.",
},
{
id: "dist-010",
date: "2026-05-13T11:15:00Z",
strain: "White Widow",
amountGrams: 5.0,
recordedBy: "Thomas K.",
},
{
id: "dist-011",
date: "2026-05-09T09:00:00Z",
strain: "Blue Dream",
amountGrams: 5.0,
recordedBy: "Anna M.",
},
{
id: "dist-012",
date: "2026-05-05T16:30:00Z",
strain: "OG Kush",
amountGrams: 3.5,
recordedBy: "Thomas K.",
},
]
@@ -0,0 +1,132 @@
export const mockMonthlyReportPreview = {
month: "Juni 2026",
club: "Grüner Daumen e.V.",
totalDistributions: 142,
totalGrams: 2840,
uniqueMembers: 38,
averagePerMember: 74.7,
topStrains: [
{ name: "Amnesia Haze", grams: 680, percent: 23.9 },
{ name: "White Widow", grams: 520, percent: 18.3 },
{ name: "Northern Lights", grams: 440, percent: 15.5 },
{ name: "Jack Herer", grams: 380, percent: 13.4 },
{ name: "Blue Dream", grams: 360, percent: 12.7 },
],
dailyBreakdown: [
{ date: "01.06.", distributions: 5, grams: 98 },
{ date: "02.06.", distributions: 7, grams: 135 },
{ date: "03.06.", distributions: 4, grams: 82 },
{ date: "04.06.", distributions: 6, grams: 118 },
{ date: "05.06.", distributions: 8, grams: 156 },
{ date: "06.06.", distributions: 5, grams: 95 },
{ date: "07.06.", distributions: 3, grams: 62 },
{ date: "08.06.", distributions: 6, grams: 120 },
{ date: "09.06.", distributions: 7, grams: 142 },
{ date: "10.06.", distributions: 5, grams: 105 },
{ date: "11.06.", distributions: 4, grams: 88 },
{ date: "12.06.", distributions: 6, grams: 115 },
],
}
export const mockMemberListPreview = {
generatedAt: "2026-06-12",
totalMembers: 45,
active: 40,
suspended: 3,
expelled: 2,
members: [
{
memberNumber: "M-001",
name: "Max Müller",
status: "ACTIVE" as const,
joinedAt: "2025-01-15",
monthlyUsage: 42.5,
monthlyLimit: 50,
},
{
memberNumber: "M-002",
name: "Anna Schmidt",
status: "ACTIVE" as const,
joinedAt: "2025-02-20",
monthlyUsage: 28.0,
monthlyLimit: 50,
},
{
memberNumber: "M-003",
name: "Thomas Weber",
status: "ACTIVE" as const,
joinedAt: "2025-01-30",
monthlyUsage: 50.0,
monthlyLimit: 50,
},
{
memberNumber: "M-004",
name: "Lisa Wagner",
status: "SUSPENDED" as const,
joinedAt: "2025-03-10",
monthlyUsage: 0,
monthlyLimit: 50,
},
{
memberNumber: "M-005",
name: "Felix Braun",
status: "ACTIVE" as const,
joinedAt: "2025-04-05",
monthlyUsage: 18.5,
monthlyLimit: 30,
},
{
memberNumber: "M-006",
name: "Sophie Klein",
status: "ACTIVE" as const,
joinedAt: "2025-05-12",
monthlyUsage: 35.0,
monthlyLimit: 50,
},
{
memberNumber: "M-007",
name: "Jan Hoffmann",
status: "EXPELLED" as const,
joinedAt: "2025-01-22",
monthlyUsage: 0,
monthlyLimit: 50,
},
{
memberNumber: "M-008",
name: "Marie Fischer",
status: "ACTIVE" as const,
joinedAt: "2025-06-01",
monthlyUsage: 22.0,
monthlyLimit: 50,
},
],
}
export const mockRecallReportPreview = {
dateRange: { from: "2026-05-01", to: "2026-06-12" },
recalledBatches: 2,
affectedDistributions: 12,
affectedMembers: 8,
batches: [
{
batchId: "B-2026-042",
strain: "Girl Scout Cookies",
recalledAt: "2026-05-18",
reason: "THC-Gehalt über Grenzwert (>10% Abweichung)",
originalGrams: 500,
distributedGrams: 180,
affectedMembers: 5,
affectedDistributions: 7,
},
{
batchId: "B-2026-051",
strain: "OG Kush",
recalledAt: "2026-06-02",
reason: "Schimmelbefall festgestellt bei Qualitätskontrolle",
originalGrams: 300,
distributedGrams: 95,
affectedMembers: 3,
affectedDistributions: 5,
},
],
}
+199
View File
@@ -0,0 +1,199 @@
import type { Batch, Strain } from "@/types/api"
function daysAgo(days: number): string {
const d = new Date()
d.setDate(d.getDate() - days)
d.setHours(10, 0, 0, 0)
return d.toISOString()
}
export const mockStrains: Strain[] = [
{
id: "s-001",
name: "Amnesia Haze",
defaultThcPercent: 22,
defaultCbdPercent: 1,
},
{
id: "s-002",
name: "White Widow",
defaultThcPercent: 19,
defaultCbdPercent: 2,
},
{
id: "s-003",
name: "Northern Lights",
defaultThcPercent: 18,
defaultCbdPercent: 3,
},
{
id: "s-004",
name: "Jack Herer",
defaultThcPercent: 21,
defaultCbdPercent: 1.5,
},
{
id: "s-005",
name: "Blue Dream",
defaultThcPercent: 20,
defaultCbdPercent: 2,
},
{
id: "s-006",
name: "Girl Scout Cookies",
defaultThcPercent: 25,
defaultCbdPercent: 1,
},
]
export const mockBatches: Batch[] = [
{
id: "b-001",
strainName: "Amnesia Haze",
thcPercent: 22.5,
cbdPercent: 0.8,
totalGrams: 500,
availableGrams: 342,
status: "AVAILABLE",
supplier: "GreenGrow GmbH",
harvestDate: "2026-04-15",
receivedAt: daysAgo(45),
notes: "Premium-Qualität, Labor geprüft",
},
{
id: "b-002",
strainName: "White Widow",
thcPercent: 19.2,
cbdPercent: 2.1,
totalGrams: 300,
availableGrams: 187,
status: "AVAILABLE",
supplier: "CannaCraft Berlin",
harvestDate: "2026-04-20",
receivedAt: daysAgo(40),
},
{
id: "b-003",
strainName: "Northern Lights",
thcPercent: 17.8,
cbdPercent: 3.2,
totalGrams: 400,
availableGrams: 78,
status: "AVAILABLE",
supplier: "GreenGrow GmbH",
harvestDate: "2026-05-01",
receivedAt: daysAgo(30),
notes: "Hoher CBD-Gehalt, beliebt bei Schmerzpatienten",
},
{
id: "b-004",
strainName: "Jack Herer",
thcPercent: 21.0,
cbdPercent: 1.5,
totalGrams: 600,
availableGrams: 455,
status: "AVAILABLE",
supplier: "HanfHaus Sachsen",
harvestDate: "2026-05-05",
receivedAt: daysAgo(25),
},
{
id: "b-005",
strainName: "Blue Dream",
thcPercent: 20.3,
cbdPercent: 1.8,
totalGrams: 350,
availableGrams: 220,
status: "AVAILABLE",
supplier: "CannaCraft Berlin",
harvestDate: "2026-05-10",
receivedAt: daysAgo(20),
},
{
id: "b-006",
strainName: "Girl Scout Cookies",
thcPercent: 25.1,
cbdPercent: 0.9,
totalGrams: 250,
availableGrams: 180,
status: "AVAILABLE",
supplier: "MedCanna Bayern",
harvestDate: "2026-05-12",
receivedAt: daysAgo(18),
notes: "Sehr hoher THC-Gehalt — nur erfahrene Nutzer",
},
{
id: "b-007",
strainName: "Amnesia Haze",
thcPercent: 23.0,
cbdPercent: 0.7,
totalGrams: 450,
availableGrams: 390,
status: "AVAILABLE",
supplier: "GreenGrow GmbH",
harvestDate: "2026-05-18",
receivedAt: daysAgo(12),
},
{
id: "b-008",
strainName: "White Widow",
thcPercent: 18.5,
cbdPercent: 2.3,
totalGrams: 200,
availableGrams: 52,
status: "AVAILABLE",
supplier: "HanfHaus Sachsen",
harvestDate: "2026-05-22",
receivedAt: daysAgo(8),
},
{
id: "b-009",
strainName: "Northern Lights",
thcPercent: 18.0,
cbdPercent: 3.0,
totalGrams: 500,
availableGrams: 0,
status: "DEPLETED",
supplier: "GreenGrow GmbH",
harvestDate: "2026-03-01",
receivedAt: daysAgo(75),
},
{
id: "b-010",
strainName: "Jack Herer",
thcPercent: 20.5,
cbdPercent: 1.4,
totalGrams: 350,
availableGrams: 0,
status: "DEPLETED",
supplier: "CannaCraft Berlin",
harvestDate: "2026-03-10",
receivedAt: daysAgo(70),
},
{
id: "b-011",
strainName: "Blue Dream",
thcPercent: 19.8,
cbdPercent: 2.0,
totalGrams: 300,
availableGrams: 245,
status: "RECALLED",
supplier: "MedCanna Bayern",
harvestDate: "2026-04-01",
receivedAt: daysAgo(55),
notes: "Rückruf: Schimmelbefall bei Laborkontrolle festgestellt",
},
{
id: "b-012",
strainName: "Girl Scout Cookies",
thcPercent: 24.8,
cbdPercent: 1.0,
totalGrams: 200,
availableGrams: 130,
status: "RECALLED",
supplier: "HanfHaus Sachsen",
harvestDate: "2026-04-08",
receivedAt: daysAgo(50),
notes: "Rückruf: Pestizid-Rückstände über Grenzwert",
},
]
@@ -0,0 +1,39 @@
import type { NavigationType } from "@/types"
export const navigationsData: NavigationType[] = [
{
title: "Main",
items: [
{
title: "Dashboard",
href: "/dashboard",
iconName: "LayoutDashboard",
},
{
title: "Mitglieder",
href: "/members",
iconName: "Users",
},
{
title: "Ausgabe",
href: "/distributions",
iconName: "Leaf",
},
{
title: "Lager",
href: "/stock",
iconName: "Package",
},
{
title: "Berichte",
href: "/reports",
iconName: "FileText",
},
{
title: "Einstellungen",
href: "/settings",
iconName: "Settings",
},
],
},
]
+29
View File
@@ -0,0 +1,29 @@
import type { UserType } from "@/types"
export const userData: UserType = {
id: "1",
firstName: "John",
lastName: "Doe",
name: "John Doe",
password: "StrongPass123",
username: "john.doe",
role: "Next.js Developer",
avatar: "/images/avatars/male-01.svg",
background: "",
status: "ONLINE",
phoneNumber: "+15558675309",
email: "john.doe@example.com",
state: "California",
country: "United States",
address: "123 Main Street, Apt 4B",
zipCode: "90210",
language: "English",
timeZone: "GMT+08:00",
currency: "USD",
organization: "Tech Innovations Inc.",
twoFactorAuth: false,
loginAlerts: true,
accountReoveryOption: "email",
connections: 1212,
followers: 3300,
}
@@ -0,0 +1,21 @@
"use client"
import { useSession } from "next-auth/react"
export function useAuth() {
const { data: session, status } = useSession()
return {
session,
user: session?.user,
role: session?.user?.role,
isAdmin: session?.user?.role === "ADMIN",
isStaff: session?.user?.role === "STAFF",
isMember: session?.user?.role === "MEMBER",
isPreventionOfficer: session?.user?.role === "PREVENTION_OFFICER",
isAuthenticated: status === "authenticated",
isLoading: status === "loading",
hasError: !!session?.error,
error: session?.error,
}
}
@@ -0,0 +1,10 @@
"use client"
import { useDirection } from "@radix-ui/react-direction"
export function useIsRtl() {
const direction = useDirection()
const isRtl = direction === "rtl"
return isRtl
}
@@ -0,0 +1,10 @@
"use client"
import { useSettings } from "@/hooks/use-settings"
export function useIsVertical() {
const { settings } = useSettings()
const isVertical = settings.layout === "vertical"
return isVertical
}
@@ -0,0 +1,11 @@
"use client"
import { useMedia } from "react-use"
const MOBILE_BREAKPOINT = 1024
export function useIsMobile() {
const isMobile = useMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
return isMobile
}
@@ -0,0 +1,18 @@
"use client"
import { useMedia } from "react-use"
import { useSettings } from "@/hooks/use-settings"
export function useIsDarkMode() {
const { settings } = useSettings()
const isDarkModePreferred = useMedia("(prefers-color-scheme: dark)")
let resolvedMode = settings.mode
if (resolvedMode === "system") {
resolvedMode = isDarkModePreferred ? "dark" : "light"
}
return resolvedMode === "dark"
}
@@ -0,0 +1,16 @@
"use client"
import { remToPx } from "@/lib/utils"
import { useSettings } from "@/hooks/use-settings"
export function useRadius(asPx = true) {
const { settings } = useSettings()
let radius = Number(settings.radius)
if (asPx) {
radius = remToPx(radius)
}
return radius
}
@@ -0,0 +1,13 @@
"use client"
import { useContext } from "react"
import { SettingsContext } from "@/contexts/settings-context"
export function useSettings() {
const context = useContext(SettingsContext)
if (!context) {
throw new Error("useSettings must be used within a SettingsProvider")
}
return context
}
+190
View File
@@ -0,0 +1,190 @@
"use client"
// Inspired by react-hot-toast library
import { useEffect, useState } from "react"
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
import type { ReactNode } from "react"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: ReactNode
description?: ReactNode
action?: ToastActionElement
}
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = {
ADD_TOAST: "ADD_TOAST"
UPDATE_TOAST: "UPDATE_TOAST"
DISMISS_TOAST: "DISMISS_TOAST"
REMOVE_TOAST: "REMOVE_TOAST"
}
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = useState<State>(memoryState)
useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }
+12
View File
@@ -0,0 +1,12 @@
import { getRequestConfig } from "next-intl/server"
export default getRequestConfig(async () => {
// For now, use a static locale. Later this can be dynamic based on
// user preferences or cookies.
const locale = "de"
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
}
})
+123
View File
@@ -0,0 +1,123 @@
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
/** Helper: fetch with an AbortController timeout (default 5s) */
async function fetchWithTimeout(
url: string,
options: RequestInit,
timeoutMs = 5000
): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch(url, { ...options, signal: controller.signal })
return res
} finally {
clearTimeout(timeout)
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
try {
const res = await fetchWithTimeout(
`${process.env.BACKEND_URL}/api/v1/auth/login`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: credentials.email,
password: credentials.password,
}),
}
)
if (!res.ok) return null
const data = await res.json()
return {
id: data.member.id,
email: data.member.email,
name: data.member.clubName,
role: data.member.role,
clubId: data.member.clubId,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
expiresAt: Date.now() + data.expiresIn * 1000,
}
} catch {
// Backend unreachable or timeout — fail gracefully
return null
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
// Initial sign-in: transfer user data to token
if (user) {
token.role = user.role
token.clubId = user.clubId
token.accessToken = user.accessToken
token.refreshToken = user.refreshToken
token.expiresAt = user.expiresAt
}
// Token refresh: if access token expired, use refresh token
if (token.expiresAt && Date.now() > (token.expiresAt as number)) {
try {
const res = await fetchWithTimeout(
`${process.env.BACKEND_URL}/api/v1/auth/refresh`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: token.refreshToken }),
}
)
if (res.ok) {
const refreshed = await res.json()
token.accessToken = refreshed.accessToken
token.refreshToken = refreshed.refreshToken
token.expiresAt = Date.now() + refreshed.expiresIn * 1000
} else {
token.error = "RefreshTokenExpired"
}
} catch {
token.error = "RefreshTokenError"
}
}
return token
},
async session({ session, token }) {
session.user.role = token.role as string
session.user.clubId = token.clubId as string
session.error = token.error as string | undefined
return session
},
async redirect({ url, baseUrl }) {
// Handle relative URLs
if (url.startsWith("/")) return `${baseUrl}${url}`
// Handle same-origin URLs
if (new URL(url).origin === baseUrl) return url
return baseUrl
},
},
pages: {
signIn: "/login",
error: "/login",
},
session: {
strategy: "jwt",
},
})
+62
View File
@@ -0,0 +1,62 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import type { ClassValue } from "clsx"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getInitials(fullName: string) {
if (fullName.length === 0) return ""
// Split the name by spaces
const names = fullName.split(" ")
// Extract the first letter of each name and convert it to uppercase
const initials = names.map((name) => name.charAt(0).toUpperCase()).join("")
return initials
}
export function remToPx(rem: number) {
// Get the root font size (default is 16px if not set otherwise)
const rootFontSize = parseFloat(
getComputedStyle(document.documentElement).fontSize
)
return rem * rootFontSize
}
export function isActivePathname(
basePathname: string,
currentPathname: string,
exactMatch: boolean = false
) {
if (typeof basePathname !== "string" || typeof currentPathname !== "string") {
throw new Error("Both basePathname and currentPathname must be strings")
}
// Use this when you want a strict comparison, e.g., highlighting a specific page.
if (exactMatch) {
return basePathname === currentPathname
}
// Allow deeper routes to be considered as active.
// Example: If basePathname is "/dashboard", it should match "/dashboard/stats".
return (
currentPathname.startsWith(basePathname) &&
(currentPathname.length === basePathname.length ||
currentPathname[basePathname.length] === "/")
)
}
export function formatFileSize(bytes: number, decimals: number = 2) {
if (bytes === 0) return "0 Bytes"
const k = 1000 // Use 1024 for binary
const dm = decimals < 0 ? 0 : decimals
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]
}
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
export default auth((req) => {
const { nextUrl } = req
const isAuthenticated = !!req.auth
// Public routes that don't require authentication
const publicRoutes = [
"/login",
"/register",
"/forgot-password",
"/api/auth",
"/portal-login",
]
const isPublicRoute = publicRoutes.some((route) =>
nextUrl.pathname.startsWith(route)
)
// Portal routes — allow without admin auth (mock for now)
const isPortalRoute = nextUrl.pathname.startsWith("/portal")
if (isPublicRoute || isPortalRoute) {
// If user is already authenticated and tries to access login, redirect based on role
if (isAuthenticated && nextUrl.pathname.startsWith("/login")) {
const role = req.auth?.user?.role
const redirectPath = getRedirectForRole(role)
return NextResponse.redirect(new URL(redirectPath, nextUrl))
}
return NextResponse.next()
}
// Redirect unauthenticated users to login
if (!isAuthenticated) {
const loginUrl = new URL("/login", nextUrl)
loginUrl.searchParams.set("callbackUrl", nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
})
function getRedirectForRole(role: string | undefined): string {
switch (role) {
case "ADMIN":
case "STAFF":
case "PREVENTION_OFFICER":
return "/dashboard"
case "MEMBER":
return "/portal/dashboard"
default:
return "/dashboard"
}
}
export const config = {
matcher: [
// Protect all routes EXCEPT:
// - /login, /register, /forgot-password (auth pages)
// - /portal-login (portal auth page)
// - /api/auth (NextAuth API routes)
// - /_next/static, /_next/image (Next.js internals)
// - /favicon.ico, /images (public assets)
"/((?!login|register|forgot-password|portal-login|api/auth|_next/static|_next/image|favicon.ico|images).*)",
],
}
@@ -0,0 +1,31 @@
"use client"
import { SessionProvider } from "next-auth/react"
import type { LocaleType } from "@/types"
import type { ReactNode } from "react"
import { SettingsProvider } from "@/contexts/settings-context"
import { SidebarProvider } from "@/components/ui/sidebar"
import { ModeProvider } from "./mode-provider"
import { ThemeProvider } from "./theme-provider"
export function Providers({
locale,
children,
}: Readonly<{
locale: LocaleType
children: ReactNode
}>) {
return (
<SessionProvider>
<SettingsProvider locale={locale}>
<ModeProvider>
<ThemeProvider>
<SidebarProvider>{children}</SidebarProvider>
</ThemeProvider>
</ModeProvider>
</SettingsProvider>
</SessionProvider>
)
}
@@ -0,0 +1,24 @@
"use client"
import { useEffect } from "react"
import type { ReactNode } from "react"
import { useIsDarkMode } from "@/hooks/use-mode"
const defaultModes = ["light", "dark"]
export function ModeProvider({ children }: { children: ReactNode }) {
const isDarkMode = useIsDarkMode()
const mode = isDarkMode ? "dark" : "light"
useEffect(() => {
const rootElement = document.documentElement
// Update class names in the <html> tag
rootElement.classList.remove(...defaultModes)
rootElement.classList.add(mode)
}, [mode])
return <>{children}</>
}
@@ -0,0 +1,30 @@
"use client"
import { useEffect } from "react"
import type { ReactNode } from "react"
import { useSettings } from "@/hooks/use-settings"
export function ThemeProvider({ children }: { children: ReactNode }) {
const { settings } = useSettings()
useEffect(() => {
const bodyElement = document.body
// Update class names in the <body> tag
Array.from(bodyElement.classList)
.filter(
(className) =>
className.startsWith("theme-") || className.startsWith("radius-")
)
.forEach((className) => {
bodyElement.classList.remove(className)
})
bodyElement.classList.add(`theme-${settings.theme}`)
bodyElement.classList.add(`radius-${settings.radius ?? 0.5}`)
}, [settings.theme, settings.radius])
return <>{children}</>
}
+115
View File
@@ -0,0 +1,115 @@
import type { LucideIcon, icons } from "lucide-react"
import type { ComponentType, SVGAttributes } from "react"
export type LayoutType = "vertical" | "horizontal"
export type ModeType = "light" | "dark" | "system"
export type OrientationType = "vertical" | "horizontal"
export type DirectionType = "ltr" | "rtl"
export type LocaleType = "de" | "en"
export type ThemeType = string
export type RadiusType = number
export type SettingsType = {
theme: ThemeType
mode: ModeType
radius: RadiusType
layout: LayoutType
locale: LocaleType
}
export interface IconProps extends SVGAttributes<SVGElement> {
children?: never
color?: string
}
export type IconType = ComponentType<IconProps> | LucideIcon
export type DynamicIconNameType = keyof typeof icons
export interface UserType {
id: string
firstName: string
lastName: string
name: string
password: string
username: string
role: string
avatar: string
background: string
status: string
phoneNumber: string
email: string
state: string
country: string
address: string
zipCode: string
language: string
timeZone: string
currency: string
organization: string
twoFactorAuth: boolean
loginAlerts: boolean
accountReoveryOption?: "email" | "sms" | "codes"
connections: number
followers: number
}
export interface NavigationType {
title: string
items: NavigationRootItem[]
}
export type NavigationRootItem =
| NavigationRootItemWithHrefType
| NavigationRootItemWithItemsType
export interface NavigationRootItemBasicType {
title: string
label?: string
iconName: DynamicIconNameType
}
export interface NavigationRootItemWithHrefType
extends NavigationRootItemBasicType {
href: string
items?: never
}
export interface NavigationRootItemWithItemsType
extends NavigationRootItemBasicType {
items: (
| NavigationNestedItemWithHrefType
| NavigationNestedItemWithItemsType
)[]
href?: never
}
export interface NavigationNestedItemBasicType {
title: string
label?: string
}
export interface NavigationNestedItemWithHrefType
extends NavigationNestedItemBasicType {
href: string
items?: never
}
export interface NavigationNestedItemWithItemsType
extends NavigationNestedItemBasicType {
items: (
| NavigationNestedItemWithHrefType
| NavigationNestedItemWithItemsType
)[]
href?: never
}
export type NavigationNestedItem =
| NavigationNestedItemWithHrefType
| NavigationNestedItemWithItemsType
+88
View File
@@ -0,0 +1,88 @@
export interface ClubStats {
totalMembers: number
activeMembers: number
distributionsToday: number
gramsDistributedToday: number
totalStockGrams: number
monthlyQuotaUsagePercent: number
}
export interface Distribution {
id: string
memberId: string
memberName: string
strainName: string
amountGrams: number
recordedBy: string
recordedAt: string // ISO 8601
}
export interface DistributionRecord {
id: string
memberId: string
memberName: string
batchId: string
strainName: string
amountGrams: number
recordedBy: string
recordedAt: string // ISO 8601
status: "COMPLETED" // immutable
}
export interface QuotaStatus {
dailyUsedGrams: number
dailyLimitGrams: number // always 25
monthlyUsedGrams: number
monthlyLimitGrams: number // 50 for ≥21, 30 for <21
isUnder21: boolean
}
export interface AvailableBatch {
id: string
strainName: string
availableGrams: number
thcPercent: number
status: "AVAILABLE"
}
export interface BatchSummary {
id: string
strainName: string
availableGrams: number
status: "AVAILABLE" | "RECALLED" | "DEPLETED"
}
export interface Batch {
id: string
strainName: string
thcPercent: number
cbdPercent: number
totalGrams: number
availableGrams: number
status: "AVAILABLE" | "RECALLED" | "DEPLETED"
supplier: string
harvestDate: string // ISO 8601
receivedAt: string // ISO 8601
notes?: string
}
export interface Strain {
id: string
name: string
defaultThcPercent: number
defaultCbdPercent: number
}
export interface Member {
id: string
firstName: string
lastName: string
email: string
dateOfBirth: string // ISO 8601
phone?: string
memberNumber: string
status: "ACTIVE" | "SUSPENDED" | "EXPELLED"
joinedAt: string // ISO 8601
monthlyQuotaUsedPercent: number
notes?: string
}
+30
View File
@@ -0,0 +1,30 @@
import type { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user: {
role: string
clubId: string
} & DefaultSession["user"]
error?: string
}
interface User {
role: string
clubId: string
accessToken: string
refreshToken: string
expiresAt: number
}
}
declare module "next-auth/jwt" {
interface JWT {
role?: string
clubId?: string
accessToken?: string
refreshToken?: string
expiresAt?: number
error?: string
}
}