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

@ -534,6 +534,57 @@ Hermes stellt dafuer Tabellen mit dem Prefix `knowledge_` bereit:
Athena stellt die BFF-Routen unter `/api/knowledge/...` bereit. Der Browser spricht weiterhin ausschliesslich mit Athena.
### Knowledge-Workflow
Seit v0.7.1 fuehrt die UI Benutzer explizit durch den fachlichen Ablauf:
```text
Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
```
Dokumente wie Schaltplaene, Service Manuals oder Abgleichanleitungen werden immer einem konkreten Geraet zugeordnet. Der Upload wird in Athena deaktiviert, solange Hersteller oder Geraet fehlen. Empty States erklaeren, was fehlt, und bieten die naechste sinnvolle Aktion an.
Upload-Voraussetzungen:
- Hersteller ist Pflicht.
- Geraet ist Pflicht.
- Dokumenttyp ist Pflicht.
- Datei ist Pflicht bei neuen Uploads.
- Das Geraet muss zum ausgewaehlten Hersteller gehoeren.
Hermes validiert diese Regeln serverseitig und liefert Benutzerfehler mit klaren Meldungen statt 500er-Antworten.
### Standard-Hersteller-Seeding
Hermes legt beim Startup idempotent Standard-Hersteller an, sofern sie noch nicht existieren:
- Stabo
- President
- Albrecht
- Marconi
- Rohde & Schwarz
- HP
- CRT
- Alinco
- Motorola
- Team
Das Seeding ueberschreibt bestehende Hersteller nicht. Fehlende Slugs werden ergaenzt. Websites und Notizen bleiben leer, solange keine sicheren Stammdaten gepflegt sind.
### Empty States
Athena verwendet fuer Knowledge-Listen die zentrale Komponente `EmptyState`.
Sie wird eingesetzt fuer:
- leere Herstellerliste
- leere Geraeteliste
- leere Dokumentliste
- leere Notizliste
- Suche oder Filter ohne Treffer
Jeder Empty State erklaert kurz den fehlenden Zustand und bietet eine passende Aktion an.
### Knowledge-Dateispeicherung
Seit v0.7.0 laufen Knowledge-Dateien ueber das zentrale Storage Framework. KnowledgeService speichert, liest und loescht Dateien nicht mehr direkt ueber verstreute Dateioperationen, sondern ueber `StorageService`.

View file

@ -240,9 +240,20 @@ Upload-Regeln:
- Athena leitet FormData serverseitig an Hermes weiter.
- Hermes validiert Dateityp, MIME-Type, Dateigroesse und Speicherpfad.
- Lokale Dateien liegen ueber Docker unter `STORAGE_BASE_PATH`; alte Knowledge-Pfade bleiben nur als Legacy-Fallback erhalten.
- Dokumentuploads benoetigen Hersteller, Geraet, Dokumenttyp und Datei.
- Ein Geraet muss serverseitig zum ausgewaehlten Hersteller gehoeren.
- Fehlende Upload-Voraussetzungen muessen im Formular sichtbar sein und duerfen keinen Upload starten.
- Audit Logs duerfen keine Datei-Inhalte enthalten.
- Keine Pfade aus unvalidierten Benutzereingaben zusammensetzen.
Workflow-Regel:
```text
Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
```
Knowledge-Listen sollen `EmptyState` nutzen, wenn Daten fehlen oder Suche/Filter keinen Treffer liefern.
Paperless-ngx ist nur vorbereitet. `paperless_document_id` und `external_url` duerfen gepflegt werden, aber es werden keine Paperless-Secrets oder API-Keys eingefuehrt.
### Storage

View file

@ -118,6 +118,16 @@ STORAGE_BASE_PATH=/data/storage
Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen.
## Knowledge Workflow
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
```text
Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
```
Hermes legt beim Startup Standard-Hersteller an, wenn sie noch fehlen. Danach koennen Geraete angelegt werden. Dokumentuploads sind erst sinnvoll, wenn Hersteller und Geraet vorhanden sind; Athena blockiert unvollstaendige Uploads direkt im Formular.
## Typische Fehler
Hermes restartet wegen fehlender Migration:

View file

@ -65,6 +65,15 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Deployment-, Migrations-, Healthcheck-, Backup- und Restore-Skripte
- README-DEV fuer mehrere Entwicklungsrechner
## v0.7.1 - Knowledge UX und Workflow
- Gefuehrter Workflow Hersteller -> Geraet -> Dokument -> Notiz
- Dokumentupload erst nach Hersteller- und Geraeteanlage
- Inline-Validierung fuer Upload-Pflichtfelder
- Professionelle Empty States fuer Knowledge-Listen und Suchen
- Idempotentes Standard-Hersteller-Seeding
- Klarere Hermes-Fehler fuer unvollstaendige Dokumentzuordnungen
## v0.8.0 - Projektmodul, geplant
- Projektstammdaten

View file

@ -0,0 +1,56 @@
import logging
from sqlalchemy.orm import Session
from app.models.knowledge import KnowledgeManufacturer
from app.repositories.knowledge_repository import KnowledgeRepository
from app.services.knowledge_service import unique_slug
logger = logging.getLogger(__name__)
STANDARD_MANUFACTURERS = [
"Stabo",
"President",
"Albrecht",
"Marconi",
"Rohde & Schwarz",
"HP",
"CRT",
"Alinco",
"Motorola",
"Team",
]
def seed_knowledge_manufacturers(db: Session) -> None:
created = 0
for name in STANDARD_MANUFACTURERS:
manufacturer = KnowledgeRepository.get_manufacturer_by_name(db, name)
if manufacturer is not None:
changed = False
if not manufacturer.slug:
manufacturer.slug = unique_slug(
db,
manufacturer.name,
lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(
session,
value,
manufacturer.id,
) is not None,
)
changed = True
if changed:
db.add(manufacturer)
continue
slug = unique_slug(
db,
name,
lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value) is not None,
)
db.add(KnowledgeManufacturer(name=name, slug=slug, website="", notes=""))
created += 1
db.commit()
logger.info("knowledge.manufacturers.seeded", extra={"created": created})

View file

@ -20,6 +20,7 @@ from app.api.users import router as users_router
from app.db.database import SessionLocal
from app.db.health import check_database
from app.core.logging import configure_logging
from app.knowledge_seed import seed_knowledge_manufacturers
from app.rbac.seed import seed_rbac
from app.services.initial_admin_bootstrap import bootstrap_initial_admin
@ -76,6 +77,7 @@ def startup_seed_rbac():
db = SessionLocal()
try:
seed_rbac(db)
seed_knowledge_manufacturers(db)
bootstrap_initial_admin(db)
finally:
db.close()

View file

@ -125,7 +125,7 @@ class KnowledgeService:
file: UploadFile,
payload: KnowledgeDocumentCreate,
) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
KnowledgeService._validate_upload_links(db, payload.manufacturer_id, payload.device_id)
storage_service = get_storage_service()
max_bytes = storage_service.max_upload_mb * 1024 * 1024
content = await file.read(max_bytes + 1)
@ -208,6 +208,8 @@ class KnowledgeService:
@staticmethod
def _validate_document_links(db: Session, manufacturer_id: int, device_id: int | None) -> None:
if not manufacturer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie einen Hersteller aus.")
if KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
if device_id is not None:
@ -227,3 +229,23 @@ class KnowledgeService:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
if manufacturer_id is not None and device.manufacturer_id != manufacturer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Gerät gehört nicht zum Hersteller")
@staticmethod
def _validate_upload_links(db: Session, manufacturer_id: int, device_id: int | None) -> None:
if not manufacturer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie einen Hersteller aus.")
if device_id is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie ein Gerät aus.")
manufacturer = KnowledgeRepository.get_manufacturer(db, manufacturer_id)
if manufacturer is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Der ausgewählte Hersteller existiert nicht.")
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Das ausgewählte Gerät existiert nicht.")
if device.manufacturer_id != manufacturer_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Das ausgewählte Gerät gehört nicht zum ausgewählten Hersteller.",
)

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>}

View file

@ -0,0 +1,19 @@
"use client";
import type { ReactNode } from "react";
type Props = {
title: string;
description: string;
action?: ReactNode;
};
export default function EmptyState({ title, description, action }: Props) {
return (
<div className="rounded-lg border bg-white p-8 text-center">
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
<p className="mx-auto mt-2 max-w-xl text-sm text-slate-500">{description}</p>
{action && <div className="mt-5 flex justify-center">{action}</div>}
</div>
);
}

View file

@ -101,6 +101,7 @@ export function DeviceFormDialog({
open,
device,
manufacturers,
initialManufacturerId,
pending,
error,
onOpenChange,
@ -109,6 +110,7 @@ export function DeviceFormDialog({
open: boolean;
device: KnowledgeDevice | null;
manufacturers: KnowledgeManufacturer[];
initialManufacturerId?: number | null;
pending: boolean;
error: string;
onOpenChange: (open: boolean) => void;
@ -137,7 +139,7 @@ export function DeviceFormDialog({
production_year_to: device.production_year_to,
notes: device.notes,
} : {
manufacturer_id: manufacturers[0]?.id ?? 0,
manufacturer_id: initialManufacturerId ?? manufacturers[0]?.id ?? 0,
name: "",
model_number: "",
device_type: "",
@ -147,7 +149,7 @@ export function DeviceFormDialog({
notes: "",
});
});
}, [device, manufacturers, open]);
}, [device, initialManufacturerId, manufacturers, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -178,19 +180,23 @@ export function DocumentUploadDialog({
document,
manufacturers,
devices,
initialManufacturerId,
pending,
error,
onOpenChange,
onSubmit,
onValidationError,
}: {
open: boolean;
document: KnowledgeDocument | null;
manufacturers: KnowledgeManufacturer[];
devices: KnowledgeDevice[];
initialManufacturerId?: number | null;
pending: boolean;
error: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: DocumentPayload, file: File | null) => void;
onValidationError?: (message: string) => void;
}) {
const [payload, setPayload] = useState<DocumentPayload>({
manufacturer_id: 0,
@ -205,6 +211,7 @@ export function DocumentUploadDialog({
});
const [tags, setTags] = useState("");
const [file, setFile] = useState<File | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => {
queueMicrotask(() => {
@ -219,7 +226,7 @@ export function DocumentUploadDialog({
description: document.description,
tags: document.tags,
} : {
manufacturer_id: manufacturers[0]?.id ?? 0,
manufacturer_id: initialManufacturerId ?? manufacturers[0]?.id ?? 0,
device_id: null,
title: "",
document_type: "service_manual",
@ -231,31 +238,92 @@ export function DocumentUploadDialog({
});
setTags(document ? tagsToText(document.tags) : "");
setFile(null);
setFieldErrors({});
});
}, [document, manufacturers, open]);
}, [document, initialManufacturerId, manufacturers, open]);
const filteredDevices = devices.filter((device) => device.manufacturer_id === payload.manufacturer_id);
const selectedManufacturer = manufacturers.find((manufacturer) => manufacturer.id === payload.manufacturer_id);
function validate() {
const errors: Record<string, string> = {};
if (!payload.manufacturer_id) {
errors.manufacturer_id = "Bitte wählen Sie einen Hersteller aus.";
}
if (!payload.device_id) {
errors.device_id = "Bitte wählen Sie ein Gerät aus.";
}
if (!payload.document_type) {
errors.document_type = "Bitte wählen Sie einen Dokumenttyp aus.";
}
if (!payload.title.trim()) {
errors.title = "Bitte geben Sie einen Titel ein.";
}
if (!document && !file) {
errors.file = "Bitte wählen Sie eine Datei aus.";
}
setFieldErrors(errors);
const firstMessage = Object.values(errors)[0];
if (firstMessage) {
onValidationError?.(firstMessage);
return false;
}
return true;
}
function submit() {
if (!validate()) {
return;
}
onSubmit({ ...payload, tags: textToTags(tags) }, file);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader><DialogTitle>{document ? "Dokument bearbeiten" : "Dokument hochladen"}</DialogTitle></DialogHeader>
<p className="text-sm text-slate-500">
Dokumente werden immer einem Gerät zugeordnet. Lege daher zuerst Hersteller und Gerät an.
</p>
<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>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 })}>
<option value={0}>Hersteller auswählen</option>
{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
{fieldErrors.manufacturer_id && <p className="text-xs text-red-600">{fieldErrors.manufacturer_id}</p>}
</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 })} disabled={!payload.manufacturer_id || filteredDevices.length === 0}>
<option value="">Gerät auswählen</option>
{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
{payload.manufacturer_id && filteredDevices.length === 0 && (
<p className="text-xs text-amber-700">Für diesen Hersteller existiert noch kein Gerät.</p>
)}
{fieldErrors.device_id && <p className="text-xs text-red-600">{fieldErrors.device_id}</p>}
</div>
<div className={fieldClass()}><Label>Titel</Label><Input value={payload.title} onChange={(event) => setPayload({ ...payload, title: event.target.value })} />{fieldErrors.title && <p className="text-xs text-red-600">{fieldErrors.title}</p>}</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>{fieldErrors.document_type && <p className="text-xs text-red-600">{fieldErrors.document_type}</p>}</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>}
{!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)} />{fieldErrors.file && <p className="text-xs text-red-600">{fieldErrors.file}</p>}</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>
{selectedManufacturer && filteredDevices.length === 0 && (
<p className="text-sm text-amber-700 sm:col-span-2">
Für {selectedManufacturer.name} existiert noch kein Gerät. Dokumente wie Schaltpläne oder Service Manuals müssen einem Gerät zugeordnet werden.
</p>
)}
{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>
<Button onClick={submit} disabled={pending}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>