feat(knowledge): add service library
This commit is contained in:
parent
ecab0fe6b6
commit
228da8f814
32 changed files with 2878 additions and 6 deletions
51
frontend/athena/app/api/knowledge/[...path]/route.ts
Normal file
51
frontend/athena/app/api/knowledge/[...path]/route.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
import { proxyHermesRequest, proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
path: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
async function knowledgePath(request: NextRequest, context: Params) {
|
||||
const { path } = await context.params;
|
||||
return `/knowledge/${path.join("/")}${request.nextUrl.search}`;
|
||||
}
|
||||
|
||||
function isDownloadPath(path: string) {
|
||||
return /\/documents\/\d+\/download(?:\?|$)/.test(path);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
const path = await knowledgePath(request, context);
|
||||
if (isDownloadPath(path)) {
|
||||
return proxyHermesStreamRequest(request, path);
|
||||
}
|
||||
return proxyHermesRequest(request, path);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
return proxyHermesRequest(request, await knowledgePath(request, context));
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
return proxyHermesRequest(request, await knowledgePath(request, context));
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
return proxyHermesRequest(request, await knowledgePath(request, context));
|
||||
}
|
||||
85
frontend/athena/app/knowledge/devices/[id]/page.tsx
Normal file
85
frontend/athena/app/knowledge/devices/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
frontend/athena/app/knowledge/devices/page.tsx
Normal file
149
frontend/athena/app/knowledge/devices/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
132
frontend/athena/app/knowledge/manufacturers/page.tsx
Normal file
132
frontend/athena/app/knowledge/manufacturers/page.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Edit, 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 { ManufacturerFormDialog } from "@/components/knowledge/KnowledgeForms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import type { KnowledgeManufacturer, ManufacturerPayload } 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 KnowledgeManufacturersPage() {
|
||||
const { showToast } = useToast();
|
||||
const [items, setItems] = 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 [sortKey, setSortKey] = useState("name");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<KnowledgeManufacturer | null>(null);
|
||||
const [deleteItem, setDeleteItem] = useState<KnowledgeManufacturer | null>(null);
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100");
|
||||
setItems(response.data);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadItems();
|
||||
});
|
||||
}, [loadItems]);
|
||||
|
||||
const rows = useMemo(() => items
|
||||
.filter((item) => item.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const av = String(a[sortKey as keyof KnowledgeManufacturer] ?? "").toLowerCase();
|
||||
const bv = String(b[sortKey as keyof KnowledgeManufacturer] ?? "").toLowerCase();
|
||||
const result = av > bv ? 1 : av < bv ? -1 : 0;
|
||||
return sortDirection === "asc" ? result : -result;
|
||||
}), [items, search, sortDirection, sortKey]);
|
||||
|
||||
const columns: DataTableColumn<KnowledgeManufacturer>[] = [
|
||||
{ key: "name", label: "Name", sortable: true, render: (item) => <span className="font-medium">{item.name}</span> },
|
||||
{ key: "website", label: "Website", sortable: true, render: (item) => item.website || "-" },
|
||||
{ key: "notes", label: "Notizen", render: (item) => item.notes || "-" },
|
||||
{
|
||||
key: "actions",
|
||||
label: "Aktionen",
|
||||
className: "px-4 py-3 text-right",
|
||||
render: (item) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<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: ManufacturerPayload) {
|
||||
setPending(true);
|
||||
setFormError("");
|
||||
try {
|
||||
if (selected) {
|
||||
await api.put(`/knowledge/manufacturers/${selected.id}`, payload);
|
||||
showToast({ type: "success", title: "Hersteller aktualisiert", description: payload.name });
|
||||
} else {
|
||||
await api.post("/knowledge/manufacturers", payload);
|
||||
showToast({ type: "success", title: "Hersteller erstellt", description: payload.name });
|
||||
}
|
||||
setFormOpen(false);
|
||||
await loadItems();
|
||||
} catch (err) {
|
||||
const message = errorMessage(err);
|
||||
setFormError(message);
|
||||
showToast({ type: "error", title: "Hersteller konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteItem) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await api.delete(`/knowledge/manufacturers/${deleteItem.id}`);
|
||||
showToast({ type: "success", title: "Hersteller gelöscht", description: deleteItem.name });
|
||||
setDeleteItem(null);
|
||||
await loadItems();
|
||||
} catch (err) {
|
||||
showToast({ type: "error", title: "Hersteller 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">Hersteller</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Einträge</p></div>
|
||||
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Hersteller</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-4"><SearchInput value={search} onChange={setSearch} placeholder="Hersteller suchen" /></div>
|
||||
<DataTable columns={columns} rows={rows} rowKey={(item) => item.id} sortKey={sortKey} sortDirection={sortDirection} loading={loading} error={error} emptyTitle="Keine Hersteller" emptyDescription="Lege den ersten Hersteller an." onSort={(key) => { setSortKey(key); setSortDirection((current) => sortKey === key && current === "asc" ? "desc" : "asc"); }} />
|
||||
<ManufacturerFormDialog open={formOpen} manufacturer={selected} pending={pending} error={formError} onOpenChange={setFormOpen} onSubmit={save} />
|
||||
<ConfirmDialog open={Boolean(deleteItem)} title="Hersteller löschen" description="Dieser Hersteller wird gelöscht, sofern keine abhängigen Dokumente bestehen." pending={pending} onOpenChange={(open) => !open && setDeleteItem(null)} onConfirm={confirmDelete}>
|
||||
{deleteItem && <p className="text-sm font-medium">{deleteItem.name}</p>}
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
frontend/athena/app/knowledge/notes/page.tsx
Normal file
152
frontend/athena/app/knowledge/notes/page.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Edit, 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 { KnowledgeNoteFormDialog } from "@/components/knowledge/KnowledgeForms";
|
||||
import TagList from "@/components/knowledge/TagList";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import type { KnowledgeDevice, KnowledgeManufacturer, KnowledgeNote, NotePayload } 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";
|
||||
}
|
||||
|
||||
const noteTypeLabels: Record<string, string> = {
|
||||
repair: "Reparaturhinweis",
|
||||
known_fault: "Bekannter Fehler",
|
||||
alignment: "Abgleichhinweis",
|
||||
spare_part: "Ersatzteilhinweis",
|
||||
general: "Allgemein",
|
||||
};
|
||||
|
||||
export default function KnowledgeNotesPage() {
|
||||
const { showToast } = useToast();
|
||||
const [notes, setNotes] = useState<KnowledgeNote[]>([]);
|
||||
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 [sortKey, setSortKey] = useState("created_at");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<KnowledgeNote | null>(null);
|
||||
const [deleteItem, setDeleteItem] = useState<KnowledgeNote | null>(null);
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [manufacturerResponse, deviceResponse, noteResponse] = await Promise.all([
|
||||
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
|
||||
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
|
||||
api.get<KnowledgeNote[]>("/knowledge/notes?limit=100"),
|
||||
]);
|
||||
setManufacturers(manufacturerResponse.data);
|
||||
setDevices(deviceResponse.data);
|
||||
setNotes(noteResponse.data);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadItems();
|
||||
});
|
||||
}, [loadItems]);
|
||||
|
||||
const rows = useMemo(() => notes
|
||||
.filter((item) => [item.title, item.content, item.device?.name ?? "", item.manufacturer?.name ?? "", ...item.tags].some((value) => value.toLowerCase().includes(search.toLowerCase())))
|
||||
.sort((a, b) => {
|
||||
const av = String(a[sortKey as keyof KnowledgeNote] ?? "");
|
||||
const bv = String(b[sortKey as keyof KnowledgeNote] ?? "");
|
||||
const result = av.toLowerCase() > bv.toLowerCase() ? 1 : av.toLowerCase() < bv.toLowerCase() ? -1 : 0;
|
||||
return sortDirection === "asc" ? result : -result;
|
||||
}), [notes, search, sortDirection, sortKey]);
|
||||
|
||||
const columns: DataTableColumn<KnowledgeNote>[] = [
|
||||
{ key: "title", label: "Titel", sortable: true, render: (item) => <span className="font-medium">{item.title}</span> },
|
||||
{ key: "note_type", label: "Typ", sortable: true, render: (item) => noteTypeLabels[item.note_type] ?? item.note_type },
|
||||
{ key: "manufacturer", label: "Hersteller", render: (item) => item.manufacturer?.name ?? "-" },
|
||||
{ key: "device", label: "Gerät", render: (item) => item.device?.name ?? "-" },
|
||||
{ key: "severity", label: "Schweregrad", sortable: true, render: (item) => item.severity || "-" },
|
||||
{ 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">
|
||||
<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: NotePayload) {
|
||||
setPending(true);
|
||||
setFormError("");
|
||||
try {
|
||||
if (selected) {
|
||||
await api.put(`/knowledge/notes/${selected.id}`, payload);
|
||||
showToast({ type: "success", title: "Notiz aktualisiert", description: payload.title });
|
||||
} else {
|
||||
await api.post("/knowledge/notes", payload);
|
||||
showToast({ type: "success", title: "Notiz erstellt", description: payload.title });
|
||||
}
|
||||
setFormOpen(false);
|
||||
await loadItems();
|
||||
} catch (err) {
|
||||
const message = errorMessage(err);
|
||||
setFormError(message);
|
||||
showToast({ type: "error", title: "Notiz konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteItem) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await api.delete(`/knowledge/notes/${deleteItem.id}`);
|
||||
showToast({ type: "success", title: "Notiz gelöscht", description: deleteItem.title });
|
||||
setDeleteItem(null);
|
||||
await loadItems();
|
||||
} catch (err) {
|
||||
showToast({ type: "error", title: "Notiz 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">Knowledge Notes</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Reparatur- und Servicehinweise</p></div>
|
||||
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Notiz</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-4"><SearchInput value={search} onChange={setSearch} placeholder="Notizen suchen" /></div>
|
||||
<DataTable columns={columns} rows={rows} rowKey={(item) => item.id} sortKey={sortKey} sortDirection={sortDirection} loading={loading} error={error} emptyTitle="Keine Notizen" emptyDescription="Erstelle den ersten Reparaturhinweis." onSort={(key) => { setSortKey(key); setSortDirection((current) => sortKey === key && current === "asc" ? "desc" : "asc"); }} />
|
||||
<KnowledgeNoteFormDialog open={formOpen} note={selected} manufacturers={manufacturers} devices={devices} pending={pending} error={formError} onOpenChange={setFormOpen} onSubmit={save} />
|
||||
<ConfirmDialog open={Boolean(deleteItem)} title="Notiz löschen" description="Diese Wissensnotiz wird dauerhaft gelöscht." pending={pending} onOpenChange={(open) => !open && setDeleteItem(null)} onConfirm={confirmDelete}>
|
||||
{deleteItem && <p className="text-sm font-medium">{deleteItem.title}</p>}
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
frontend/athena/app/knowledge/page.tsx
Normal file
121
frontend/athena/app/knowledge/page.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { BookOpen, Cpu, Factory, FileText, StickyNote } from "lucide-react";
|
||||
|
||||
import SearchInput from "@/components/common/SearchInput";
|
||||
import SummaryCard from "@/components/common/SummaryCard";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import type { KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, KnowledgeNote, KnowledgeSearchResult } from "@/types/knowledge";
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
|
||||
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
|
||||
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
|
||||
const [notes, setNotes] = useState<KnowledgeNote[]>([]);
|
||||
const [results, setResults] = useState<KnowledgeSearchResult[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
Promise.all([
|
||||
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
|
||||
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
|
||||
api.get<KnowledgeDocument[]>("/knowledge/documents?limit=10"),
|
||||
api.get<KnowledgeNote[]>("/knowledge/notes?limit=10"),
|
||||
]).then(([manufacturerResponse, deviceResponse, documentResponse, noteResponse]) => {
|
||||
setManufacturers(manufacturerResponse.data);
|
||||
setDevices(deviceResponse.data);
|
||||
setDocuments(documentResponse.data);
|
||||
setNotes(noteResponse.data);
|
||||
}).catch(() => setError("Wissensdatenbank konnte nicht geladen werden"));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
queueMicrotask(() => setResults([]));
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(() => {
|
||||
api.get<KnowledgeSearchResult[]>(`/knowledge/search?q=${encodeURIComponent(trimmed)}`)
|
||||
.then((response) => setResults(response.data))
|
||||
.catch(() => setResults([]));
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [query]);
|
||||
|
||||
const latestDocuments = useMemo(() => documents.slice(0, 5), [documents]);
|
||||
const latestNotes = useMemo(() => notes.slice(0, 5), [notes]);
|
||||
|
||||
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">Wissensdatenbank</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Serviceunterlagen, Modelle, Schaltpläne und Reparaturwissen</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/manufacturers"><Factory size={16} />Hersteller</Link>
|
||||
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/devices"><Cpu size={16} />Geräte</Link>
|
||||
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/documents"><FileText size={16} />Dokumente</Link>
|
||||
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/notes"><StickyNote size={16} />Notizen</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<SummaryCard label="Hersteller" value={manufacturers.length} />
|
||||
<SummaryCard label="Geräte" value={devices.length} />
|
||||
<SummaryCard label="Dokumente" value={documents.length} />
|
||||
<SummaryCard label="Notizen" value={notes.length} />
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border bg-white p-4">
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Hersteller, Modell, Dokument oder Notiz suchen" />
|
||||
{results.length > 0 && (
|
||||
<div className="mt-4 divide-y rounded-lg border">
|
||||
{results.map((result) => (
|
||||
<Link key={`${result.entity_type}-${result.id}`} href={result.href} className="flex items-center gap-3 p-3 hover:bg-slate-50">
|
||||
<BookOpen size={16} className="text-slate-500" />
|
||||
<span className="font-medium text-slate-950">{result.title}</span>
|
||||
<span className="text-sm text-slate-500">{result.subtitle}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="rounded-lg border bg-white p-5">
|
||||
<h2 className="text-lg font-semibold text-slate-950">Letzte Dokumente</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{latestDocuments.map((document) => (
|
||||
<Link key={document.id} href={`/knowledge/documents/${document.id}`} className="block rounded-lg border p-3 hover:bg-slate-50">
|
||||
<p className="font-medium">{document.title}</p>
|
||||
<p className="text-sm text-slate-500">{document.manufacturer.name}{document.device ? ` · ${document.device.name}` : ""}</p>
|
||||
</Link>
|
||||
))}
|
||||
{latestDocuments.length === 0 && <p className="text-sm text-slate-500">Noch keine Dokumente vorhanden.</p>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-lg border bg-white p-5">
|
||||
<h2 className="text-lg font-semibold text-slate-950">Letzte Notizen</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{latestNotes.map((note) => (
|
||||
<Link key={note.id} href="/knowledge/notes" className="block rounded-lg border p-3 hover:bg-slate-50">
|
||||
<p className="font-medium">{note.title}</p>
|
||||
<p className="line-clamp-2 text-sm text-slate-500">{note.content}</p>
|
||||
</Link>
|
||||
))}
|
||||
{latestNotes.length === 0 && <p className="text-sm text-slate-500">Noch keine Notizen vorhanden.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
|
|
@ -49,6 +50,12 @@ const menu = [
|
|||
href: "/customers",
|
||||
permission: "customers.read",
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
name: "Wissensdatenbank",
|
||||
href: "/knowledge",
|
||||
permission: "knowledge.read",
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
name: "Reparaturen",
|
||||
|
|
|
|||
25
frontend/athena/components/knowledge/DocumentTypeBadge.tsx
Normal file
25
frontend/athena/components/knowledge/DocumentTypeBadge.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { DocumentType } from "@/types/knowledge";
|
||||
|
||||
const labels: Record<DocumentType, string> = {
|
||||
manual: "Bedienungsanleitung",
|
||||
service_manual: "Service Manual",
|
||||
schematic: "Schaltplan",
|
||||
alignment: "Abgleich",
|
||||
parts_list: "Ersatzteile",
|
||||
firmware: "Firmware",
|
||||
datasheet: "Datenblatt",
|
||||
service_bulletin: "Bulletin",
|
||||
other: "Sonstiges",
|
||||
};
|
||||
|
||||
export function documentTypeLabel(type: DocumentType) {
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
export default function DocumentTypeBadge({ type }: { type: DocumentType }) {
|
||||
return (
|
||||
<span className="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">
|
||||
{documentTypeLabel(type)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
325
frontend/athena/components/knowledge/KnowledgeForms.tsx
Normal file
325
frontend/athena/components/knowledge/KnowledgeForms.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type {
|
||||
DevicePayload,
|
||||
DocumentPayload,
|
||||
DocumentType,
|
||||
KnowledgeDevice,
|
||||
KnowledgeDocument,
|
||||
KnowledgeManufacturer,
|
||||
KnowledgeNote,
|
||||
ManufacturerPayload,
|
||||
NotePayload,
|
||||
NoteType,
|
||||
} from "@/types/knowledge";
|
||||
|
||||
const documentTypes: Array<[DocumentType, string]> = [
|
||||
["manual", "Bedienungsanleitung"],
|
||||
["service_manual", "Service Manual"],
|
||||
["schematic", "Schaltplan"],
|
||||
["alignment", "Abgleichanleitung"],
|
||||
["parts_list", "Ersatzteilliste"],
|
||||
["firmware", "Firmware"],
|
||||
["datasheet", "Datenblatt"],
|
||||
["service_bulletin", "Service Bulletin"],
|
||||
["other", "Sonstiges"],
|
||||
];
|
||||
|
||||
const noteTypes: Array<[NoteType, string]> = [
|
||||
["repair", "Reparaturhinweis"],
|
||||
["known_fault", "Bekannter Fehler"],
|
||||
["alignment", "Abgleichhinweis"],
|
||||
["spare_part", "Ersatzteilhinweis"],
|
||||
["general", "Allgemein"],
|
||||
];
|
||||
|
||||
function tagsToText(tags: string[]) {
|
||||
return tags.join(", ");
|
||||
}
|
||||
|
||||
function textToTags(value: string) {
|
||||
return value.split(",").map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function fieldClass() {
|
||||
return "grid gap-2";
|
||||
}
|
||||
|
||||
export function ManufacturerFormDialog({
|
||||
open,
|
||||
manufacturer,
|
||||
pending,
|
||||
error,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
manufacturer: KnowledgeManufacturer | null;
|
||||
pending: boolean;
|
||||
error: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: ManufacturerPayload) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [website, setWebsite] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setName(manufacturer?.name ?? "");
|
||||
setWebsite(manufacturer?.website ?? "");
|
||||
setNotes(manufacturer?.notes ?? "");
|
||||
});
|
||||
}, [manufacturer, open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{manufacturer ? "Hersteller bearbeiten" : "Hersteller erstellen"}</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className={fieldClass()}><Label>Name</Label><Input value={name} onChange={(event) => setName(event.target.value)} /></div>
|
||||
<div className={fieldClass()}><Label>Website</Label><Input value={website} onChange={(event) => setWebsite(event.target.value)} /></div>
|
||||
<div className={fieldClass()}><Label>Notizen</Label><textarea className="min-h-24 rounded-lg border border-input p-2 text-sm" value={notes} onChange={(event) => setNotes(event.target.value)} /></div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||
<Button onClick={() => onSubmit({ name, website: website || null, notes })} disabled={pending}>Speichern</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceFormDialog({
|
||||
open,
|
||||
device,
|
||||
manufacturers,
|
||||
pending,
|
||||
error,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
device: KnowledgeDevice | null;
|
||||
manufacturers: KnowledgeManufacturer[];
|
||||
pending: boolean;
|
||||
error: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: DevicePayload) => void;
|
||||
}) {
|
||||
const [payload, setPayload] = useState<DevicePayload>({
|
||||
manufacturer_id: 0,
|
||||
name: "",
|
||||
model_number: "",
|
||||
device_type: "",
|
||||
frequency_range: "",
|
||||
production_year_from: null,
|
||||
production_year_to: null,
|
||||
notes: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setPayload(device ? {
|
||||
manufacturer_id: device.manufacturer_id,
|
||||
name: device.name,
|
||||
model_number: device.model_number,
|
||||
device_type: device.device_type,
|
||||
frequency_range: device.frequency_range,
|
||||
production_year_from: device.production_year_from,
|
||||
production_year_to: device.production_year_to,
|
||||
notes: device.notes,
|
||||
} : {
|
||||
manufacturer_id: manufacturers[0]?.id ?? 0,
|
||||
name: "",
|
||||
model_number: "",
|
||||
device_type: "",
|
||||
frequency_range: "",
|
||||
production_year_from: null,
|
||||
production_year_to: null,
|
||||
notes: "",
|
||||
});
|
||||
});
|
||||
}, [device, manufacturers, open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader><DialogTitle>{device ? "Gerät bearbeiten" : "Gerät erstellen"}</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className={fieldClass()}><Label>Hersteller</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.manufacturer_id} onChange={(event) => setPayload({ ...payload, manufacturer_id: Number(event.target.value) })}>{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||
<div className={fieldClass()}><Label>Name</Label><Input value={payload.name} onChange={(event) => setPayload({ ...payload, name: event.target.value })} /></div>
|
||||
<div className={fieldClass()}><Label>Modellnummer</Label><Input value={payload.model_number} onChange={(event) => setPayload({ ...payload, model_number: event.target.value })} /></div>
|
||||
<div className={fieldClass()}><Label>Gerätetyp</Label><Input value={payload.device_type} onChange={(event) => setPayload({ ...payload, device_type: event.target.value })} /></div>
|
||||
<div className={fieldClass()}><Label>Frequenzbereich</Label><Input value={payload.frequency_range} onChange={(event) => setPayload({ ...payload, frequency_range: event.target.value })} /></div>
|
||||
<div className={fieldClass()}><Label>Baujahr von</Label><Input type="number" value={payload.production_year_from ?? ""} onChange={(event) => setPayload({ ...payload, production_year_from: event.target.value ? Number(event.target.value) : null })} /></div>
|
||||
<div className={fieldClass()}><Label>Baujahr bis</Label><Input type="number" value={payload.production_year_to ?? ""} onChange={(event) => setPayload({ ...payload, production_year_to: event.target.value ? Number(event.target.value) : null })} /></div>
|
||||
<div className="grid gap-2 sm:col-span-2"><Label>Notizen</Label><textarea className="min-h-24 rounded-lg border border-input p-2 text-sm" value={payload.notes} onChange={(event) => setPayload({ ...payload, notes: event.target.value })} /></div>
|
||||
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||
<Button onClick={() => onSubmit(payload)} disabled={pending || !payload.manufacturer_id}>Speichern</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentUploadDialog({
|
||||
open,
|
||||
document,
|
||||
manufacturers,
|
||||
devices,
|
||||
pending,
|
||||
error,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
document: KnowledgeDocument | null;
|
||||
manufacturers: KnowledgeManufacturer[];
|
||||
devices: KnowledgeDevice[];
|
||||
pending: boolean;
|
||||
error: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: DocumentPayload, file: File | null) => void;
|
||||
}) {
|
||||
const [payload, setPayload] = useState<DocumentPayload>({
|
||||
manufacturer_id: 0,
|
||||
device_id: null,
|
||||
title: "",
|
||||
document_type: "service_manual",
|
||||
language: "de",
|
||||
external_url: null,
|
||||
paperless_document_id: "",
|
||||
description: "",
|
||||
tags: [],
|
||||
});
|
||||
const [tags, setTags] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setPayload(document ? {
|
||||
manufacturer_id: document.manufacturer_id,
|
||||
device_id: document.device_id,
|
||||
title: document.title,
|
||||
document_type: document.document_type,
|
||||
language: document.language,
|
||||
external_url: document.external_url || null,
|
||||
paperless_document_id: document.paperless_document_id,
|
||||
description: document.description,
|
||||
tags: document.tags,
|
||||
} : {
|
||||
manufacturer_id: manufacturers[0]?.id ?? 0,
|
||||
device_id: null,
|
||||
title: "",
|
||||
document_type: "service_manual",
|
||||
language: "de",
|
||||
external_url: null,
|
||||
paperless_document_id: "",
|
||||
description: "",
|
||||
tags: [],
|
||||
});
|
||||
setTags(document ? tagsToText(document.tags) : "");
|
||||
setFile(null);
|
||||
});
|
||||
}, [document, manufacturers, open]);
|
||||
|
||||
const filteredDevices = devices.filter((device) => device.manufacturer_id === payload.manufacturer_id);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader><DialogTitle>{document ? "Dokument bearbeiten" : "Dokument hochladen"}</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className={fieldClass()}><Label>Hersteller</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.manufacturer_id} onChange={(event) => setPayload({ ...payload, manufacturer_id: Number(event.target.value), device_id: null })}>{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||
<div className={fieldClass()}><Label>Gerät</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.device_id ?? ""} onChange={(event) => setPayload({ ...payload, device_id: event.target.value ? Number(event.target.value) : null })}><option value="">Kein Gerät</option>{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||
<div className={fieldClass()}><Label>Titel</Label><Input value={payload.title} onChange={(event) => setPayload({ ...payload, title: event.target.value })} /></div>
|
||||
<div className={fieldClass()}><Label>Typ</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.document_type} onChange={(event) => setPayload({ ...payload, document_type: event.target.value as DocumentType })}>{documentTypes.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div>
|
||||
<div className={fieldClass()}><Label>Sprache</Label><Input value={payload.language} onChange={(event) => setPayload({ ...payload, language: event.target.value })} /></div>
|
||||
<div className={fieldClass()}><Label>Paperless-ID</Label><Input value={payload.paperless_document_id} onChange={(event) => setPayload({ ...payload, paperless_document_id: event.target.value })} /></div>
|
||||
<div className={fieldClass()}><Label>Externe URL</Label><Input value={payload.external_url ?? ""} onChange={(event) => setPayload({ ...payload, external_url: event.target.value || null })} /></div>
|
||||
{!document && <div className={fieldClass()}><Label>Datei</Label><input type="file" className="h-9 rounded-lg border px-2 py-1 text-sm" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></div>}
|
||||
<div className={fieldClass()}><Label>Tags</Label><Input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="cb-funk, schaltplan" /></div>
|
||||
<div className="grid gap-2 sm:col-span-2"><Label>Beschreibung</Label><textarea className="min-h-24 rounded-lg border border-input p-2 text-sm" value={payload.description} onChange={(event) => setPayload({ ...payload, description: event.target.value })} /></div>
|
||||
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||
<Button onClick={() => onSubmit({ ...payload, tags: textToTags(tags) }, file)} disabled={pending || !payload.manufacturer_id || (!document && !file)}>Speichern</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function KnowledgeNoteFormDialog({
|
||||
open,
|
||||
note,
|
||||
manufacturers,
|
||||
devices,
|
||||
pending,
|
||||
error,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
note: KnowledgeNote | null;
|
||||
manufacturers: KnowledgeManufacturer[];
|
||||
devices: KnowledgeDevice[];
|
||||
pending: boolean;
|
||||
error: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: NotePayload) => void;
|
||||
}) {
|
||||
const [payload, setPayload] = useState<NotePayload>({ manufacturer_id: null, device_id: null, title: "", content: "", note_type: "general", severity: "", tags: [] });
|
||||
const [tags, setTags] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setPayload(note ? {
|
||||
manufacturer_id: note.manufacturer_id,
|
||||
device_id: note.device_id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
note_type: note.note_type,
|
||||
severity: note.severity,
|
||||
tags: note.tags,
|
||||
} : { manufacturer_id: null, device_id: null, title: "", content: "", note_type: "general", severity: "", tags: [] });
|
||||
setTags(note ? tagsToText(note.tags) : "");
|
||||
});
|
||||
}, [note, open]);
|
||||
|
||||
const filteredDevices = payload.manufacturer_id ? devices.filter((device) => device.manufacturer_id === payload.manufacturer_id) : devices;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader><DialogTitle>{note ? "Notiz bearbeiten" : "Notiz erstellen"}</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className={fieldClass()}><Label>Hersteller</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.manufacturer_id ?? ""} onChange={(event) => setPayload({ ...payload, manufacturer_id: event.target.value ? Number(event.target.value) : null, device_id: null })}><option value="">Kein Hersteller</option>{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||
<div className={fieldClass()}><Label>Gerät</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.device_id ?? ""} onChange={(event) => setPayload({ ...payload, device_id: event.target.value ? Number(event.target.value) : null })}><option value="">Kein Gerät</option>{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||
<div className={fieldClass()}><Label>Titel</Label><Input value={payload.title} onChange={(event) => setPayload({ ...payload, title: event.target.value })} /></div>
|
||||
<div className={fieldClass()}><Label>Typ</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.note_type} onChange={(event) => setPayload({ ...payload, note_type: event.target.value as NoteType })}>{noteTypes.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div>
|
||||
<div className={fieldClass()}><Label>Schweregrad</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.severity} onChange={(event) => setPayload({ ...payload, severity: event.target.value as NotePayload["severity"] })}><option value="">Keiner</option><option value="low">Niedrig</option><option value="medium">Mittel</option><option value="high">Hoch</option><option value="critical">Kritisch</option></select></div>
|
||||
<div className={fieldClass()}><Label>Tags</Label><Input value={tags} onChange={(event) => setTags(event.target.value)} /></div>
|
||||
<div className="grid gap-2 sm:col-span-2"><Label>Inhalt</Label><textarea className="min-h-40 rounded-lg border border-input p-2 text-sm" value={payload.content} onChange={(event) => setPayload({ ...payload, content: event.target.value })} /></div>
|
||||
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||
<Button onClick={() => onSubmit({ ...payload, tags: textToTags(tags) })} disabled={pending}>Speichern</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
15
frontend/athena/components/knowledge/TagList.tsx
Normal file
15
frontend/athena/components/knowledge/TagList.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export default function TagList({ tags }: { tags: string[] }) {
|
||||
if (tags.length === 0) {
|
||||
return <span className="text-slate-400">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -57,3 +57,47 @@ export async function proxyHermesRequest(
|
|||
|
||||
return hermesJsonResponse(hermesResponse);
|
||||
}
|
||||
|
||||
export async function proxyHermesStreamRequest(
|
||||
request: NextRequest,
|
||||
path: string,
|
||||
) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
if (!token) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const hermesUrl = getHermesUrl();
|
||||
|
||||
if (!hermesUrl) {
|
||||
return upstreamConfigurationErrorResponse();
|
||||
}
|
||||
|
||||
let hermesResponse: Response;
|
||||
|
||||
try {
|
||||
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: request.headers.get("accept") ?? "*/*",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return upstreamUnavailableResponse();
|
||||
}
|
||||
|
||||
if (!hermesResponse.ok) {
|
||||
return hermesJsonResponse(hermesResponse);
|
||||
}
|
||||
|
||||
return new Response(hermesResponse.body, {
|
||||
status: hermesResponse.status,
|
||||
headers: {
|
||||
"Content-Type": hermesResponse.headers.get("content-type") ?? "application/octet-stream",
|
||||
"Content-Disposition": hermesResponse.headers.get("content-disposition") ?? "attachment",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
79
frontend/athena/types/knowledge.ts
Normal file
79
frontend/athena/types/knowledge.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
export type DocumentType = "manual" | "service_manual" | "schematic" | "alignment" | "parts_list" | "firmware" | "datasheet" | "service_bulletin" | "other";
|
||||
export type NoteType = "repair" | "known_fault" | "alignment" | "spare_part" | "general";
|
||||
|
||||
export interface KnowledgeManufacturer {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
website: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeDevice {
|
||||
id: number;
|
||||
manufacturer_id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
model_number: string;
|
||||
device_type: string;
|
||||
frequency_range: string;
|
||||
production_year_from: number | null;
|
||||
production_year_to: number | null;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
manufacturer: KnowledgeManufacturer;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocument {
|
||||
id: number;
|
||||
manufacturer_id: number;
|
||||
device_id: number | null;
|
||||
title: string;
|
||||
slug: string;
|
||||
document_type: DocumentType;
|
||||
language: string;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
checksum_sha256: string;
|
||||
external_url: string;
|
||||
paperless_document_id: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
manufacturer: KnowledgeManufacturer;
|
||||
device: KnowledgeDevice | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeNote {
|
||||
id: number;
|
||||
manufacturer_id: number | null;
|
||||
device_id: number | null;
|
||||
title: string;
|
||||
content: string;
|
||||
note_type: NoteType;
|
||||
severity: "" | "low" | "medium" | "high" | "critical";
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
manufacturer: KnowledgeManufacturer | null;
|
||||
device: KnowledgeDevice | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeSearchResult {
|
||||
entity_type: string;
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export type ManufacturerPayload = Pick<KnowledgeManufacturer, "name" | "notes"> & { website?: string | null };
|
||||
export type DevicePayload = Omit<KnowledgeDevice, "id" | "slug" | "created_at" | "updated_at" | "manufacturer">;
|
||||
export type DocumentPayload = Pick<KnowledgeDocument, "manufacturer_id" | "device_id" | "title" | "document_type" | "language" | "paperless_document_id" | "description" | "tags"> & { external_url?: string | null };
|
||||
export type NotePayload = Pick<KnowledgeNote, "manufacturer_id" | "device_id" | "title" | "content" | "note_type" | "severity" | "tags">;
|
||||
Loading…
Add table
Add a link
Reference in a new issue