feat(knowledge): add service library

This commit is contained in:
DS | Schubert 2026-07-03 10:25:52 +02:00
parent ecab0fe6b6
commit 228da8f814
32 changed files with 2878 additions and 6 deletions

View file

@ -0,0 +1,85 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Download } from "lucide-react";
import DetailSection from "@/components/common/DetailSection";
import DocumentTypeBadge from "@/components/knowledge/DocumentTypeBadge";
import TagList from "@/components/knowledge/TagList";
import { buttonVariants } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { KnowledgeDevice, KnowledgeDocument, KnowledgeNote } from "@/types/knowledge";
export default function KnowledgeDeviceDetailPage() {
const params = useParams<{ id: string }>();
const [device, setDevice] = useState<KnowledgeDevice | null>(null);
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
const [notes, setNotes] = useState<KnowledgeNote[]>([]);
const [error, setError] = useState("");
useEffect(() => {
Promise.all([
api.get<KnowledgeDevice>(`/knowledge/devices/${params.id}`),
api.get<KnowledgeDocument[]>(`/knowledge/documents?device_id=${params.id}&limit=100`),
api.get<KnowledgeNote[]>(`/knowledge/notes?device_id=${params.id}&limit=100`),
]).then(([deviceResponse, documentResponse, noteResponse]) => {
setDevice(deviceResponse.data);
setDocuments(documentResponse.data);
setNotes(noteResponse.data);
}).catch(() => setError("Gerät konnte nicht geladen werden"));
}, [params.id]);
const yearRange = useMemo(() => {
if (!device) return "-";
if (!device.production_year_from && !device.production_year_to) return "-";
return `${device.production_year_from ?? "?"} - ${device.production_year_to ?? "?"}`;
}, [device]);
if (error) return <p className="text-red-600">{error}</p>;
if (!device) return <p className="text-slate-500">Gerät wird geladen...</p>;
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-slate-950">{device.name}</h1>
<p className="mt-1 text-sm text-slate-500">{device.manufacturer.name}{device.model_number ? ` · ${device.model_number}` : ""}</p>
</div>
<DetailSection title="Stammdaten">
<dl className="grid gap-3 text-sm md:grid-cols-2">
<div><dt className="text-slate-500">Gerätetyp</dt><dd className="font-medium">{device.device_type || "-"}</dd></div>
<div><dt className="text-slate-500">Frequenzbereich</dt><dd className="font-medium">{device.frequency_range || "-"}</dd></div>
<div><dt className="text-slate-500">Baujahre</dt><dd className="font-medium">{yearRange}</dd></div>
<div><dt className="text-slate-500">Slug</dt><dd className="font-medium">{device.slug}</dd></div>
</dl>
{device.notes && <p className="mt-4 whitespace-pre-wrap text-sm text-slate-700">{device.notes}</p>}
</DetailSection>
<DetailSection title="Dokumente">
<div className="space-y-3">
{documents.map((document) => (
<div key={document.id} className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<Link href={`/knowledge/documents/${document.id}`} className="font-medium text-slate-950 hover:underline">{document.title}</Link>
<div className="mt-2 flex flex-wrap items-center gap-2"><DocumentTypeBadge type={document.document_type} /><TagList tags={document.tags} /></div>
</div>
{document.file_name && <Link href={`/api/knowledge/documents/${document.id}/download`} className={buttonVariants({ variant: "outline", size: "icon-sm" })} title="Download"><Download size={16} /></Link>}
</div>
))}
{documents.length === 0 && <p className="text-sm text-slate-500">Keine verknüpften Dokumente.</p>}
</div>
</DetailSection>
<DetailSection title="Notizen">
<div className="space-y-3">
{notes.map((note) => (
<div key={note.id} className="rounded-lg border p-3">
<p className="font-medium">{note.title}</p>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-600">{note.content}</p>
</div>
))}
{notes.length === 0 && <p className="text-sm text-slate-500">Keine verknüpften Notizen.</p>}
</div>
</DetailSection>
</div>
);
}

View file

@ -0,0 +1,149 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Edit, Eye, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { DeviceFormDialog } from "@/components/knowledge/KnowledgeForms";
import { Button, buttonVariants } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { DevicePayload, KnowledgeDevice, KnowledgeManufacturer } from "@/types/knowledge";
function errorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
return (error as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Aktion fehlgeschlagen";
}
return "Aktion fehlgeschlagen";
}
export default function KnowledgeDevicesPage() {
const { showToast } = useToast();
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
const [pending, setPending] = useState(false);
const [search, setSearch] = useState("");
const [manufacturerFilter, setManufacturerFilter] = useState("all");
const [sortKey, setSortKey] = useState("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [formOpen, setFormOpen] = useState(false);
const [selected, setSelected] = useState<KnowledgeDevice | null>(null);
const [deleteItem, setDeleteItem] = useState<KnowledgeDevice | null>(null);
const loadItems = useCallback(async () => {
setLoading(true);
try {
const [manufacturerResponse, deviceResponse] = await Promise.all([
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
]);
setManufacturers(manufacturerResponse.data);
setDevices(deviceResponse.data);
setError("");
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadItems();
});
}, [loadItems]);
const rows = useMemo(() => devices
.filter((item) => manufacturerFilter === "all" || item.manufacturer_id === Number(manufacturerFilter))
.filter((item) => [item.name, item.model_number, item.device_type, item.manufacturer.name].some((value) => value.toLowerCase().includes(search.toLowerCase())))
.sort((a, b) => {
const av = sortKey === "manufacturer" ? a.manufacturer.name : String(a[sortKey as keyof KnowledgeDevice] ?? "");
const bv = sortKey === "manufacturer" ? b.manufacturer.name : String(b[sortKey as keyof KnowledgeDevice] ?? "");
const result = av.toLowerCase() > bv.toLowerCase() ? 1 : av.toLowerCase() < bv.toLowerCase() ? -1 : 0;
return sortDirection === "asc" ? result : -result;
}), [devices, manufacturerFilter, search, sortDirection, sortKey]);
const columns: DataTableColumn<KnowledgeDevice>[] = [
{ key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer.name },
{ key: "name", label: "Gerät", sortable: true, render: (item) => <span className="font-medium">{item.name}</span> },
{ key: "model_number", label: "Modell", sortable: true, render: (item) => item.model_number || "-" },
{ key: "device_type", label: "Typ", sortable: true, render: (item) => item.device_type || "-" },
{ key: "frequency_range", label: "Frequenz", render: (item) => item.frequency_range || "-" },
{
key: "actions",
label: "Aktionen",
className: "px-4 py-3 text-right",
render: (item) => (
<div className="flex justify-end gap-2">
<Link href={`/knowledge/devices/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
<Button variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelected(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>
<Button variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteItem(item)}><Trash2 size={16} /></Button>
</div>
),
},
];
async function save(payload: DevicePayload) {
setPending(true);
setFormError("");
try {
if (selected) {
await api.put(`/knowledge/devices/${selected.id}`, payload);
showToast({ type: "success", title: "Gerät aktualisiert", description: payload.name });
} else {
await api.post("/knowledge/devices", payload);
showToast({ type: "success", title: "Gerät erstellt", description: payload.name });
}
setFormOpen(false);
await loadItems();
} catch (err) {
const message = errorMessage(err);
setFormError(message);
showToast({ type: "error", title: "Gerät konnte nicht gespeichert werden", description: message });
} finally {
setPending(false);
}
}
async function confirmDelete() {
if (!deleteItem) return;
setPending(true);
try {
await api.delete(`/knowledge/devices/${deleteItem.id}`);
showToast({ type: "success", title: "Gerät gelöscht", description: deleteItem.name });
setDeleteItem(null);
await loadItems();
} catch (err) {
showToast({ type: "error", title: "Gerät konnte nicht gelöscht werden", description: errorMessage(err) });
} finally {
setPending(false);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div><h1 className="text-3xl font-bold text-slate-950">Geräte</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Modelle</p></div>
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={manufacturers.length === 0}><Plus size={16} />Gerät</Button>
</div>
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row">
<SearchInput value={search} onChange={setSearch} placeholder="Geräte suchen" />
<select className="h-8 rounded-lg border px-2 text-sm" value={manufacturerFilter} onChange={(event) => setManufacturerFilter(event.target.value)}>
<option value="all">Alle Hersteller</option>
{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</div>
<DataTable columns={columns} rows={rows} rowKey={(item) => item.id} sortKey={sortKey} sortDirection={sortDirection} loading={loading} error={error} emptyTitle="Keine Geräte" emptyDescription="Lege zunächst Hersteller und Geräte an." onSort={(key) => { setSortKey(key); setSortDirection((current) => sortKey === key && current === "asc" ? "desc" : "asc"); }} />
<DeviceFormDialog open={formOpen} device={selected} manufacturers={manufacturers} pending={pending} error={formError} onOpenChange={setFormOpen} onSubmit={save} />
<ConfirmDialog open={Boolean(deleteItem)} title="Gerät löschen" description="Dieses Gerät wird gelöscht, sofern keine abhängigen Einträge blockieren." pending={pending} onOpenChange={(open) => !open && setDeleteItem(null)} onConfirm={confirmDelete}>
{deleteItem && <p className="text-sm font-medium">{deleteItem.name}</p>}
</ConfirmDialog>
</div>
);
}