feat(validation): complete validation editor and master data modules
This commit is contained in:
parent
2b5c765e41
commit
f73a24df13
73 changed files with 10194 additions and 0 deletions
72
validation-suite/frontend/atlas/components/app-shell.tsx
Normal file
72
validation-suite/frontend/atlas/components/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
validation-suite/frontend/atlas/components/auth.tsx
Normal file
51
validation-suite/frontend/atlas/components/auth.tsx
Normal 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;
|
||||
}
|
||||
|
||||
195
validation-suite/frontend/atlas/components/crud-page.tsx
Normal file
195
validation-suite/frontend/atlas/components/crud-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
validation-suite/frontend/atlas/components/data-table.tsx
Normal file
44
validation-suite/frontend/atlas/components/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
50
validation-suite/frontend/atlas/components/resource-page.tsx
Normal file
50
validation-suite/frontend/atlas/components/resource-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue