diff --git a/package.json b/package.json index a1bcbe5..4cc0ebf 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,71 @@ -{ - "name": "bricktrack", - "version": "0.1.0", - "private": true, - "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", - "packageManager": "pnpm@10.34.5", - "engines": { - "node": ">=22" - }, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint .", - "typecheck": "tsc --noEmit", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx scripts/migrate.ts", - "db:seed": "tsx scripts/seed.ts", - "db:studio": "drizzle-kit studio", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e": "playwright test", - "screenshots": "playwright test --config=playwright.screenshots.config.ts" - }, - "dependencies": { - "@better-auth/passkey": "^1.7.3", - "better-auth": "^1.7.3", - "better-sqlite3": "^13.0.3", - "class-variance-authority": "^0.7.1", - "cn": "^0.2.6", - "date-fns": "^4.4.0", - "drizzle-orm": "^0.45.2", - "lucide-react": "^1.41.0", - "next": "15.5.25", - "next-themes": "^0.4.6", - "radix-ui": "^1.6.7", - "react": "19.1.0", - "react-dom": "19.1.0", - "sharp": "^0.35.4", - "sonner": "^2.0.8", - "tw-animate-css": "^1.4.0", - "zod": "^4.5.4" - }, - "devDependencies": { - "@eslint/eslintrc": "^3", - "@playwright/test": "^1.63.0", - "@tailwindcss/postcss": "^4", - "@types/better-sqlite3": "^9.6.0", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "dotenv": "^17.4.2", - "drizzle-kit": "^0.31.10", - "eslint": "^9", - "eslint-config-next": "15.5.25", - "shadcn": "^4.21.0", - "tailwindcss": "^4", - "tsx": "^4.23.13", - "typescript": "^5", - "vitest": "^5.0.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "better-sqlite3", - "esbuild", - "sharp", - "unrs-resolver" - ] - } -} +{ + "name": "bricktrack", + "version": "0.1.0", + "private": true, + "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx scripts/migrate.ts", + "db:seed": "tsx scripts/seed.ts", + "db:studio": "drizzle-kit studio", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "screenshots": "playwright test --config=playwright.screenshots.config.ts" + }, + "dependencies": { + "@better-auth/passkey": "^1.7.3", + "better-auth": "^1.7.3", + "better-sqlite3": "^13.0.3", + "class-variance-authority": "^0.7.1", + "cn": "^0.2.6", + "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.41.0", + "next": "15.5.25", + "next-themes": "^0.4.6", + "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", + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.63.0", + "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", + "eslint": "^9", + "eslint-config-next": "15.5.25", + "shadcn": "^4.21.0", + "tailwindcss": "^4", + "tsx": "^4.23.13", + "typescript": "^5", + "vitest": "^5.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3", + "esbuild", + "sharp", + "unrs-resolver" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3989d8..d200f5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/app/(app)/verwaltung/actions.ts b/src/app/(app)/verwaltung/actions.ts new file mode 100644 index 0000000..8751fe9 --- /dev/null +++ b/src/app/(app)/verwaltung/actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/src/app/(app)/verwaltung/hersteller/page.tsx b/src/app/(app)/verwaltung/hersteller/page.tsx new file mode 100644 index 0000000..e6f8057 --- /dev/null +++ b/src/app/(app)/verwaltung/hersteller/page.tsx @@ -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 ; +} diff --git a/src/app/(app)/verwaltung/layout.tsx b/src/app/(app)/verwaltung/layout.tsx new file mode 100644 index 0000000..7a57af8 --- /dev/null +++ b/src/app/(app)/verwaltung/layout.tsx @@ -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 ( +
+
+

Verwaltung

+

+ Stammdaten, die im Katalog und in der Sammlung ausgewählt werden. +

+
+ + {children} +
+ ); +} diff --git a/src/app/(app)/verwaltung/standorte/page.tsx b/src/app/(app)/verwaltung/standorte/page.tsx new file mode 100644 index 0000000..36c97e8 --- /dev/null +++ b/src/app/(app)/verwaltung/standorte/page.tsx @@ -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 ; +} diff --git a/src/app/(app)/verwaltung/themen/page.tsx b/src/app/(app)/verwaltung/themen/page.tsx new file mode 100644 index 0000000..9bccbb0 --- /dev/null +++ b/src/app/(app)/verwaltung/themen/page.tsx @@ -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 ( +

+ Themen gehören immer zu einem Hersteller. Leg zuerst einen Hersteller an. +

+ ); + } + + return ; +} diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 0000000..84b9414 --- /dev/null +++ b/src/components/ui/field.tsx @@ -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 ( +
+ + {children} + {hint ?

{hint}

: null} +
+ ); +} diff --git a/src/components/ui/native-select.tsx b/src/components/ui/native-select.tsx new file mode 100644 index 0000000..1e9d4cd --- /dev/null +++ b/src/components/ui/native-select.tsx @@ -0,0 +1,23 @@ +import { cn } from 'cn'; + +/** + * A plain + ); +} diff --git a/src/components/verwaltung/crud-section.tsx b/src/components/verwaltung/crud-section.tsx new file mode 100644 index 0000000..2307805 --- /dev/null +++ b/src/components/verwaltung/crud-section.tsx @@ -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 = { + 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) => Promise; + onUpdate: (id: string, input: Record) => Promise; + onDelete: (id: string) => Promise; + 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({ + items, + emptyText, + createLabel, + dialogTitle, + dialogDescription, + renderFields, + onCreate, + onUpdate, + onDelete, + deleteWarning, +}: Props) { + const router = useRouter(); + const [editing, setEditing] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [deleting, setDeleting] = useState(null); + const [pending, startTransition] = useTransition(); + + function openCreate() { + setEditing(null); + setDialogOpen(true); + } + + function openEdit(item: T) { + setEditing(item); + setDialogOpen(true); + } + + function submit(event: React.FormEvent) { + 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 ( +
+ + + {items.length === 0 ? ( +

+ {emptyText} +

+ ) : ( +
    + {items.map((item) => ( +
  • +
    +

    {item.title}

    + {item.subtitle ? ( +

    {item.subtitle}

    + ) : null} +
    + + +
  • + ))} +
+ )} + + { + setDialogOpen(open); + if (!open) setEditing(null); + }} + > + + + {dialogTitle(editing)} + {dialogDescription ? ( + {dialogDescription} + ) : null} + + {/* Remounting on change resets defaultValue-based fields. */} +
+ {renderFields(editing)} +
+ + + + +
+
+ + !open && setDeleting(null)}> + + + Wirklich löschen? + + {deleting ? deleteWarning(deleting) : ''} + + + + Abbrechen + Löschen + + + +
+ ); +} diff --git a/src/components/verwaltung/location-list.tsx b/src/components/verwaltung/location-list.tsx new file mode 100644 index 0000000..c0cc7bb --- /dev/null +++ b/src/components/verwaltung/location-list.tsx @@ -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 ( + + 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) => ( + <> + + + + + + + {locations + .filter((candidate) => candidate.id !== item?.id) + .map((candidate) => ( + + ))} + + + + )} + /> + ); +} diff --git a/src/components/verwaltung/manufacturer-list.tsx b/src/components/verwaltung/manufacturer-list.tsx new file mode 100644 index 0000000..1eacb7d --- /dev/null +++ b/src/components/verwaltung/manufacturer-list.tsx @@ -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 ( + + 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) => ( + <> + + + + + + + + )} + /> + ); +} diff --git a/src/components/verwaltung/theme-list.tsx b/src/components/verwaltung/theme-list.tsx new file mode 100644 index 0000000..1c15b02 --- /dev/null +++ b/src/components/verwaltung/theme-list.tsx @@ -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 ( + + 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) => ( + <> + + + {manufacturers.map((manufacturer) => ( + + ))} + + + + + + + + + {themes + .filter((candidate) => candidate.id !== item?.id) + .map((candidate) => ( + + ))} + + + + )} + /> + ); +} diff --git a/src/components/verwaltung/verwaltung-nav.tsx b/src/components/verwaltung/verwaltung-nav.tsx new file mode 100644 index 0000000..fd83275 --- /dev/null +++ b/src/components/verwaltung/verwaltung-nav.tsx @@ -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 ( + + ); +} diff --git a/src/lib/action-result.ts b/src/lib/action-result.ts new file mode 100644 index 0000000..32d51cb --- /dev/null +++ b/src/lib/action-result.ts @@ -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 = + | ({ ok: true } & (T extends undefined ? { data?: undefined } : { data: T })) + | { ok: false; error: string; fieldErrors?: Record }; + +export function actionError(error: string, fieldErrors?: Record) { + return { ok: false as const, error, fieldErrors }; +} + +export function actionOk(): { ok: true } { + return { ok: true }; +} + +export function actionData(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 { + const result: Record = {}; + + 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: Schema, + input: unknown, +): { ok: true; data: z.output } | { ok: false; error: string; fieldErrors: Record } { + const parsed = schema.safeParse(input); + if (parsed.success) return { ok: true, data: parsed.data }; + + const fieldErrors: Record = {}; + 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, + }; +} diff --git a/src/lib/queries/master-data.ts b/src/lib/queries/master-data.ts new file mode 100644 index 0000000..d4d902c --- /dev/null +++ b/src/lib/queries/master-data.ts @@ -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`( + 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`( + 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[number]; +export type ThemeListRow = ReturnType[number]; +export type LocationListRow = ReturnType[number]; diff --git a/src/lib/slug.ts b/src/lib/slug.ts new file mode 100644 index 0000000..1a05d2f --- /dev/null +++ b/src/lib/slug.ts @@ -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 { + 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}`; +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..82306c7 --- /dev/null +++ b/src/lib/validation.ts @@ -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; +export type ThemeInput = z.infer; +export type LocationInput = z.infer; +export type SetInput = z.infer; +export type CollectionItemInput = z.infer; +export type WishlistInput = z.infer;