feat(stammdaten): crud fuer hersteller, themen und standorte

Gemeinsame CrudSection fuer die drei kleinen Stammdatentabellen; sie
unterscheiden sich nur in den Formularfeldern. Loeschen ist bewusst
blockiert, solange Sets am Hersteller oder Thema haengen - Standorte
duerfen weg, die Sammlungseintraege verlieren dann nur den Ort.

Dazu die gemeinsamen Bausteine: zod-Schemata fuer alle Formulare,
einheitliches ActionResult, Slug-Erzeugung mit deutschen Umlauten,
Geld als Cent und Datumsformatierung dd.MM.yyyy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 22:46:45 +02:00
co-authored by Claude Opus 5
parent 4c78c5672b
commit af0ae039cb
18 changed files with 1254 additions and 70 deletions
+30
View File
@@ -0,0 +1,30 @@
import { cn } from 'cn';
import { Label } from './label';
/** Label + control + optional hint, the layout every form in the app uses. */
export function Field({
label,
htmlFor,
hint,
required,
className,
children,
}: {
label: string;
htmlFor: string;
hint?: string;
required?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn('space-y-2', className)}>
<Label htmlFor={htmlFor}>
{label}
{required ? <span className="text-destructive"> *</span> : null}
</Label>
{children}
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { cn } from 'cn';
/**
* A plain <select>.
*
* Deliberately not the Radix select: this app is used on phones, where the
* native picker is faster to operate, and the value lands in FormData without
* a hidden mirror input.
*/
export function NativeSelect({ className, ...props }: React.ComponentProps<'select'>) {
return (
<select
data-slot="native-select"
className={cn(
'h-11 w-full rounded-lg border border-input bg-transparent px-2.5 text-base outline-none transition-colors',
'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50',
'disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
className,
)}
{...props}
/>
);
}
+211
View File
@@ -0,0 +1,211 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { ActionResult } from '@/lib/action-result';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
export type CrudItem = {
id: string;
title: string;
subtitle?: string | null;
};
type Props<T extends CrudItem> = {
items: T[];
emptyText: string;
createLabel: string;
dialogTitle: (item: T | null) => string;
dialogDescription?: string;
/** Form fields for the create/edit dialog. `item` is null when creating. */
renderFields: (item: T | null) => React.ReactNode;
onCreate: (input: Record<string, unknown>) => Promise<ActionResult>;
onUpdate: (id: string, input: Record<string, unknown>) => Promise<ActionResult>;
onDelete: (id: string) => Promise<ActionResult>;
deleteWarning: (item: T) => string;
};
/**
* Shared list + create/edit/delete UI for the small master-data tables
* (manufacturers, themes, locations). They differ only in their form fields.
*/
export function CrudSection<T extends CrudItem>({
items,
emptyText,
createLabel,
dialogTitle,
dialogDescription,
renderFields,
onCreate,
onUpdate,
onDelete,
deleteWarning,
}: Props<T>) {
const router = useRouter();
const [editing, setEditing] = useState<T | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleting, setDeleting] = useState<T | null>(null);
const [pending, startTransition] = useTransition();
function openCreate() {
setEditing(null);
setDialogOpen(true);
}
function openEdit(item: T) {
setEditing(item);
setDialogOpen(true);
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const input = Object.fromEntries(formData.entries());
const current = editing;
startTransition(async () => {
const result = current ? await onUpdate(current.id, input) : await onCreate(input);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success(current ? 'Gespeichert.' : 'Angelegt.');
setDialogOpen(false);
setEditing(null);
router.refresh();
});
}
function confirmDelete() {
const item = deleting;
if (!item) return;
startTransition(async () => {
const result = await onDelete(item.id);
if (!result.ok) {
toast.error(result.error);
setDeleting(null);
return;
}
toast.success('Gelöscht.');
setDeleting(null);
router.refresh();
});
}
return (
<div className="space-y-4">
<Button size="lg" className="w-full sm:w-auto" onClick={openCreate}>
<Plus />
{createLabel}
</Button>
{items.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
{emptyText}
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((item) => (
<li key={item.id} className="flex items-center gap-2 p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title}</p>
{item.subtitle ? (
<p className="truncate text-xs text-muted-foreground">{item.subtitle}</p>
) : null}
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${item.title} bearbeiten`}
onClick={() => openEdit(item)}
disabled={pending}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${item.title} löschen`}
onClick={() => setDeleting(item)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setEditing(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle(editing)}</DialogTitle>
{dialogDescription ? (
<DialogDescription>{dialogDescription}</DialogDescription>
) : null}
</DialogHeader>
{/* Remounting on change resets defaultValue-based fields. */}
<form
key={editing?.id ?? 'new'}
id="crud-form"
onSubmit={submit}
className="space-y-4"
>
{renderFields(editing)}
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="crud-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Wirklich löschen?</AlertDialogTitle>
<AlertDialogDescription>
{deleting ? deleteWarning(deleting) : ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>Löschen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,62 @@
'use client';
import { createLocation, deleteLocation, updateLocation } from '@/app/(app)/verwaltung/actions';
import { CrudSection, type CrudItem } from './crud-section';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type LocationRow = CrudItem & {
parentLocationId: string | null;
};
export function LocationList({ locations }: { locations: LocationRow[] }) {
return (
<CrudSection<LocationRow>
items={locations}
emptyText="Noch kein Standort angelegt – zum Beispiel „Regal Kinderzimmer“ oder „Kiste Keller 3“."
createLabel="Standort anlegen"
dialogTitle={(item) => (item ? 'Standort bearbeiten' : 'Neuer Standort')}
onCreate={createLocation}
onUpdate={updateLocation}
onDelete={deleteLocation}
deleteWarning={(item) =>
`„${item.title}“ wird gelöscht. Sets an diesem Standort bleiben erhalten und haben danach keinen Standort mehr.`
}
renderFields={(item) => (
<>
<Field label="Name" htmlFor="name" required>
<Input
id="name"
name="name"
defaultValue={item?.title ?? ''}
required
maxLength={80}
placeholder="Regal Kinderzimmer"
/>
</Field>
<Field
label="Liegt in"
htmlFor="parentLocationId"
hint="Optional, für Schachtelungen wie „Keller → Kiste 3“."
>
<NativeSelect
id="parentLocationId"
name="parentLocationId"
defaultValue={item?.parentLocationId ?? 'none'}
>
<option value="none">– kein übergeordneter Standort –</option>
{locations
.filter((candidate) => candidate.id !== item?.id)
.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.title}
</option>
))}
</NativeSelect>
</Field>
</>
)}
/>
);
}
@@ -0,0 +1,54 @@
'use client';
import {
createManufacturer,
deleteManufacturer,
updateManufacturer,
} from '@/app/(app)/verwaltung/actions';
import { CrudSection, type CrudItem } from './crud-section';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
export type ManufacturerRow = CrudItem & {
website: string | null;
setCount: number;
};
export function ManufacturerList({ manufacturers }: { manufacturers: ManufacturerRow[] }) {
return (
<CrudSection<ManufacturerRow>
items={manufacturers}
emptyText="Noch kein Hersteller angelegt. Lego, Playmobil, Cobi – leg einfach los."
createLabel="Hersteller anlegen"
dialogTitle={(item) => (item ? 'Hersteller bearbeiten' : 'Neuer Hersteller')}
dialogDescription="Der Name erscheint als Badge auf jeder Set-Kachel."
onCreate={createManufacturer}
onUpdate={updateManufacturer}
onDelete={deleteManufacturer}
deleteWarning={(item) =>
`„${item.title}“ wird gelöscht. Das geht nur, solange kein Set diesen Hersteller nutzt.`
}
renderFields={(item) => (
<>
<Field label="Name" htmlFor="name" required>
<Input id="name" name="name" defaultValue={item?.title ?? ''} required maxLength={80} />
</Field>
<Field
label="Website"
htmlFor="website"
hint="Optional. Wird auf der öffentlichen Wunschliste als Link zum Shop angeboten."
>
<Input
id="website"
name="website"
type="url"
inputMode="url"
defaultValue={item?.website ?? ''}
placeholder="https://www.lego.com"
/>
</Field>
</>
)}
/>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { createTheme, deleteTheme, updateTheme } from '@/app/(app)/verwaltung/actions';
import { CrudSection, type CrudItem } from './crud-section';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type ThemeRow = CrudItem & {
manufacturerId: string;
parentThemeId: string | null;
};
export function ThemeList({
themes,
manufacturers,
}: {
themes: ThemeRow[];
manufacturers: Array<{ id: string; name: string }>;
}) {
return (
<CrudSection<ThemeRow>
items={themes}
emptyText="Noch kein Thema angelegt. Themen sind Serien wie „City“, „Technic“ oder „Novelmore“."
createLabel="Thema anlegen"
dialogTitle={(item) => (item ? 'Thema bearbeiten' : 'Neues Thema')}
onCreate={createTheme}
onUpdate={updateTheme}
onDelete={deleteTheme}
deleteWarning={(item) =>
`„${item.title}“ wird gelöscht. Das geht nur, solange dem Thema kein Set zugeordnet ist.`
}
renderFields={(item) => (
<>
<Field label="Hersteller" htmlFor="manufacturerId" required>
<NativeSelect
id="manufacturerId"
name="manufacturerId"
defaultValue={item?.manufacturerId ?? manufacturers[0]?.id ?? ''}
required
>
{manufacturers.map((manufacturer) => (
<option key={manufacturer.id} value={manufacturer.id}>
{manufacturer.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Name" htmlFor="name" required>
<Input id="name" name="name" defaultValue={item?.title ?? ''} required maxLength={80} />
</Field>
<Field
label="Oberthema"
htmlFor="parentThemeId"
hint="Optional, für Unterthemen wie „City → Feuerwehr“."
>
<NativeSelect
id="parentThemeId"
name="parentThemeId"
defaultValue={item?.parentThemeId ?? 'none'}
>
<option value="none">– kein Oberthema –</option>
{themes
.filter((candidate) => candidate.id !== item?.id)
.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.title}
</option>
))}
</NativeSelect>
</Field>
</>
)}
/>
);
}
@@ -0,0 +1,38 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from 'cn';
const tabs = [
{ href: '/verwaltung/hersteller', label: 'Hersteller' },
{ href: '/verwaltung/themen', label: 'Themen' },
{ href: '/verwaltung/standorte', label: 'Standorte' },
] as const;
export function VerwaltungNav() {
const pathname = usePathname();
return (
<nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten">
{tabs.map((tab) => {
const active = pathname === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex-1 whitespace-nowrap rounded-md px-3 py-2 text-center text-sm font-medium transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</Link>
);
})}
</nav>
);
}