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:
@@ -36,6 +36,7 @@
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
Generated
+8
@@ -47,6 +47,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
server-only:
|
||||
specifier: ^0.0.1
|
||||
version: 0.0.1
|
||||
sharp:
|
||||
specifier: ^0.35.4
|
||||
version: 0.35.4(@types/node@20.19.43)
|
||||
@@ -4417,6 +4420,9 @@ packages:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
set-cookie-parser@3.1.2:
|
||||
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
|
||||
|
||||
@@ -9111,6 +9117,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
set-cookie-parser@3.1.2: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { location, manufacturer, set, theme } from '@/db/schema';
|
||||
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { uniqueSlug } from '@/lib/slug';
|
||||
import { locationSchema, manufacturerSchema, themeSchema } from '@/lib/validation';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateMasterData() {
|
||||
revalidatePath('/verwaltung/hersteller');
|
||||
revalidatePath('/verwaltung/themen');
|
||||
revalidatePath('/verwaltung/standorte');
|
||||
revalidatePath('/katalog');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manufacturers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createManufacturer(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(manufacturerSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all();
|
||||
const exists = db
|
||||
.select({ id: manufacturer.id })
|
||||
.from(manufacturer)
|
||||
.where(eq(manufacturer.name, parsed.data.name))
|
||||
.get();
|
||||
if (exists) return actionError('Diesen Hersteller gibt es schon.');
|
||||
|
||||
db.insert(manufacturer)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: parsed.data.name,
|
||||
slug: uniqueSlug(
|
||||
parsed.data.name,
|
||||
taken.map((row) => row.slug),
|
||||
),
|
||||
website: parsed.data.website,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function updateManufacturer(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(manufacturerSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const clash = db
|
||||
.select({ id: manufacturer.id })
|
||||
.from(manufacturer)
|
||||
.where(and(eq(manufacturer.name, parsed.data.name), ne(manufacturer.id, id)))
|
||||
.get();
|
||||
if (clash) return actionError('Ein anderer Hersteller heißt bereits so.');
|
||||
|
||||
db.update(manufacturer)
|
||||
.set({ name: parsed.data.name, website: parsed.data.website, updatedAt: now() })
|
||||
.where(eq(manufacturer.id, id))
|
||||
.run();
|
||||
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteManufacturer(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const used = db.select({ id: set.id }).from(set).where(eq(set.manufacturerId, id)).get();
|
||||
if (used) {
|
||||
return actionError(
|
||||
'Der Hersteller wird noch von mindestens einem Set verwendet und kann deshalb nicht gelöscht werden.',
|
||||
);
|
||||
}
|
||||
|
||||
db.delete(manufacturer).where(eq(manufacturer.id, id)).run();
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Themes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createTheme(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(themeSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const exists = db
|
||||
.select({ id: theme.id })
|
||||
.from(theme)
|
||||
.where(
|
||||
and(eq(theme.manufacturerId, parsed.data.manufacturerId), eq(theme.name, parsed.data.name)),
|
||||
)
|
||||
.get();
|
||||
if (exists) return actionError('Dieses Thema gibt es beim Hersteller schon.');
|
||||
|
||||
db.insert(theme)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
manufacturerId: parsed.data.manufacturerId,
|
||||
name: parsed.data.name,
|
||||
parentThemeId: parsed.data.parentThemeId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function updateTheme(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(themeSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
if (parsed.data.parentThemeId === id) {
|
||||
return actionError('Ein Thema kann nicht sein eigenes Oberthema sein.');
|
||||
}
|
||||
|
||||
const clash = db
|
||||
.select({ id: theme.id })
|
||||
.from(theme)
|
||||
.where(
|
||||
and(
|
||||
eq(theme.manufacturerId, parsed.data.manufacturerId),
|
||||
eq(theme.name, parsed.data.name),
|
||||
ne(theme.id, id),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (clash) return actionError('Ein anderes Thema dieses Herstellers heißt bereits so.');
|
||||
|
||||
db.update(theme)
|
||||
.set({
|
||||
manufacturerId: parsed.data.manufacturerId,
|
||||
name: parsed.data.name,
|
||||
parentThemeId: parsed.data.parentThemeId,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(theme.id, id))
|
||||
.run();
|
||||
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteTheme(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
// Sets keep existing without a theme (ON DELETE SET NULL), but silently
|
||||
// unthemeing a dozen sets would be a surprise - so we ask for a decision.
|
||||
const used = db.select({ id: set.id }).from(set).where(eq(set.themeId, id)).get();
|
||||
if (used) {
|
||||
return actionError(
|
||||
'Dem Thema sind noch Sets zugeordnet. Ordne sie zuerst um, dann lässt sich das Thema löschen.',
|
||||
);
|
||||
}
|
||||
|
||||
db.delete(theme).where(eq(theme.id, id)).run();
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createLocation(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(locationSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
db.insert(location)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: parsed.data.name,
|
||||
parentLocationId: parsed.data.parentLocationId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidatePath('/verwaltung/standorte');
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function updateLocation(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(locationSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
if (parsed.data.parentLocationId === id) {
|
||||
return actionError('Ein Standort kann nicht in sich selbst liegen.');
|
||||
}
|
||||
|
||||
db.update(location)
|
||||
.set({
|
||||
name: parsed.data.name,
|
||||
parentLocationId: parsed.data.parentLocationId,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(location.id, id))
|
||||
.run();
|
||||
|
||||
revalidatePath('/verwaltung/standorte');
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteLocation(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
// Collection items only lose their location (ON DELETE SET NULL).
|
||||
db.delete(location).where(eq(location.id, id)).run();
|
||||
|
||||
revalidatePath('/verwaltung/standorte');
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { ManufacturerList, type ManufacturerRow } from '@/components/verwaltung/manufacturer-list';
|
||||
import { pluralize } from '@/lib/format';
|
||||
import { listManufacturers } from '@/lib/queries/master-data';
|
||||
|
||||
export const metadata: Metadata = { title: 'Hersteller' };
|
||||
|
||||
export default function HerstellerPage() {
|
||||
const rows: ManufacturerRow[] = listManufacturers().map((row) => ({
|
||||
id: row.id,
|
||||
title: row.name,
|
||||
subtitle: [pluralize(row.setCount, 'Set', 'Sets'), row.website]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
website: row.website,
|
||||
setCount: row.setCount,
|
||||
}));
|
||||
|
||||
return <ManufacturerList manufacturers={rows} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Verwaltung' };
|
||||
|
||||
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireUser();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Verwaltung</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
|
||||
</p>
|
||||
</div>
|
||||
<VerwaltungNav />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { LocationList, type LocationRow } from '@/components/verwaltung/location-list';
|
||||
import { listLocations } from '@/lib/queries/master-data';
|
||||
|
||||
export const metadata: Metadata = { title: 'Standorte' };
|
||||
|
||||
export default function StandortePage() {
|
||||
const rows: LocationRow[] = listLocations().map((row) => ({
|
||||
id: row.id,
|
||||
title: row.name,
|
||||
subtitle: row.parentName ? `in ${row.parentName}` : null,
|
||||
parentLocationId: row.parentLocationId,
|
||||
}));
|
||||
|
||||
return <LocationList locations={rows} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { ThemeList, type ThemeRow } from '@/components/verwaltung/theme-list';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
|
||||
export const metadata: Metadata = { title: 'Themen' };
|
||||
|
||||
export default function ThemenPage() {
|
||||
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
||||
|
||||
const rows: ThemeRow[] = listThemes().map((row) => ({
|
||||
id: row.id,
|
||||
title: row.name,
|
||||
subtitle: [row.manufacturerName, row.parentName ? `in ${row.parentName}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
manufacturerId: row.manufacturerId,
|
||||
parentThemeId: row.parentThemeId,
|
||||
}));
|
||||
|
||||
if (manufacturers.length === 0) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
Themen gehören immer zu einem Hersteller. Leg zuerst einen Hersteller an.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return <ThemeList themes={rows} manufacturers={manufacturers} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Uniform return shape for every server action, so the client always knows
|
||||
* where to find the error message and never has to catch exceptions.
|
||||
*/
|
||||
export type ActionResult<T = undefined> =
|
||||
| ({ ok: true } & (T extends undefined ? { data?: undefined } : { data: T }))
|
||||
| { ok: false; error: string; fieldErrors?: Record<string, string> };
|
||||
|
||||
export function actionError(error: string, fieldErrors?: Record<string, string>) {
|
||||
return { ok: false as const, error, fieldErrors };
|
||||
}
|
||||
|
||||
export function actionOk(): { ok: true } {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function actionData<T>(data: T): { ok: true; data: T } {
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
/** Turns FormData into a plain object; repeated keys become arrays. */
|
||||
export function formToObject(formData: FormData): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value instanceof File) continue;
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a zod schema against form input and maps the first message per field
|
||||
* into a shape the forms can render.
|
||||
*/
|
||||
export function parseForm<Schema extends z.ZodType>(
|
||||
schema: Schema,
|
||||
input: unknown,
|
||||
): { ok: true; data: z.output<Schema> } | { ok: false; error: string; fieldErrors: Record<string, string> } {
|
||||
const parsed = schema.safeParse(input);
|
||||
if (parsed.success) return { ok: true, data: parsed.data };
|
||||
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = issue.path.join('.') || '_';
|
||||
fieldErrors[key] ??= issue.message;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: Object.values(fieldErrors)[0] ?? 'Die Eingaben sind unvollständig.',
|
||||
fieldErrors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'server-only';
|
||||
import { asc, count, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { location, manufacturer, set, theme } from '@/db/schema';
|
||||
|
||||
export function listManufacturers() {
|
||||
return db
|
||||
.select({
|
||||
id: manufacturer.id,
|
||||
name: manufacturer.name,
|
||||
slug: manufacturer.slug,
|
||||
website: manufacturer.website,
|
||||
setCount: count(set.id),
|
||||
})
|
||||
.from(manufacturer)
|
||||
.leftJoin(set, eq(set.manufacturerId, manufacturer.id))
|
||||
.groupBy(manufacturer.id)
|
||||
.orderBy(asc(manufacturer.name))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function listThemes() {
|
||||
const parent = sql<string | null>`(
|
||||
select parent.name from theme as parent where parent.id = ${theme.parentThemeId}
|
||||
)`.as('parent_name');
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
manufacturerId: theme.manufacturerId,
|
||||
manufacturerName: manufacturer.name,
|
||||
parentThemeId: theme.parentThemeId,
|
||||
parentName: parent,
|
||||
})
|
||||
.from(theme)
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, theme.manufacturerId))
|
||||
.orderBy(asc(manufacturer.name), asc(theme.name))
|
||||
.all();
|
||||
}
|
||||
|
||||
/** Themes of one manufacturer - used by the set form and the catalog filter. */
|
||||
export function listThemesByManufacturer(manufacturerId: string) {
|
||||
return db
|
||||
.select({ id: theme.id, name: theme.name })
|
||||
.from(theme)
|
||||
.where(eq(theme.manufacturerId, manufacturerId))
|
||||
.orderBy(asc(theme.name))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function listLocations() {
|
||||
const parent = sql<string | null>`(
|
||||
select parent.name from location as parent where parent.id = ${location.parentLocationId}
|
||||
)`.as('parent_name');
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
parentLocationId: location.parentLocationId,
|
||||
parentName: parent,
|
||||
})
|
||||
.from(location)
|
||||
.orderBy(asc(location.name))
|
||||
.all();
|
||||
}
|
||||
|
||||
export type ManufacturerListRow = ReturnType<typeof listManufacturers>[number];
|
||||
export type ThemeListRow = ReturnType<typeof listThemes>[number];
|
||||
export type LocationListRow = ReturnType<typeof listLocations>[number];
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* URL-safe slug from arbitrary user input. German umlauts are spelled out
|
||||
* before the accents are stripped, so "Grün" becomes "gruen" and not "grun".
|
||||
*/
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.normalize('NFD')
|
||||
// Combining diacritical marks left over from the decomposition.
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a counter until the slug is free. `taken` holds the slugs that
|
||||
* already exist in the same table.
|
||||
*/
|
||||
export function uniqueSlug(base: string, taken: Iterable<string>): string {
|
||||
const existing = new Set(taken);
|
||||
const seed = slugify(base) || 'eintrag';
|
||||
if (!existing.has(seed)) return seed;
|
||||
|
||||
let counter = 2;
|
||||
while (existing.has(`${seed}-${counter}`)) counter += 1;
|
||||
return `${seed}-${counter}`;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { z } from 'zod';
|
||||
import { parseEuroInput } from './money';
|
||||
|
||||
/**
|
||||
* Server-side validation for every form in the app.
|
||||
*
|
||||
* All input arrives as FormData, i.e. as strings, so the schemas do the
|
||||
* coercion as well: empty fields become null, prices become cents, dates
|
||||
* become Date objects.
|
||||
*/
|
||||
|
||||
/** Empty text fields must not be stored as empty strings. */
|
||||
export const optionalText = (max = 500) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max, `Höchstens ${max} Zeichen.`)
|
||||
.nullish()
|
||||
.transform((value) => (value && value.length > 0 ? value : null));
|
||||
|
||||
export const optionalUrl = z
|
||||
.string()
|
||||
.trim()
|
||||
.nullish()
|
||||
.transform((value) => (value && value.length > 0 ? value : null))
|
||||
.refine(
|
||||
(value) => value === null || /^https?:\/\/\S+$/i.test(value),
|
||||
'Bitte eine vollständige Adresse mit http:// oder https:// angeben.',
|
||||
);
|
||||
|
||||
/** A select that may be left on "keine Auswahl". */
|
||||
export const optionalId = z
|
||||
.string()
|
||||
.trim()
|
||||
.nullish()
|
||||
.transform((value) => (value && value.length > 0 && value !== 'none' ? value : null));
|
||||
|
||||
export const optionalInt = (min: number, max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.nullish()
|
||||
.transform((value) => (value && value.length > 0 ? Number(value) : null))
|
||||
.refine(
|
||||
(value) => value === null || (Number.isInteger(value) && value >= min && value <= max),
|
||||
`Bitte eine ganze Zahl zwischen ${min} und ${max} angeben.`,
|
||||
);
|
||||
|
||||
/** Money arrives as "12,99" or "12.99" and is stored as cents. */
|
||||
export const optionalPriceCents = z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((value) => parseEuroInput(value ?? null))
|
||||
.refine((value) => value === null || value <= 100_000_00, 'Der Betrag ist unrealistisch hoch.');
|
||||
|
||||
export const optionalDate = z
|
||||
.string()
|
||||
.trim()
|
||||
.nullish()
|
||||
.transform((value) => (value && value.length > 0 ? new Date(`${value}T12:00:00`) : null))
|
||||
.refine(
|
||||
(value) => value === null || !Number.isNaN(value.getTime()),
|
||||
'Bitte ein gültiges Datum angeben.',
|
||||
);
|
||||
|
||||
export const checkbox = z
|
||||
.union([z.literal('on'), z.literal('true'), z.literal('false'), z.literal('')])
|
||||
.nullish()
|
||||
.transform((value) => value === 'on' || value === 'true');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Master data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const manufacturerSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(80),
|
||||
website: optionalUrl,
|
||||
});
|
||||
|
||||
export const themeSchema = z.object({
|
||||
manufacturerId: z.string().trim().min(1, 'Bitte einen Hersteller wählen.'),
|
||||
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(80),
|
||||
parentThemeId: optionalId,
|
||||
});
|
||||
|
||||
export const locationSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(80),
|
||||
parentLocationId: optionalId,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
export const setSchema = z.object({
|
||||
manufacturerId: z.string().trim().min(1, 'Bitte einen Hersteller wählen.'),
|
||||
themeId: optionalId,
|
||||
setNumber: z.string().trim().min(1, 'Bitte die Setnummer angeben.').max(40),
|
||||
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(160),
|
||||
year: optionalInt(1930, currentYear + 5),
|
||||
pieceCount: optionalInt(0, 100_000),
|
||||
minifigCount: optionalInt(0, 500),
|
||||
ean: optionalText(20),
|
||||
ageRange: optionalText(20),
|
||||
imageUrl: optionalUrl,
|
||||
retailPriceCents: optionalPriceCents,
|
||||
notes: optionalText(2000),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const collectionStatusEnum = z.enum([
|
||||
'owned',
|
||||
'built',
|
||||
'disassembled',
|
||||
'lent_out',
|
||||
'sold',
|
||||
]);
|
||||
|
||||
export const completenessEnum = z.enum(['complete', 'missing_parts', 'unknown']);
|
||||
|
||||
export const conditionEnum = z.enum(['new_sealed', 'like_new', 'good', 'used', 'damaged']);
|
||||
|
||||
export const collectionItemSchema = z.object({
|
||||
setId: z.string().trim().min(1),
|
||||
status: collectionStatusEnum.default('owned'),
|
||||
completeness: completenessEnum.default('unknown'),
|
||||
condition: z
|
||||
.string()
|
||||
.trim()
|
||||
.nullish()
|
||||
.transform((value) => (value && value.length > 0 && value !== 'none' ? value : null))
|
||||
.refine(
|
||||
(value) => value === null || conditionEnum.safeParse(value).success,
|
||||
'Unbekannter Zustand.',
|
||||
),
|
||||
locationId: optionalId,
|
||||
acquiredAt: optionalDate,
|
||||
acquiredPriceCents: optionalPriceCents,
|
||||
acquiredFrom: optionalText(120),
|
||||
giftedBy: optionalText(120),
|
||||
lentOutTo: optionalText(120),
|
||||
notes: optionalText(2000),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wishlists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const occasionEnum = z.enum(['christmas', 'birthday', 'easter', 'other']);
|
||||
|
||||
export const wishlistSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80),
|
||||
occasion: occasionEnum.default('other'),
|
||||
eventDate: optionalDate,
|
||||
note: optionalText(1000),
|
||||
isPublic: checkbox,
|
||||
surpriseMode: checkbox,
|
||||
});
|
||||
|
||||
export const wishlistItemSchema = z.object({
|
||||
priority: z.coerce.number().int().min(1).max(3).default(2),
|
||||
note: optionalText(500),
|
||||
});
|
||||
|
||||
/** Bulk add from the "fehlende Sets" selection. */
|
||||
export const addSetsToWishlistSchema = z.object({
|
||||
wishlistId: z.string().trim().min(1, 'Bitte eine Wunschliste wählen.'),
|
||||
setIds: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens ein Set auswählen.'),
|
||||
priority: z.coerce.number().int().min(1).max(3).default(2),
|
||||
});
|
||||
|
||||
export const reservationSchema = z.object({
|
||||
itemId: z.string().trim().min(1),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Bitte trag deinen Namen ein, damit klar ist, wer das Geschenk übernimmt.')
|
||||
.max(60),
|
||||
});
|
||||
|
||||
export type ManufacturerInput = z.infer<typeof manufacturerSchema>;
|
||||
export type ThemeInput = z.infer<typeof themeSchema>;
|
||||
export type LocationInput = z.infer<typeof locationSchema>;
|
||||
export type SetInput = z.infer<typeof setSchema>;
|
||||
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
|
||||
export type WishlistInput = z.infer<typeof wishlistSchema>;
|
||||
Reference in New Issue
Block a user