feat(validation): complete validation editor and master data modules

This commit is contained in:
Schubert Ferenc 2026-07-10 22:42:03 +02:00
parent 2b5c765e41
commit f73a24df13
73 changed files with 10194 additions and 0 deletions

View file

@ -0,0 +1,195 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit2, Search, Trash2, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useAuth } from "@/components/auth";
import { apiDelete, apiGet, apiSend, Entity, Paginated } from "@/lib/api";
export type FieldOption = { label: string; value: string };
export type FieldConfig = {
name: string;
label: string;
type?: "text" | "email" | "number" | "date" | "textarea" | "select";
required?: boolean;
options?: FieldOption[];
};
export type ColumnConfig<T> = { key: keyof T; label: string };
function valueForInput(value: unknown) {
return value === null || value === undefined ? "" : String(value);
}
export function CrudPage<T extends Entity>({
title,
subtitle,
endpoint,
columns,
fields,
schema,
emptyValues
}: {
title: string;
subtitle: string;
endpoint: string;
columns: ColumnConfig<T>[];
fields: FieldConfig[];
schema: z.ZodTypeAny;
emptyValues: Record<string, unknown>;
}) {
const { token } = useAuth();
const client = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<T | null>(null);
const [open, setOpen] = useState(false);
const pageSize = 10;
const queryPath = `${endpoint}?page=${page}&page_size=${pageSize}&search=${encodeURIComponent(search)}`;
const form = useForm<Record<string, unknown>>({
resolver: zodResolver(schema),
defaultValues: emptyValues
});
const query = useQuery({
queryKey: [endpoint, page, pageSize, search, token],
queryFn: () => apiGet<Paginated<T>>(queryPath, token ?? ""),
enabled: Boolean(token)
});
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / pageSize));
const invalidate = () => client.invalidateQueries({ queryKey: [endpoint] });
const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => {
const cleaned = Object.fromEntries(
Object.entries(values).map(([key, value]) => [key, value === "" ? null : value])
);
return apiSend<T>(editing ? `${endpoint}/${editing.id}` : endpoint, token ?? "", editing ? "PUT" : "POST", cleaned);
},
onSuccess: () => {
setOpen(false);
setEditing(null);
form.reset(emptyValues);
invalidate();
}
});
const deleteMutation = useMutation({
mutationFn: (item: T) => apiDelete(`${endpoint}/${item.id}`, token ?? ""),
onSuccess: invalidate
});
const rows = useMemo(() => query.data?.items ?? [], [query.data]);
function startCreate() {
setEditing(null);
form.reset(emptyValues);
setOpen(true);
}
function startEdit(item: T) {
setEditing(item);
form.reset(Object.fromEntries(Object.keys(emptyValues).map((key) => [key, valueForInput(item[key as keyof T])])));
setOpen(true);
}
return (
<div className="space-y-6">
<header className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-3xl font-semibold text-text">{title}</h1>
<p className="mt-2 text-text-light">{subtitle}</p>
</div>
<button onClick={startCreate} className="h-12 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft">Neu</button>
</header>
<div className="flex items-center gap-3 rounded-lg border border-border bg-surface px-4 py-3 shadow-soft">
<Search className="h-5 w-5 text-primary" />
<input
value={search}
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
placeholder="Suchen"
className="h-9 flex-1 bg-transparent outline-none"
/>
</div>
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="bg-background text-xs uppercase text-text-light">
<tr>
{columns.map((column) => <th key={String(column.key)} className="px-5 py-4">{column.label}</th>)}
<th className="px-5 py-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((item) => (
<tr key={item.id}>
{columns.map((column) => <td key={String(column.key)} className="whitespace-nowrap px-5 py-4">{valueForInput(item[column.key])}</td>)}
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<button aria-label="Bearbeiten" onClick={() => startEdit(item)} className="rounded-lg border border-border p-2 text-primary-dark"><Edit2 className="h-4 w-4" /></button>
<button aria-label="Loeschen" onClick={() => deleteMutation.mutate(item)} className="rounded-lg border border-border p-2 text-danger"><Trash2 className="h-4 w-4" /></button>
</div>
</td>
</tr>
))}
{!query.isLoading && rows.length === 0 && (
<tr><td colSpan={columns.length + 1} className="px-5 py-10 text-center text-text-light">Keine Datensaetze gefunden.</td></tr>
)}
</tbody>
</table>
</div>
</section>
<footer className="flex items-center justify-between text-sm text-text-light">
<span>{query.data?.total ?? 0} Datensaetze</span>
<div className="flex items-center gap-3">
<button disabled={page <= 1} onClick={() => setPage((value) => value - 1)} className="rounded-lg border border-border px-4 py-2 disabled:opacity-40">Zurueck</button>
<span>Seite {page} von {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage((value) => value + 1)} className="rounded-lg border border-border px-4 py-2 disabled:opacity-40">Weiter</button>
</div>
</footer>
{open && (
<div className="fixed inset-0 z-50 flex items-end bg-black/20 p-3 sm:items-center sm:justify-center">
<form onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))} className="max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg bg-surface p-6 shadow-soft">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-semibold">{editing ? "Bearbeiten" : "Neu"}</h2>
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border p-2"><X className="h-4 w-4" /></button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => (
<label key={field.name} className={field.type === "textarea" ? "sm:col-span-2" : ""}>
<span className="text-sm font-medium">{field.label}</span>
{field.type === "select" ? (
<select {...form.register(field.name)} className="mt-2 h-11 w-full rounded-lg border border-border px-3">
<option value="">Bitte waehlen</option>
{field.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
) : field.type === "textarea" ? (
<textarea {...form.register(field.name)} className="mt-2 min-h-24 w-full rounded-lg border border-border px-3 py-2" />
) : (
<input type={field.type ?? "text"} {...form.register(field.name)} className="mt-2 h-11 w-full rounded-lg border border-border px-3" />
)}
{form.formState.errors[field.name] && <span className="mt-1 block text-xs text-danger">Bitte gueltig ausfuellen.</span>}
</label>
))}
</div>
{saveMutation.isError && <p className="mt-4 text-sm text-danger">Speichern fehlgeschlagen. Bitte Eingaben pruefen.</p>}
<div className="mt-6 flex justify-end gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border px-5 py-3 font-semibold">Abbrechen</button>
<button type="submit" className="rounded-lg bg-primary px-5 py-3 font-semibold text-white shadow-soft">Speichern</button>
</div>
</form>
</div>
)}
</div>
);
}