feat(knowledge): add service library
This commit is contained in:
parent
ecab0fe6b6
commit
228da8f814
32 changed files with 2878 additions and 6 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue