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:
@@ -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