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,72 @@
"use client";
import {
Building2,
ClipboardCheck,
FileArchive,
Gauge,
LayoutDashboard,
LogOut,
MapPin,
Stethoscope,
UserRound
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/components/auth";
const navigation = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/customers", label: "Kunden", icon: Building2 },
{ href: "/locations", label: "Standorte", icon: MapPin },
{ href: "/contacts", label: "Ansprechpartner", icon: UserRound },
{ href: "/devices", label: "Geraete", icon: Stethoscope },
{ href: "/equipment", label: "Pruefmittel", icon: Gauge },
{ href: "/validations", label: "Validierungen", icon: ClipboardCheck },
{ href: "/documents", label: "Dokumente", icon: FileArchive }
];
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const auth = useAuth();
return (
<div className="min-h-screen bg-background text-text">
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
<div className="mb-8">
<div className="text-xl font-semibold text-primary-dark">Validation Suite</div>
<div className="mt-1 text-sm text-text-light">Atlas Workspace</div>
</div>
<nav className="space-y-1">
{navigation.map((item, index) => {
const Icon = item.icon;
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={`${item.label}-${index}`}
href={item.href}
className={`flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition ${
active ? "bg-accent/35 text-primary-dark" : "text-text-light hover:bg-background hover:text-text"
}`}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<button
type="button"
onClick={auth.logout}
className="absolute bottom-6 left-5 right-5 flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-semibold text-white shadow-soft"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</aside>
<main className="lg:pl-72">
<div className="mx-auto min-h-screen max-w-7xl px-4 py-5 sm:px-6 lg:px-10 lg:py-8">{children}</div>
</main>
</div>
);
}

View file

@ -0,0 +1,51 @@
"use client";
import { useRouter } from "next/navigation";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
type AuthContextValue = {
token: string | null;
setToken: (value: string | null) => void;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [token, setTokenState] = useState<string | null>(null);
useEffect(() => {
setTokenState(window.localStorage.getItem("atlas_token"));
}, []);
const value = useMemo<AuthContextValue>(() => {
const setToken = (next: string | null) => {
setTokenState(next);
if (next) {
window.localStorage.setItem("atlas_token", next);
} else {
window.localStorage.removeItem("atlas_token");
}
};
return {
token,
setToken,
logout: () => {
setToken(null);
router.push("/login");
}
};
}, [router, token]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const value = useContext(AuthContext);
if (!value) {
throw new Error("useAuth must be used inside AuthProvider");
}
return value;
}

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>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable
} from "@tanstack/react-table";
export function DataTable<T>({ data, columns }: { data: T[]; columns: ColumnDef<T>[] }) {
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
return (
<div className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
<div className="overflow-x-auto">
<table className="min-w-full border-collapse text-left text-sm">
<thead className="bg-background text-xs uppercase tracking-wide text-text-light">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-5 py-4 font-semibold">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-border">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-background/70">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="whitespace-nowrap px-5 py-4 text-text">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,10 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new QueryClient());
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View file

@ -0,0 +1,50 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ColumnDef } from "@tanstack/react-table";
import { Plus } from "lucide-react";
import { useAuth } from "@/components/auth";
import { DataTable } from "@/components/data-table";
import { apiGet } from "@/lib/api";
export function ResourcePage<T>({
title,
subtitle,
path,
columns
}: {
title: string;
subtitle: string;
path: string;
columns: ColumnDef<T>[];
}) {
const { token } = useAuth();
const query = useQuery({
queryKey: [path, token],
queryFn: () => apiGet<T[]>(path, token ?? ""),
enabled: Boolean(token)
});
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 type="button" className="inline-flex h-12 items-center justify-center gap-2 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft">
<Plus className="h-5 w-5" />
Neu
</button>
</header>
{query.isLoading ? (
<div className="rounded-lg border border-border bg-surface p-8 text-text-light shadow-soft">Daten werden geladen.</div>
) : query.isError ? (
<div className="rounded-lg border border-danger/30 bg-surface p-8 text-danger shadow-soft">Daten konnten nicht geladen werden.</div>
) : (
<DataTable data={query.data ?? []} columns={columns} />
)}
</div>
);
}