feat(ui): raw material lots and production inputs

- Pages for raw material lots (beeswax, propolis): list, create in grams
  with optional apiary, detail with stock card (harvested / used in
  production / withdrawn / remaining) and "used in" batches.
- Production batch form takes the raw material used (lot + grams) and the
  batch page shows what it was made from - traceability both ways.
- Sidebar entry "Rohstoffe".
- Removes backend files that were committed into ApiCore-Frontend/src by
  mistake in e52a605 (copies of ApiCore-Backend/src/raw-materials).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 06:31:07 +02:00
1 parent f9899d5621
commit 569a43d247
22 files changed
+803 -412

No files matched your search

@@ -15,6 +15,10 @@ import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { StockCard } from "@/components/stock/stock-card";
import { useAuth } from "@/lib/auth/auth-context";
import { EditProductionBatchDialog } from "./edit-production-batch-dialog";
import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
const BACK = {
backHref: "/production-batches",
@@ -102,6 +106,25 @@ export default function ProductionBatchDetailPage() {
{batch.notes ? (
<p className="whitespace-pre-line">Notiz: {batch.notes}</p>
) : null}
{batch.inputs && batch.inputs.length > 0 ? (
<div className="flex flex-col gap-1">
<p className="font-medium">Hergestellt aus</p>
<ul className="flex flex-col gap-1">
{batch.inputs.map((input) => (
<li key={input.id}>
<Link
className="underline"
href={`/raw-materials/${input.rawMaterialLot.id}`}
>
{RAW_MATERIAL_KIND_LABELS[input.rawMaterialLot.kind]}{" "}
{input.rawMaterialLot.lotNumber}
</Link>
: {formatGrams(input.quantity)}
</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
<StockCard
@@ -7,6 +7,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createProductionBatch } from "@/lib/production-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { fetchRawMaterialLots } from "@/lib/raw-materials/api";
import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -34,13 +39,31 @@ export default function NewProductionBatchPage() {
const [quantity, setQuantity] = useState("");
const [bestBeforeDate, setBestBeforeDate] = useState("");
const [notes, setNotes] = useState("");
// Verbrauchte Rohstoffe: je Zeile eine Rohstoff-Charge und Gramm.
const [inputs, setInputs] = useState<
{ rawMaterialLotId: string; grams: string }[]
>([]);
const { data: rawLots } = useQuery({
queryKey: ["raw-material-lots"],
queryFn: () => fetchRawMaterialLots(),
});
const availableLots = (rawLots ?? []).filter((lot) => lot.remainingCount > 0);
const updateInput = (
index: number,
change: Partial<{ rawMaterialLotId: string; grams: string }>,
) =>
setInputs((current) =>
current.map((row, i) => (i === index ? { ...row, ...change } : row)),
);
const [error, setError] = useState<string | null>(null);
const { data: products } = useQuery({
queryKey: ["products", { status: "ACTIVE" }],
queryFn: () => fetchProducts({ status: "ACTIVE" }),
});
const manufactured = (products ?? []).filter((product) => product.kind === "MANUFACTURED");
const manufactured = (products ?? []).filter(
(product) => product.kind === "MANUFACTURED",
);
const selected = manufactured.find((product) => product.id === productId);
const mutation = useMutation({
@@ -50,8 +73,16 @@ export default function NewProductionBatchPage() {
lotNumber: lotNumber.trim(),
date: new Date(date).toISOString(),
quantity: Number(quantity),
bestBeforeDate: bestBeforeDate ? new Date(bestBeforeDate).toISOString() : undefined,
bestBeforeDate: bestBeforeDate
? new Date(bestBeforeDate).toISOString()
: undefined,
notes: notes.trim() || undefined,
inputs: inputs
.filter((row) => row.rawMaterialLotId && Number(row.grams) > 0)
.map((row) => ({
rawMaterialLotId: row.rawMaterialLotId,
quantity: Number(row.grams),
})),
}),
onSuccess: (batch) => {
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
@@ -59,7 +90,8 @@ export default function NewProductionBatchPage() {
router.push(`/production-batches/${batch.id}`);
},
onError: (mutationError: Error) => {
const message = mutationError.message || "Charge konnte nicht angelegt werden";
const message =
mutationError.message || "Charge konnte nicht angelegt werden";
setError(message);
toast.error(message);
},
@@ -84,12 +116,20 @@ export default function NewProductionBatchPage() {
/>
<Card>
<CardContent>
<form className="flex max-w-xl flex-col gap-4" onSubmit={handleSubmit}>
<form
className="flex max-w-xl flex-col gap-4"
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-product">Artikel</Label>
<Select value={productId} onValueChange={(value) => setProductId(value ?? "")}>
<Select
value={productId}
onValueChange={(value) => setProductId(value ?? "")}
>
<SelectTrigger id="pb-product" className="w-full">
<SelectValue placeholder="Artikel wählen">{selected?.name}</SelectValue>
<SelectValue placeholder="Artikel wählen">
{selected?.name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{manufactured.map((product) => (
@@ -101,7 +141,8 @@ export default function NewProductionBatchPage() {
</Select>
{products && manufactured.length === 0 ? (
<p className="text-xs text-muted-foreground">
Noch kein Artikel der Art &bdquo;Hergestellte Ware&ldquo;. Lege ihn unter Produkte an.
Noch kein Artikel der Art &bdquo;Hergestellte Ware&ldquo;.
Lege ihn unter Produkte an.
</p>
) : null}
</div>
@@ -138,7 +179,9 @@ export default function NewProductionBatchPage() {
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-best-before">Mindesthaltbar bis (optional)</Label>
<Label htmlFor="pb-best-before">
Mindesthaltbar bis (optional)
</Label>
<Input
id="pb-best-before"
type="date"
@@ -146,9 +189,108 @@ export default function NewProductionBatchPage() {
onChange={(event) => setBestBeforeDate(event.target.value)}
/>
</div>
<fieldset className="flex flex-col gap-3 rounded-lg border p-3">
<legend className="px-1 text-sm font-medium">
Verbrauchte Rohstoffe (optional)
</legend>
<p className="text-xs text-muted-foreground">
Wird beim Anlegen vom Rohstoff abgebucht. So lässt sich später
nachverfolgen, aus welchem Wachs oder Propolis diese Charge
stammt.
</p>
{inputs.map((row, index) => {
const lot = availableLots.find(
(candidate) => candidate.id === row.rawMaterialLotId,
);
return (
<div key={index} className="flex flex-wrap items-end gap-2">
<div className="flex min-w-48 flex-1 flex-col gap-1">
<Label htmlFor={`pb-input-lot-${index}`}>Rohstoff</Label>
<Select
value={row.rawMaterialLotId}
onValueChange={(value) =>
updateInput(index, { rawMaterialLotId: value ?? "" })
}
>
<SelectTrigger
id={`pb-input-lot-${index}`}
className="w-full"
>
<SelectValue placeholder="Charge wählen">
{lot
? `${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableLots.map((candidate) => (
<SelectItem key={candidate.id} value={candidate.id}>
{RAW_MATERIAL_KIND_LABELS[candidate.kind]}{" "}
{candidate.lotNumber} (noch{" "}
{formatGrams(candidate.remainingCount)})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-28 flex-col gap-1">
<Label htmlFor={`pb-input-grams-${index}`}>Gramm</Label>
<Input
id={`pb-input-grams-${index}`}
type="number"
inputMode="numeric"
min={1}
max={lot?.remainingCount}
step={1}
value={row.grams}
onChange={(event) =>
updateInput(index, { grams: event.target.value })
}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setInputs((current) =>
current.filter((_, i) => i !== index),
)
}
>
Entfernen
</Button>
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
disabled={availableLots.length === 0}
onClick={() =>
setInputs((current) => [
...current,
{ rawMaterialLotId: "", grams: "" },
])
}
>
+ Rohstoff hinzufügen
</Button>
{rawLots && availableLots.length === 0 ? (
<p className="text-xs text-muted-foreground">
Kein Rohstoff mit Restbestand vorhanden.
</p>
) : null}
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-notes">Notiz (optional, intern)</Label>
<Textarea id="pb-notes" value={notes} onChange={(event) => setNotes(event.target.value)} />
<Textarea
id="pb-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
@@ -20,7 +20,11 @@ export default function ProductionBatchesPage() {
});
const newButton = canCreate ? (
<Button size="touch" nativeButton={false} render={<Link href="/production-batches/new" />}>
<Button
size="touch"
nativeButton={false}
render={<Link href="/production-batches/new" />}
>
+ Neue Herstellungscharge
</Button>
) : null;
@@ -59,9 +63,15 @@ export default function ProductionBatchesPage() {
{ label: "Hergestellt", value: String(batch.quantity) },
{
label: "Rest",
value: batch.remainingCount < 0 ? `${batch.remainingCount} (prüfen)` : String(batch.remainingCount),
value:
batch.remainingCount < 0
? `${batch.remainingCount} (prüfen)`
: String(batch.remainingCount),
},
{
label: "Datum",
value: new Date(batch.date).toLocaleDateString("de-DE"),
},
{ label: "Datum", value: new Date(batch.date).toLocaleDateString("de-DE") },
]}
/>
))}
@@ -0,0 +1,136 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { Boxes } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
deleteRawMaterialLot,
fetchRawMaterialLot,
} from "@/lib/raw-materials/api";
import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { StockCard } from "@/components/stock/stock-card";
import { useAuth } from "@/lib/auth/auth-context";
const BACK = { backHref: "/raw-materials", backLabel: "Alle Rohstoffe" };
export default function RawMaterialDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
const lotQuery = useQuery({
queryKey: ["raw-material-lots", params.id],
queryFn: () => fetchRawMaterialLot(params.id),
});
const lot = lotQuery.data;
const deletion = useMutation({
mutationFn: () => deleteRawMaterialLot(params.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["raw-material-lots"] });
toast.success("Rohstoff gelöscht");
router.push("/raw-materials");
},
onError: (error: Error) =>
toast.error(error.message || "Rohstoff konnte nicht gelöscht werden"),
});
if (lotQuery.isLoading || lotQuery.isError || !lot) {
return (
<DetailQueryFallback
query={lotQuery}
what="Daten des Rohstoffs"
{...BACK}
/>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Boxes}
title={`${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`}
subtitle={`${formatGrams(lot.quantity)} gewonnen am ${new Date(lot.date).toLocaleDateString("de-DE")}`}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Angaben</CardTitle>
{hasPermission("raw-material:delete") ? (
<ConfirmDialog
triggerLabel="Löschen"
triggerVariant="outline"
triggerSize="sm"
title="Rohstoff löschen"
description={`${lot.lotNumber} wird entfernt. Das geht nur, solange nichts verbraucht, entnommen oder inventiert wurde.`}
confirmLabel="Endgültig löschen"
onConfirm={() => deletion.mutate()}
isPending={deletion.isPending}
/>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
<p>Art: {RAW_MATERIAL_KIND_LABELS[lot.kind]}</p>
{lot.apiary ? <p>Stand: {lot.apiary.name}</p> : null}
{lot.notes ? (
<p className="whitespace-pre-line">Notiz: {lot.notes}</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Verwendet in</CardTitle>
</CardHeader>
<CardContent>
{lot.usedIn.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch in keiner Herstellungscharge verwendet.
</p>
) : (
<ul className="flex flex-col divide-y rounded-lg border text-sm">
{lot.usedIn.map((use) => (
<li key={use.id}>
<Link
href={`/production-batches/${use.productionBatch.id}`}
className="flex items-baseline justify-between gap-2 p-3 hover:bg-muted"
>
<span>
<span className="font-mono font-medium">
{use.productionBatch.lotNumber}
</span>{" "}
<span className="text-muted-foreground">
{use.productionBatch.product.name}
</span>
</span>
<span className="tabular-nums">
{formatGrams(use.quantity)}
</span>
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<StockCard
path="raw-material-lots"
id={lot.id}
initialLabel="Gewonnen"
unit="g"
canBook={hasPermission("raw-material:withdraw")}
/>
</div>
</main>
);
}
@@ -0,0 +1,186 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Boxes } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createRawMaterialLot } from "@/lib/raw-materials/api";
import { RAW_MATERIAL_KIND_LABELS } from "@/lib/raw-materials/labels";
import { RawMaterialKind } from "@/lib/raw-materials/types";
import { fetchApiaries } from "@/lib/apiaries/api";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const NO_APIARY = "__none__";
export default function NewRawMaterialPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [kind, setKind] = useState<RawMaterialKind>("BEESWAX");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
const [quantity, setQuantity] = useState("");
const [apiaryId, setApiaryId] = useState(NO_APIARY);
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const { data: apiaries } = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const selectedApiary = apiaries?.find((apiary) => apiary.id === apiaryId);
const mutation = useMutation({
mutationFn: () =>
createRawMaterialLot({
kind,
lotNumber: lotNumber.trim(),
date: new Date(date).toISOString(),
quantity: Number(quantity),
apiaryId: apiaryId === NO_APIARY ? undefined : apiaryId,
notes: notes.trim() || undefined,
}),
onSuccess: (lot) => {
void queryClient.invalidateQueries({ queryKey: ["raw-material-lots"] });
toast.success(`Rohstoff ${lot.lotNumber} angelegt`);
router.push(`/raw-materials/${lot.id}`);
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Rohstoff konnte nicht angelegt werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Boxes}
title="Neuer Rohstoff"
description="Was hast du gewonnen, und wie viel?"
/>
<Card>
<CardContent>
<form
className="flex max-w-xl flex-col gap-4"
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-kind">Art</Label>
<Select
value={kind}
onValueChange={(value) =>
setKind((value ?? kind) as RawMaterialKind)
}
>
<SelectTrigger id="raw-kind" className="w-full">
<SelectValue>{RAW_MATERIAL_KIND_LABELS[kind]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
Object.keys(RAW_MATERIAL_KIND_LABELS) as RawMaterialKind[]
).map((option) => (
<SelectItem key={option} value={option}>
{RAW_MATERIAL_KIND_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-lot">Chargennummer</Label>
<Input
id="raw-lot"
required
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-date">Gewonnen am</Label>
<Input
id="raw-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-quantity">Menge (g)</Label>
<Input
id="raw-quantity"
type="number"
inputMode="numeric"
min={1}
step={1}
required
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
In Gramm, z. B. 4200 für 4,2 kg Wachs.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-apiary">Stand (optional)</Label>
<Select
value={apiaryId}
onValueChange={(value) => setApiaryId(value ?? NO_APIARY)}
>
<SelectTrigger id="raw-apiary" className="w-full">
<SelectValue>
{selectedApiary?.name ?? "Kein Stand"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_APIARY}>Kein Stand</SelectItem>
{(apiaries ?? []).map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-notes">Notiz (optional, intern)</Label>
<Textarea
id="raw-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Rohstoff anlegen"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { Boxes } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchRawMaterialLots } from "@/lib/raw-materials/api";
import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { useAuth } from "@/lib/auth/auth-context";
export default function RawMaterialsPage() {
const { hasPermission } = useAuth();
const lotsQuery = useQuery({
queryKey: ["raw-material-lots"],
queryFn: () => fetchRawMaterialLots(),
});
const newButton = hasPermission("raw-material:create") ? (
<Button
size="touch"
nativeButton={false}
render={<Link href="/raw-materials/new" />}
>
+ Neuer Rohstoff
</Button>
) : null;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Boxes}
title="Rohstoffe"
description="Wachs und Propolis je Charge - mit gewonnener Menge, Verbrauch in der Herstellung und Rest."
actions={newButton}
/>
<QueryState
query={lotsQuery}
what="Rohstoffe"
empty={
<EmptyState
icon={Boxes}
title="Noch keine Rohstoffe"
description="Erfasse Wachs vom Entdeckeln oder Propolis von den Gittern, um daraus Kerzen, Tinkturen oder Salben herzustellen."
action={newButton ?? undefined}
/>
}
>
{(lots) => (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{lots.map((lot) => (
<EntityCard
key={lot.id}
href={`/raw-materials/${lot.id}`}
icon={Boxes}
title={<span className="font-mono">{lot.lotNumber}</span>}
subtitle={RAW_MATERIAL_KIND_LABELS[lot.kind]}
fields={[
{ label: "Gewonnen", value: formatGrams(lot.quantity) },
{
label: "Rest",
value:
lot.remainingCount < 0
? `${formatGrams(lot.remainingCount)} (prüfen)`
: formatGrams(lot.remainingCount),
},
{
label: "Datum",
value: new Date(lot.date).toLocaleDateString("de-DE"),
},
{
label: "Verwendet in",
value: `${lot.usedIn.length} Charge(n)`,
},
]}
/>
))}
</div>
)}
</QueryState>
</main>
);
}
+25 -4
View File
@@ -9,6 +9,7 @@ import {
Database,
Droplet,
Factory,
Boxes,
FileClock,
FlaskConical,
Hexagon,
@@ -82,6 +83,12 @@ const NAV_GROUPS: NavGroup[] = [
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling", icon: Package },
{
label: "Rohstoffe",
href: "/raw-materials",
icon: Boxes,
permission: "raw-material:read",
},
{
label: "Herstellungschargen",
href: "/production-batches",
@@ -128,7 +135,12 @@ const ADMIN_GROUP: NavGroup = {
icon: Database,
permission: ["backup:export", "backup:import", "import:execute"],
},
{ label: "Audit-Log", href: "/settings/audit-log", icon: History, permission: "audit:read" },
{
label: "Audit-Log",
href: "/settings/audit-log",
icon: History,
permission: "audit:read",
},
],
};
@@ -157,7 +169,10 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
);
}
function hasNavPermission(user: AuthUser, permission: NavItem["permission"]): boolean {
function hasNavPermission(
user: AuthUser,
permission: NavItem["permission"],
): boolean {
if (!permission) {
return true;
}
@@ -185,13 +200,19 @@ export function Sidebar({ user }: { user: AuthUser }) {
className="flex size-8 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-4.5 fill-white/25 text-white" strokeWidth={2.25} />
<Hexagon
className="size-4.5 fill-white/25 text-white"
strokeWidth={2.25}
/>
</span>
ApiCore
</Link>
<nav className="flex flex-1 flex-col gap-5">
{groups.map((group, index) => (
<div key={group.label ?? `group-${index}`} className="flex flex-col gap-0.5">
<div
key={group.label ?? `group-${index}`}
className="flex flex-col gap-0.5"
>
{group.label ? (
<p className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted-foreground/80 uppercase">
{group.label}