195 lines
8.5 KiB
TypeScript
195 lines
8.5 KiB
TypeScript
"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>
|
|
);
|
|
}
|