fix(knowledge): improve upload workflow and seed manufacturers
This commit is contained in:
parent
964b545bc5
commit
612e1ad8ed
13 changed files with 429 additions and 20 deletions
|
|
@ -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.
|
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
|
### 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`.
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -240,9 +240,20 @@ Upload-Regeln:
|
||||||
- Athena leitet FormData serverseitig an Hermes weiter.
|
- Athena leitet FormData serverseitig an Hermes weiter.
|
||||||
- Hermes validiert Dateityp, MIME-Type, Dateigroesse und Speicherpfad.
|
- 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.
|
- 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.
|
- Audit Logs duerfen keine Datei-Inhalte enthalten.
|
||||||
- Keine Pfade aus unvalidierten Benutzereingaben zusammensetzen.
|
- 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.
|
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
|
### Storage
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## Typische Fehler
|
||||||
|
|
||||||
Hermes restartet wegen fehlender Migration:
|
Hermes restartet wegen fehlender Migration:
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,15 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- Deployment-, Migrations-, Healthcheck-, Backup- und Restore-Skripte
|
- Deployment-, Migrations-, Healthcheck-, Backup- und Restore-Skripte
|
||||||
- README-DEV fuer mehrere Entwicklungsrechner
|
- 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
|
## v0.8.0 - Projektmodul, geplant
|
||||||
|
|
||||||
- Projektstammdaten
|
- Projektstammdaten
|
||||||
|
|
|
||||||
56
backend/hermes/app/knowledge_seed.py
Normal file
56
backend/hermes/app/knowledge_seed.py
Normal 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})
|
||||||
|
|
@ -20,6 +20,7 @@ from app.api.users import router as users_router
|
||||||
from app.db.database import SessionLocal
|
from app.db.database import SessionLocal
|
||||||
from app.db.health import check_database
|
from app.db.health import check_database
|
||||||
from app.core.logging import configure_logging
|
from app.core.logging import configure_logging
|
||||||
|
from app.knowledge_seed import seed_knowledge_manufacturers
|
||||||
from app.rbac.seed import seed_rbac
|
from app.rbac.seed import seed_rbac
|
||||||
from app.services.initial_admin_bootstrap import bootstrap_initial_admin
|
from app.services.initial_admin_bootstrap import bootstrap_initial_admin
|
||||||
|
|
||||||
|
|
@ -76,6 +77,7 @@ def startup_seed_rbac():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
seed_rbac(db)
|
seed_rbac(db)
|
||||||
|
seed_knowledge_manufacturers(db)
|
||||||
bootstrap_initial_admin(db)
|
bootstrap_initial_admin(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ class KnowledgeService:
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
payload: KnowledgeDocumentCreate,
|
payload: KnowledgeDocumentCreate,
|
||||||
) -> KnowledgeDocument:
|
) -> 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()
|
storage_service = get_storage_service()
|
||||||
max_bytes = storage_service.max_upload_mb * 1024 * 1024
|
max_bytes = storage_service.max_upload_mb * 1024 * 1024
|
||||||
content = await file.read(max_bytes + 1)
|
content = await file.read(max_bytes + 1)
|
||||||
|
|
@ -208,6 +208,8 @@ class KnowledgeService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_document_links(db: Session, manufacturer_id: int, device_id: int | None) -> None:
|
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:
|
if KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
|
||||||
if device_id is not None:
|
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")
|
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:
|
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")
|
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.",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Edit, Eye, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||||
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
import SearchInput from "@/components/common/SearchInput";
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
import { DeviceFormDialog } from "@/components/knowledge/KnowledgeForms";
|
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>)}
|
{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{!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"); }} />
|
<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} />
|
<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}>
|
<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>}
|
{deleteItem && <p className="text-sm font-medium">{deleteItem.name}</p>}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,15 @@ import { Download, Edit, Eye, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||||
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
import SearchInput from "@/components/common/SearchInput";
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
import DocumentTypeBadge from "@/components/knowledge/DocumentTypeBadge";
|
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 TagList from "@/components/knowledge/TagList";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { api } from "@/lib/api";
|
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) {
|
function errorMessage(error: unknown) {
|
||||||
if (typeof error === "object" && error !== null && "response" in error) {
|
if (typeof error === "object" && error !== null && "response" in error) {
|
||||||
|
|
@ -43,6 +44,9 @@ export default function KnowledgeDocumentsPage() {
|
||||||
const [sortKey, setSortKey] = useState("created_at");
|
const [sortKey, setSortKey] = useState("created_at");
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
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 [selected, setSelected] = useState<KnowledgeDocument | null>(null);
|
||||||
const [deleteItem, setDeleteItem] = useState<KnowledgeDocument | null>(null);
|
const [deleteItem, setDeleteItem] = useState<KnowledgeDocument | null>(null);
|
||||||
|
|
||||||
|
|
@ -82,6 +86,17 @@ export default function KnowledgeDocumentsPage() {
|
||||||
return sortDirection === "asc" ? result : -result;
|
return sortDirection === "asc" ? result : -result;
|
||||||
}), [documents, manufacturerFilter, search, sortDirection, sortKey, typeFilter]);
|
}), [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>[] = [
|
const columns: DataTableColumn<KnowledgeDocument>[] = [
|
||||||
{ key: "title", label: "Titel", sortable: true, render: (item) => <span className="font-medium">{item.title}</span> },
|
{ key: "title", label: "Titel", sortable: true, render: (item) => <span className="font-medium">{item.title}</span> },
|
||||||
{ key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer.name },
|
{ key: "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) {
|
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);
|
setPending(true);
|
||||||
setFormError("");
|
setFormError("");
|
||||||
try {
|
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() {
|
async function confirmDelete() {
|
||||||
if (!deleteItem) return;
|
if (!deleteItem) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
|
|
@ -153,7 +218,7 @@ export default function KnowledgeDocumentsPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<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>
|
<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>
|
||||||
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row">
|
<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" />
|
<SearchInput value={search} onChange={setSearch} placeholder="Dokumente suchen" />
|
||||||
|
|
@ -174,9 +239,54 @@ export default function KnowledgeDocumentsPage() {
|
||||||
<option value="other">Sonstiges</option>
|
<option value="other">Sonstiges</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{!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"); }} />
|
<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}>
|
<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>}
|
{deleteItem && <p className="text-sm font-medium">{deleteItem.title}</p>}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Edit, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||||
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
import SearchInput from "@/components/common/SearchInput";
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
import { ManufacturerFormDialog } from "@/components/knowledge/KnowledgeForms";
|
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>
|
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Hersteller</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-white p-4"><SearchInput value={search} onChange={setSearch} placeholder="Hersteller suchen" /></div>
|
<div className="rounded-lg border bg-white p-4"><SearchInput value={search} onChange={setSearch} placeholder="Hersteller suchen" /></div>
|
||||||
|
{!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"); }} />
|
<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} />
|
<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}>
|
<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>}
|
{deleteItem && <p className="text-sm font-medium">{deleteItem.name}</p>}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Edit, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||||
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
import SearchInput from "@/components/common/SearchInput";
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
import { KnowledgeNoteFormDialog } from "@/components/knowledge/KnowledgeForms";
|
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>
|
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Notiz</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-white p-4"><SearchInput value={search} onChange={setSearch} placeholder="Notizen suchen" /></div>
|
<div className="rounded-lg border bg-white p-4"><SearchInput value={search} onChange={setSearch} placeholder="Notizen suchen" /></div>
|
||||||
|
{!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"); }} />
|
<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} />
|
<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}>
|
<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>}
|
{deleteItem && <p className="text-sm font-medium">{deleteItem.title}</p>}
|
||||||
|
|
|
||||||
19
frontend/athena/components/common/EmptyState.tsx
Normal file
19
frontend/athena/components/common/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -101,6 +101,7 @@ export function DeviceFormDialog({
|
||||||
open,
|
open,
|
||||||
device,
|
device,
|
||||||
manufacturers,
|
manufacturers,
|
||||||
|
initialManufacturerId,
|
||||||
pending,
|
pending,
|
||||||
error,
|
error,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -109,6 +110,7 @@ export function DeviceFormDialog({
|
||||||
open: boolean;
|
open: boolean;
|
||||||
device: KnowledgeDevice | null;
|
device: KnowledgeDevice | null;
|
||||||
manufacturers: KnowledgeManufacturer[];
|
manufacturers: KnowledgeManufacturer[];
|
||||||
|
initialManufacturerId?: number | null;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -137,7 +139,7 @@ export function DeviceFormDialog({
|
||||||
production_year_to: device.production_year_to,
|
production_year_to: device.production_year_to,
|
||||||
notes: device.notes,
|
notes: device.notes,
|
||||||
} : {
|
} : {
|
||||||
manufacturer_id: manufacturers[0]?.id ?? 0,
|
manufacturer_id: initialManufacturerId ?? manufacturers[0]?.id ?? 0,
|
||||||
name: "",
|
name: "",
|
||||||
model_number: "",
|
model_number: "",
|
||||||
device_type: "",
|
device_type: "",
|
||||||
|
|
@ -147,7 +149,7 @@ export function DeviceFormDialog({
|
||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [device, manufacturers, open]);
|
}, [device, initialManufacturerId, manufacturers, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
|
@ -178,19 +180,23 @@ export function DocumentUploadDialog({
|
||||||
document,
|
document,
|
||||||
manufacturers,
|
manufacturers,
|
||||||
devices,
|
devices,
|
||||||
|
initialManufacturerId,
|
||||||
pending,
|
pending,
|
||||||
error,
|
error,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onValidationError,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
document: KnowledgeDocument | null;
|
document: KnowledgeDocument | null;
|
||||||
manufacturers: KnowledgeManufacturer[];
|
manufacturers: KnowledgeManufacturer[];
|
||||||
devices: KnowledgeDevice[];
|
devices: KnowledgeDevice[];
|
||||||
|
initialManufacturerId?: number | null;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSubmit: (payload: DocumentPayload, file: File | null) => void;
|
onSubmit: (payload: DocumentPayload, file: File | null) => void;
|
||||||
|
onValidationError?: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [payload, setPayload] = useState<DocumentPayload>({
|
const [payload, setPayload] = useState<DocumentPayload>({
|
||||||
manufacturer_id: 0,
|
manufacturer_id: 0,
|
||||||
|
|
@ -205,6 +211,7 @@ export function DocumentUploadDialog({
|
||||||
});
|
});
|
||||||
const [tags, setTags] = useState("");
|
const [tags, setTags] = useState("");
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
|
@ -219,7 +226,7 @@ export function DocumentUploadDialog({
|
||||||
description: document.description,
|
description: document.description,
|
||||||
tags: document.tags,
|
tags: document.tags,
|
||||||
} : {
|
} : {
|
||||||
manufacturer_id: manufacturers[0]?.id ?? 0,
|
manufacturer_id: initialManufacturerId ?? manufacturers[0]?.id ?? 0,
|
||||||
device_id: null,
|
device_id: null,
|
||||||
title: "",
|
title: "",
|
||||||
document_type: "service_manual",
|
document_type: "service_manual",
|
||||||
|
|
@ -231,31 +238,92 @@ export function DocumentUploadDialog({
|
||||||
});
|
});
|
||||||
setTags(document ? tagsToText(document.tags) : "");
|
setTags(document ? tagsToText(document.tags) : "");
|
||||||
setFile(null);
|
setFile(null);
|
||||||
|
setFieldErrors({});
|
||||||
});
|
});
|
||||||
}, [document, manufacturers, open]);
|
}, [document, initialManufacturerId, manufacturers, open]);
|
||||||
|
|
||||||
const filteredDevices = devices.filter((device) => device.manufacturer_id === payload.manufacturer_id);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-3xl">
|
<DialogContent className="sm:max-w-3xl">
|
||||||
<DialogHeader><DialogTitle>{document ? "Dokument bearbeiten" : "Dokument hochladen"}</DialogTitle></DialogHeader>
|
<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="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()}>
|
||||||
<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>
|
<Label>Hersteller</Label>
|
||||||
<div className={fieldClass()}><Label>Titel</Label><Input value={payload.title} onChange={(event) => setPayload({ ...payload, title: event.target.value })} /></div>
|
<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 })}>
|
||||||
<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>
|
<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>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>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>
|
<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={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>
|
<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>}
|
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
<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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue