diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 89a1283..1f410d3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0b8f51..d514c09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README-DEV.md b/README-DEV.md index 5ab75b1..408518b 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -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: diff --git a/ROADMAP.md b/ROADMAP.md index 3a50679..5bbf234 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/backend/hermes/app/knowledge_seed.py b/backend/hermes/app/knowledge_seed.py new file mode 100644 index 0000000..31eed13 --- /dev/null +++ b/backend/hermes/app/knowledge_seed.py @@ -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}) diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 2a79c46..b7f2f70 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -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() diff --git a/backend/hermes/app/services/knowledge_service.py b/backend/hermes/app/services/knowledge_service.py index 1e4840c..8aae3c3 100644 --- a/backend/hermes/app/services/knowledge_service.py +++ b/backend/hermes/app/services/knowledge_service.py @@ -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.", + ) diff --git a/frontend/athena/app/knowledge/devices/page.tsx b/frontend/athena/app/knowledge/devices/page.tsx index e718d4b..82d30e7 100644 --- a/frontend/athena/app/knowledge/devices/page.tsx +++ b/frontend/athena/app/knowledge/devices/page.tsx @@ -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) => )} - 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 ? ( + Hersteller anlegen} + /> + ) : !loading && !error && devices.length === 0 ? ( + { setSelected(null); setFormError(""); setFormOpen(true); }}>Gerät anlegen} + /> + ) : !loading && !error && rows.length === 0 ? ( + { setSelected(null); setFormError(""); setFormOpen(true); }}>Gerät anlegen} + /> + ) : ( + 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"); }} /> + )} !open && setDeleteItem(null)} onConfirm={confirmDelete}> {deleteItem &&

{deleteItem.name}

} diff --git a/frontend/athena/app/knowledge/documents/page.tsx b/frontend/athena/app/knowledge/documents/page.tsx index 3315f53..1e52485 100644 --- a/frontend/athena/app/knowledge/documents/page.tsx +++ b/frontend/athena/app/knowledge/documents/page.tsx @@ -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(null); const [selected, setSelected] = useState(null); const [deleteItem, setDeleteItem] = useState(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[] = [ { key: "title", label: "Titel", sortable: true, render: (item) => {item.title} }, { 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("/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("/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() {

Dokumente

{rows.length} Unterlagen

- +
@@ -174,9 +239,54 @@ export default function KnowledgeDocumentsPage() {
- 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"); }} /> - - !open && setDeleteItem(null)} onConfirm={confirmDelete}> + {!loading && !error && manufacturers.length === 0 ? ( + { setFormError(""); setManufacturerFormOpen(true); }}>Hersteller anlegen} + /> + ) : !loading && !error && devices.length === 0 ? ( + openDeviceCreate()}>Gerät anlegen} + /> + ) : !loading && !error && selectedManufacturer && manufacturerFilter !== "all" && selectedManufacturerDevices.length === 0 ? ( + openDeviceCreate(selectedManufacturer.id)}>Gerät für diesen Hersteller anlegen} + /> + ) : !loading && !error && documents.length === 0 ? ( + { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={!canUpload}>Dokument hochladen} + /> + ) : !loading && !error && rows.length === 0 ? ( + { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={!canUpload}>Dokument hochladen} + /> + ) : ( + 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"); }} /> + )} + showToast({ type: "error", title: "Upload unvollständig", description: message })} + /> + + + !open && setDeleteItem(null)} onConfirm={confirmDelete}> {deleteItem &&

{deleteItem.title}

}
diff --git a/frontend/athena/app/knowledge/manufacturers/page.tsx b/frontend/athena/app/knowledge/manufacturers/page.tsx index ea15283..6dfc778 100644 --- a/frontend/athena/app/knowledge/manufacturers/page.tsx +++ b/frontend/athena/app/knowledge/manufacturers/page.tsx @@ -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() {
- 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 ? ( + { setSelected(null); setFormError(""); setFormOpen(true); }}>Hersteller anlegen} + /> + ) : !loading && !error && rows.length === 0 ? ( + { setSelected(null); setFormError(""); setFormOpen(true); }}>Hersteller anlegen} + /> + ) : ( + 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"); }} /> + )} !open && setDeleteItem(null)} onConfirm={confirmDelete}> {deleteItem &&

{deleteItem.name}

} diff --git a/frontend/athena/app/knowledge/notes/page.tsx b/frontend/athena/app/knowledge/notes/page.tsx index 66f24fb..6e459e5 100644 --- a/frontend/athena/app/knowledge/notes/page.tsx +++ b/frontend/athena/app/knowledge/notes/page.tsx @@ -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() {
- 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 ? ( + { setSelected(null); setFormError(""); setFormOpen(true); }}>Notiz anlegen} + /> + ) : !loading && !error && rows.length === 0 ? ( + { setSelected(null); setFormError(""); setFormOpen(true); }}>Notiz anlegen} + /> + ) : ( + 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"); }} /> + )} !open && setDeleteItem(null)} onConfirm={confirmDelete}> {deleteItem &&

{deleteItem.title}

} diff --git a/frontend/athena/components/common/EmptyState.tsx b/frontend/athena/components/common/EmptyState.tsx new file mode 100644 index 0000000..7dfe28f --- /dev/null +++ b/frontend/athena/components/common/EmptyState.tsx @@ -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 ( +
+

{title}

+

{description}

+ {action &&
{action}
} +
+ ); +} diff --git a/frontend/athena/components/knowledge/KnowledgeForms.tsx b/frontend/athena/components/knowledge/KnowledgeForms.tsx index 1950625..12826dc 100644 --- a/frontend/athena/components/knowledge/KnowledgeForms.tsx +++ b/frontend/athena/components/knowledge/KnowledgeForms.tsx @@ -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 ( @@ -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({ manufacturer_id: 0, @@ -205,6 +211,7 @@ export function DocumentUploadDialog({ }); const [tags, setTags] = useState(""); const [file, setFile] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); 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 = {}; + 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 ( {document ? "Dokument bearbeiten" : "Dokument hochladen"} +

+ Dokumente werden immer einem Gerät zugeordnet. Lege daher zuerst Hersteller und Gerät an. +

-
-
-
setPayload({ ...payload, title: event.target.value })} />
-
+
+ + + {fieldErrors.manufacturer_id &&

{fieldErrors.manufacturer_id}

} +
+
+ + + {payload.manufacturer_id && filteredDevices.length === 0 && ( +

Für diesen Hersteller existiert noch kein Gerät.

+ )} + {fieldErrors.device_id &&

{fieldErrors.device_id}

} +
+
setPayload({ ...payload, title: event.target.value })} />{fieldErrors.title &&

{fieldErrors.title}

}
+
{fieldErrors.document_type &&

{fieldErrors.document_type}

}
setPayload({ ...payload, language: event.target.value })} />
setPayload({ ...payload, paperless_document_id: event.target.value })} />
setPayload({ ...payload, external_url: event.target.value || null })} />
- {!document &&
setFile(event.target.files?.[0] ?? null)} />
} + {!document &&
setFile(event.target.files?.[0] ?? null)} />{fieldErrors.file &&

{fieldErrors.file}

}
}
setTags(event.target.value)} placeholder="cb-funk, schaltplan" />