fix(knowledge): improve upload workflow and seed manufacturers

This commit is contained in:
Schubert Ferenc 2026-07-03 18:40:05 +02:00
parent 964b545bc5
commit 612e1ad8ed
13 changed files with 429 additions and 20 deletions

View file

@ -6,6 +6,7 @@ import { Edit, Eye, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import EmptyState from "@/components/common/EmptyState";
import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { DeviceFormDialog } from "@/components/knowledge/KnowledgeForms";
@ -139,7 +140,27 @@ export default function KnowledgeDevicesPage() {
{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"); }} />
{!loading && !error && manufacturers.length === 0 ? (
<EmptyState
title="Es ist noch kein Hersteller vorhanden"
description="Bitte lege zuerst einen Hersteller an. Geräte müssen immer einem Hersteller zugeordnet werden."
action={<Link href="/knowledge/manufacturers" className={buttonVariants({ variant: "default" })}><Plus size={16} />Hersteller anlegen</Link>}
/>
) : !loading && !error && devices.length === 0 ? (
<EmptyState
title="Es ist noch kein Gerät vorhanden"
description="Lege ein Gerät an, damit Dokumente wie Schaltpläne oder Service Manuals sauber zugeordnet werden können."
action={<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Gerät anlegen</Button>}
/>
) : !loading && !error && rows.length === 0 ? (
<EmptyState
title="Keine Geräte gefunden"
description="Für deine Suche oder den ausgewählten Hersteller gibt es keinen Treffer."
action={<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Gerät anlegen</Button>}
/>
) : (
<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>}

View file

@ -6,14 +6,15 @@ import { Download, Edit, Eye, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import EmptyState from "@/components/common/EmptyState";
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 { DeviceFormDialog, DocumentUploadDialog, ManufacturerFormDialog } 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";
import type { DevicePayload, DocumentPayload, DocumentType, KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, ManufacturerPayload } from "@/types/knowledge";
function errorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
@ -43,6 +44,9 @@ export default function KnowledgeDocumentsPage() {
const [sortKey, setSortKey] = useState("created_at");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [formOpen, setFormOpen] = useState(false);
const [manufacturerFormOpen, setManufacturerFormOpen] = useState(false);
const [deviceFormOpen, setDeviceFormOpen] = useState(false);
const [deviceInitialManufacturerId, setDeviceInitialManufacturerId] = useState<number | null>(null);
const [selected, setSelected] = useState<KnowledgeDocument | null>(null);
const [deleteItem, setDeleteItem] = useState<KnowledgeDocument | null>(null);
@ -82,6 +86,17 @@ export default function KnowledgeDocumentsPage() {
return sortDirection === "asc" ? result : -result;
}), [documents, manufacturerFilter, search, sortDirection, sortKey, typeFilter]);
const selectedManufacturerId = manufacturerFilter === "all"
? devices[0]?.manufacturer_id ?? manufacturers[0]?.id ?? null
: Number(manufacturerFilter);
const selectedManufacturer = manufacturers.find((manufacturer) => manufacturer.id === selectedManufacturerId) ?? null;
const selectedManufacturerDevices = selectedManufacturerId
? devices.filter((device) => device.manufacturer_id === selectedManufacturerId)
: [];
const canUpload = manufacturers.length > 0
&& devices.length > 0
&& (manufacturerFilter === "all" || selectedManufacturerDevices.length > 0);
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 },
@ -106,6 +121,13 @@ export default function KnowledgeDocumentsPage() {
];
async function save(payload: DocumentPayload, file: File | null) {
if (!payload.manufacturer_id || !payload.device_id || (!selected && !file)) {
const message = "Die Datei konnte nicht hochgeladen werden, weil die Zuordnung unvollständig ist.";
setFormError(message);
showToast({ type: "error", title: "Dokument kann nicht gespeichert werden", description: message });
return;
}
setPending(true);
setFormError("");
try {
@ -134,6 +156,49 @@ export default function KnowledgeDocumentsPage() {
}
}
async function saveManufacturer(payload: ManufacturerPayload) {
setPending(true);
setFormError("");
try {
const response = await api.post<KnowledgeManufacturer>("/knowledge/manufacturers", payload);
showToast({ type: "success", title: "Hersteller erstellt", description: response.data.name });
setManufacturerFilter(String(response.data.id));
setDeviceInitialManufacturerId(response.data.id);
setManufacturerFormOpen(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 saveDevice(payload: DevicePayload) {
setPending(true);
setFormError("");
try {
const response = await api.post<KnowledgeDevice>("/knowledge/devices", payload);
showToast({ type: "success", title: "Gerät erstellt", description: response.data.name });
setManufacturerFilter(String(response.data.manufacturer_id));
setDeviceFormOpen(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);
}
}
function openDeviceCreate(manufacturerId: number | null = selectedManufacturerId) {
setDeviceInitialManufacturerId(manufacturerId);
setFormError("");
setDeviceFormOpen(true);
}
async function confirmDelete() {
if (!deleteItem) return;
setPending(true);
@ -153,7 +218,7 @@ export default function KnowledgeDocumentsPage() {
<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>
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={!canUpload}><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" />
@ -174,9 +239,54 @@ export default function KnowledgeDocumentsPage() {
<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}>
{!loading && !error && manufacturers.length === 0 ? (
<EmptyState
title="Es ist noch kein Hersteller vorhanden"
description="Bitte lege zuerst einen Hersteller an. Danach kannst du Geräte und Dokumente wie Schaltpläne oder Service Manuals erfassen."
action={<Button onClick={() => { setFormError(""); setManufacturerFormOpen(true); }}><Plus size={16} />Hersteller anlegen</Button>}
/>
) : !loading && !error && devices.length === 0 ? (
<EmptyState
title="Es ist noch kein Gerät vorhanden"
description="Dokumente wie Schaltpläne oder Service Manuals müssen einem Gerät zugeordnet werden."
action={<Button onClick={() => openDeviceCreate()}><Plus size={16} />Gerät anlegen</Button>}
/>
) : !loading && !error && selectedManufacturer && manufacturerFilter !== "all" && selectedManufacturerDevices.length === 0 ? (
<EmptyState
title="Für diesen Hersteller existiert noch kein Gerät"
description="Lege zuerst ein Gerät für diesen Hersteller an. Danach kannst du Dokumente diesem Gerät zuordnen."
action={<Button onClick={() => openDeviceCreate(selectedManufacturer.id)}><Plus size={16} />Gerät für diesen Hersteller anlegen</Button>}
/>
) : !loading && !error && documents.length === 0 ? (
<EmptyState
title="Es ist noch kein Dokument vorhanden"
description="Lade das erste Dokument hoch, sobald Hersteller und Gerät angelegt sind."
action={<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={!canUpload}><Plus size={16} />Dokument hochladen</Button>}
/>
) : !loading && !error && rows.length === 0 ? (
<EmptyState
title="Keine Dokumente gefunden"
description="Für Suche oder Filter gibt es keinen Treffer. Passe die Filter an oder lade ein neues Dokument hoch."
action={<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={!canUpload}><Plus size={16} />Dokument hochladen</Button>}
/>
) : (
<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}
initialManufacturerId={selectedManufacturerId}
pending={pending}
error={formError}
onOpenChange={setFormOpen}
onSubmit={save}
onValidationError={(message) => showToast({ type: "error", title: "Upload unvollständig", description: message })}
/>
<ManufacturerFormDialog open={manufacturerFormOpen} manufacturer={null} pending={pending} error={formError} onOpenChange={setManufacturerFormOpen} onSubmit={saveManufacturer} />
<DeviceFormDialog open={deviceFormOpen} device={null} manufacturers={manufacturers} initialManufacturerId={deviceInitialManufacturerId} pending={pending} error={formError} onOpenChange={setDeviceFormOpen} onSubmit={saveDevice} />
<ConfirmDialog open={Boolean(deleteItem)} title="Dokument löschen" description="Der Datenbankeintrag und die gespeicherte Datei werden entfernt." pending={pending} onOpenChange={(open) => !open && setDeleteItem(null)} onConfirm={confirmDelete}>
{deleteItem && <p className="text-sm font-medium">{deleteItem.title}</p>}
</ConfirmDialog>
</div>

View file

@ -5,6 +5,7 @@ import { Edit, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import EmptyState from "@/components/common/EmptyState";
import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { ManufacturerFormDialog } from "@/components/knowledge/KnowledgeForms";
@ -122,7 +123,21 @@ export default function KnowledgeManufacturersPage() {
<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"); }} />
{!loading && !error && items.length === 0 ? (
<EmptyState
title="Es ist noch kein Hersteller vorhanden"
description="Bitte lege zuerst einen Hersteller an. Danach kannst du Geräte und Dokumente wie Schaltpläne oder Service Manuals erfassen."
action={<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Hersteller anlegen</Button>}
/>
) : !loading && !error && rows.length === 0 ? (
<EmptyState
title="Keine Hersteller gefunden"
description="Für deine Suche gibt es keinen Treffer. Passe den Suchbegriff an oder lege einen neuen Hersteller an."
action={<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Hersteller anlegen</Button>}
/>
) : (
<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>}

View file

@ -5,6 +5,7 @@ import { Edit, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import EmptyState from "@/components/common/EmptyState";
import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { KnowledgeNoteFormDialog } from "@/components/knowledge/KnowledgeForms";
@ -142,7 +143,21 @@ export default function KnowledgeNotesPage() {
<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"); }} />
{!loading && !error && notes.length === 0 ? (
<EmptyState
title="Es ist noch keine Notiz vorhanden"
description="Erfasse Reparaturhinweise, bekannte Fehler oder Abgleichnotizen, sobald erstes Wissen dokumentiert werden soll."
action={<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Notiz anlegen</Button>}
/>
) : !loading && !error && rows.length === 0 ? (
<EmptyState
title="Keine Notizen gefunden"
description="Für deine Suche gibt es keinen Treffer. Passe den Suchbegriff an oder erstelle eine neue Notiz."
action={<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Notiz anlegen</Button>}
/>
) : (
<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>}