feat(knowledge): add service library
This commit is contained in:
parent
ecab0fe6b6
commit
228da8f814
32 changed files with 2878 additions and 6 deletions
61
frontend/athena/app/knowledge/documents/[id]/page.tsx
Normal file
61
frontend/athena/app/knowledge/documents/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Download, ExternalLink } 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 { KnowledgeDocument } from "@/types/knowledge";
|
||||
|
||||
function fileSize(value: number) {
|
||||
if (!value) return "-";
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function KnowledgeDocumentDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const [document, setDocument] = useState<KnowledgeDocument | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.get<KnowledgeDocument>(`/knowledge/documents/${params.id}`)
|
||||
.then((response) => setDocument(response.data))
|
||||
.catch(() => setError("Dokument konnte nicht geladen werden"));
|
||||
}, [params.id]);
|
||||
|
||||
if (error) return <p className="text-red-600">{error}</p>;
|
||||
if (!document) return <p className="text-slate-500">Dokument wird geladen...</p>;
|
||||
|
||||
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">{document.title}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{document.manufacturer.name}{document.device ? ` · ${document.device.name}` : ""}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{document.external_url && <Link href={document.external_url} target="_blank" className={buttonVariants({ variant: "outline" })}><ExternalLink size={16} />Extern</Link>}
|
||||
{document.file_name && <Link href={`/api/knowledge/documents/${document.id}/download`} className={buttonVariants()}><Download size={16} />Download</Link>}
|
||||
</div>
|
||||
</div>
|
||||
<DetailSection title="Dokument">
|
||||
<dl className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<div><dt className="text-slate-500">Typ</dt><dd className="mt-1"><DocumentTypeBadge type={document.document_type} /></dd></div>
|
||||
<div><dt className="text-slate-500">Sprache</dt><dd className="font-medium">{document.language}</dd></div>
|
||||
<div><dt className="text-slate-500">Dateiname</dt><dd className="font-medium">{document.file_name || "-"}</dd></div>
|
||||
<div><dt className="text-slate-500">Dateigröße</dt><dd className="font-medium">{fileSize(document.file_size)}</dd></div>
|
||||
<div><dt className="text-slate-500">SHA-256</dt><dd className="break-all font-medium">{document.checksum_sha256 || "-"}</dd></div>
|
||||
<div><dt className="text-slate-500">Paperless-ID</dt><dd className="font-medium">{document.paperless_document_id || "-"}</dd></div>
|
||||
</dl>
|
||||
<div className="mt-4"><TagList tags={document.tags} /></div>
|
||||
{document.description && <p className="mt-4 whitespace-pre-wrap text-sm text-slate-700">{document.description}</p>}
|
||||
</DetailSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
frontend/athena/app/knowledge/documents/page.tsx
Normal file
184
frontend/athena/app/knowledge/documents/page.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Download, 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 DocumentTypeBadge from "@/components/knowledge/DocumentTypeBadge";
|
||||
import { DocumentUploadDialog } from "@/components/knowledge/KnowledgeForms";
|
||||
import TagList from "@/components/knowledge/TagList";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import type { DocumentPayload, DocumentType, KnowledgeDevice, KnowledgeDocument, 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";
|
||||
}
|
||||
|
||||
function fileSize(value: number) {
|
||||
if (!value) return "-";
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function KnowledgeDocumentsPage() {
|
||||
const { showToast } = useToast();
|
||||
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
|
||||
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
|
||||
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
|
||||
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 [typeFilter, setTypeFilter] = useState<DocumentType | "all">("all");
|
||||
const [sortKey, setSortKey] = useState("created_at");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<KnowledgeDocument | null>(null);
|
||||
const [deleteItem, setDeleteItem] = useState<KnowledgeDocument | null>(null);
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [manufacturerResponse, deviceResponse, documentResponse] = await Promise.all([
|
||||
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
|
||||
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
|
||||
api.get<KnowledgeDocument[]>("/knowledge/documents?limit=100"),
|
||||
]);
|
||||
setManufacturers(manufacturerResponse.data);
|
||||
setDevices(deviceResponse.data);
|
||||
setDocuments(documentResponse.data);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadItems();
|
||||
});
|
||||
}, [loadItems]);
|
||||
|
||||
const rows = useMemo(() => documents
|
||||
.filter((item) => manufacturerFilter === "all" || item.manufacturer_id === Number(manufacturerFilter))
|
||||
.filter((item) => typeFilter === "all" || item.document_type === typeFilter)
|
||||
.filter((item) => [item.title, item.manufacturer.name, item.device?.name ?? "", ...item.tags].some((value) => value.toLowerCase().includes(search.toLowerCase())))
|
||||
.sort((a, b) => {
|
||||
const av = sortKey === "manufacturer" ? a.manufacturer.name : String(a[sortKey as keyof KnowledgeDocument] ?? "");
|
||||
const bv = sortKey === "manufacturer" ? b.manufacturer.name : String(b[sortKey as keyof KnowledgeDocument] ?? "");
|
||||
const result = av.toLowerCase() > bv.toLowerCase() ? 1 : av.toLowerCase() < bv.toLowerCase() ? -1 : 0;
|
||||
return sortDirection === "asc" ? result : -result;
|
||||
}), [documents, manufacturerFilter, search, sortDirection, sortKey, typeFilter]);
|
||||
|
||||
const columns: DataTableColumn<KnowledgeDocument>[] = [
|
||||
{ key: "title", label: "Titel", sortable: true, render: (item) => <span className="font-medium">{item.title}</span> },
|
||||
{ key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer.name },
|
||||
{ key: "device", label: "Gerät", render: (item) => item.device?.name ?? "-" },
|
||||
{ key: "document_type", label: "Typ", sortable: true, render: (item) => <DocumentTypeBadge type={item.document_type} /> },
|
||||
{ key: "language", label: "Sprache", sortable: true, render: (item) => item.language },
|
||||
{ key: "file_size", label: "Größe", render: (item) => fileSize(item.file_size) },
|
||||
{ key: "tags", label: "Tags", render: (item) => <TagList tags={item.tags} /> },
|
||||
{
|
||||
key: "actions",
|
||||
label: "Aktionen",
|
||||
className: "px-4 py-3 text-right",
|
||||
render: (item) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/knowledge/documents/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
|
||||
{item.file_name && <Link href={`/api/knowledge/documents/${item.id}/download`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Download"><Download 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: DocumentPayload, file: File | null) {
|
||||
setPending(true);
|
||||
setFormError("");
|
||||
try {
|
||||
if (selected) {
|
||||
await api.put(`/knowledge/documents/${selected.id}`, payload);
|
||||
showToast({ type: "success", title: "Dokument aktualisiert", description: payload.title });
|
||||
} else {
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (key === "tags") formData.append(key, (value as string[]).join(","));
|
||||
else if (value !== null && value !== undefined) formData.append(key, String(value));
|
||||
});
|
||||
formData.append("file", file);
|
||||
await api.post("/knowledge/documents/upload", formData);
|
||||
showToast({ type: "success", title: "Dokument hochgeladen", description: payload.title });
|
||||
}
|
||||
setFormOpen(false);
|
||||
await loadItems();
|
||||
} catch (err) {
|
||||
const message = errorMessage(err);
|
||||
setFormError(message);
|
||||
showToast({ type: "error", title: "Dokument konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteItem) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await api.delete(`/knowledge/documents/${deleteItem.id}`);
|
||||
showToast({ type: "success", title: "Dokument gelöscht", description: deleteItem.title });
|
||||
setDeleteItem(null);
|
||||
await loadItems();
|
||||
} catch (err) {
|
||||
showToast({ type: "error", title: "Dokument 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">Dokumente</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Unterlagen</p></div>
|
||||
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={manufacturers.length === 0}><Plus size={16} />Upload</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="Dokumente 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>
|
||||
<select className="h-8 rounded-lg border px-2 text-sm" value={typeFilter} onChange={(event) => setTypeFilter(event.target.value as DocumentType | "all")}>
|
||||
<option value="all">Alle Typen</option>
|
||||
<option value="manual">Bedienungsanleitung</option>
|
||||
<option value="service_manual">Service Manual</option>
|
||||
<option value="schematic">Schaltplan</option>
|
||||
<option value="alignment">Abgleich</option>
|
||||
<option value="parts_list">Ersatzteile</option>
|
||||
<option value="firmware">Firmware</option>
|
||||
<option value="datasheet">Datenblatt</option>
|
||||
<option value="service_bulletin">Bulletin</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<DataTable columns={columns} rows={rows} rowKey={(item) => item.id} sortKey={sortKey} sortDirection={sortDirection} loading={loading} error={error} emptyTitle="Keine Dokumente" emptyDescription="Lade das erste Service-Dokument hoch." onSort={(key) => { setSortKey(key); setSortDirection((current) => sortKey === key && current === "asc" ? "desc" : "asc"); }} />
|
||||
<DocumentUploadDialog open={formOpen} document={selected} manufacturers={manufacturers} devices={devices} pending={pending} error={formError} onOpenChange={setFormOpen} onSubmit={save} />
|
||||
<ConfirmDialog open={Boolean(deleteItem)} title="Dokument löschen" description="Der Datenbankeintrag wird entfernt. Lokal gespeicherte Dateien bleiben für Nachvollziehbarkeit erhalten." pending={pending} onOpenChange={(open) => !open && setDeleteItem(null)} onConfirm={confirmDelete}>
|
||||
{deleteItem && <p className="text-sm font-medium">{deleteItem.title}</p>}
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue