feat(knowledge): add service library

This commit is contained in:
DS | Schubert 2026-07-03 10:25:52 +02:00
parent ecab0fe6b6
commit 228da8f814
32 changed files with 2878 additions and 6 deletions

View file

@ -12,3 +12,5 @@ INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_FIRST_NAME=
INITIAL_ADMIN_LAST_NAME=
KNOWLEDGE_STORAGE_PATH=/data/knowledge
KNOWLEDGE_MAX_UPLOAD_MB=50

View file

@ -511,6 +511,69 @@ Athena nutzt einen globalen Toast-Provider fuer konsistente Erfolgs- und Fehlerm
Aktuell werden Toasts in den CRUD-Flows fuer Benutzer, Rollen, Kunden und Ansprechpartner genutzt. Inline-Fehler bleiben dort erhalten, wo sie fuer Formulare und Dialoge hilfreich sind.
## Wissensdatenbank / Service Library
v0.6.0 fuehrt das Modul Wissensdatenbank ein. Es verwaltet Hersteller, Geraete und Modelle, Dokumente sowie Reparaturhinweise fuer Funktechnik, Elektronikreparatur und Werkstattbetrieb.
Hermes stellt dafuer Tabellen mit dem Prefix `knowledge_` bereit:
- `knowledge_manufacturers`
- `knowledge_devices`
- `knowledge_documents`
- `knowledge_notes`
- `knowledge_categories`
Athena stellt die BFF-Routen unter `/api/knowledge/...` bereit. Der Browser spricht weiterhin ausschliesslich mit Athena.
### Knowledge-Dateispeicherung
Dokumentuploads werden in v0.6.0 lokal im Hermes-Container oder in einem gemounteten Volume gespeichert.
Konfiguration:
- `KNOWLEDGE_STORAGE_PATH`, Default `/data/knowledge`
- `KNOWLEDGE_MAX_UPLOAD_MB`, Default `50`
Docker Compose bindet das persistente Volume `knowledge-data` nach `/data/knowledge` ein. Dieses Volume darf nicht geloescht werden, wenn lokale Knowledge-Dateien erhalten bleiben sollen.
Erlaubte Uploadtypen:
- PDF
- JPG/JPEG
- PNG
- WEBP
- TXT
- ZIP
Hermes normalisiert Dateinamen, validiert Extension und MIME-Type, begrenzt die Uploadgroesse und erzwingt, dass gespeicherte und heruntergeladene Dateien innerhalb von `KNOWLEDGE_STORAGE_PATH` liegen.
### Knowledge-RBAC
Neue Berechtigungen:
- `knowledge.read`
- `knowledge.create`
- `knowledge.update`
- `knowledge.delete`
- `knowledge.upload`
- `knowledge.download`
Standardrollen:
- `administrator`: alle Knowledge-Rechte
- `management`: `knowledge.read`, `knowledge.download`
- `technician`: `knowledge.read`, `knowledge.create`, `knowledge.update`, `knowledge.upload`, `knowledge.download`
- `support`: `knowledge.read`, `knowledge.download`
- `guest`: keine Knowledge-Rechte
Hermes erzwingt diese Rechte serverseitig. Athena nutzt sie nur fuer Navigation und UI.
### Paperless-Vorbereitung
Eine echte Paperless-ngx-Integration ist in v0.6.0 nicht aktiv. Dokumente enthalten bereits `paperless_document_id` und `external_url`, damit spaeter ein PaperlessConnector angebunden werden kann. API-Keys oder verpflichtende Paperless-Umgebungsvariablen werden in v0.6.0 nicht eingefuehrt.
Spaetere Verknuepfungen sind fuer Kunden, Projekte, Tickets und Reparaturen vorgesehen.
## Verzeichnisstruktur
```text
@ -564,6 +627,12 @@ docker-compose.yml
`LOG_LEVEL`
: Runtime-Loglevel fuer Hermes, z. B. `INFO`, `WARNING` oder `ERROR`.
`KNOWLEDGE_STORAGE_PATH`
: Lokales Speicherverzeichnis fuer Knowledge-Dateien. Default `/data/knowledge`.
`KNOWLEDGE_MAX_UPLOAD_MB`
: Maximale Uploadgroesse fuer Knowledge-Dokumente in MB. Default `50`.
### Athena
`HERMES_INTERNAL_URL`

View file

@ -220,10 +220,35 @@ Importmodi:
Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`.
### Wissensdatenbank
Das Knowledge-Modul folgt dem bestehenden Modulstandard:
- SQLAlchemy-Modell in `backend/hermes/app/models/knowledge.py`
- Pydantic-Schemas in `backend/hermes/app/schemas/knowledge.py`
- Repository in `backend/hermes/app/repositories/knowledge_repository.py`
- Service-Logik in `backend/hermes/app/services/knowledge_service.py`
- FastAPI-Router in `backend/hermes/app/api/knowledge.py`
- Athena-BFF unter `/api/knowledge/...`
- Frontend-Seiten unter `/knowledge`
Upload-Regeln:
- Browser sendet Dateien nur an Athena.
- Athena leitet FormData serverseitig an Hermes weiter.
- Hermes validiert Dateityp, MIME-Type, Dateigroesse und Speicherpfad.
- Lokale Dateien liegen unter `KNOWLEDGE_STORAGE_PATH`.
- Audit Logs duerfen keine Datei-Inhalte enthalten.
- Keine Pfade aus unvalidierten Benutzereingaben zusammensetzen.
Paperless-ngx ist nur vorbereitet. `paperless_document_id` und `external_url` duerfen gepflegt werden, aber es werden keine Paperless-Secrets oder API-Keys eingefuehrt.
### Deployment
Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen.
Knowledge-Dateien benoetigen ein persistentes Docker-Volume. Vor produktiven Deployments muessen `KNOWLEDGE_STORAGE_PATH` und `KNOWLEDGE_MAX_UPLOAD_MB` bewusst gesetzt oder die Defaults akzeptiert werden.
### Neue Permissions
Neue Module muessen eigene stabile Permission-Strings erhalten.

View file

@ -44,7 +44,18 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Excel-freundliche CSV-Vorlage ueber Athena
- Audit Log fuer Kundenimporte ohne CSV-Rohdaten
## v0.6.0 - Projektmodul, geplant
## v0.6.0 - Wissensdatenbank / Service Library
- Herstellerverwaltung
- Geraete- und Modellverwaltung
- Dokumentverwaltung mit lokalem Upload und Download
- Reparaturhinweise und Knowledge Notes
- Suche ueber Hersteller, Modelle, Dokumente und Notizen
- RBAC-Permissions fuer Knowledge-Lesen, Upload und Download
- Paperless-ngx vorbereitet ueber `paperless_document_id` und `external_url`
- Persistentes Docker-Volume fuer Knowledge-Dateien
## v0.7.0 - Projektmodul, geplant
- Projektstammdaten
- Projektstatus und Verantwortliche
@ -52,7 +63,7 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- RBAC-Permissions fuer Projekte
- Audit Logs fuer Projektaktionen
## v0.7.0 - Tickets, geplant
## v0.8.0 - Tickets, geplant
- Ticketverwaltung
- Status- und Prioritaetsmodell
@ -60,15 +71,19 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- RBAC-Permissions fuer Tickets
- Audit Logs fuer Ticketaktionen
## v0.8.0 - Kalender und Aufgaben, geplant
## v0.9.0 - Integrationen Paperless/Lexoffice, geplant
- Paperless-ngx Connector fuer Wissensdokumente
- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten
- Sichere Connector-Konfiguration ohne Browser-Secrets
- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen
## v1.0.0 - Kalender, Aufgaben und produktiver Beta-Release
- Aufgabenverwaltung
- Kalenderansicht
- Faelligkeiten und Verantwortlichkeiten
- Verknuepfung mit Kunden, Projekten und Tickets
## v1.0.0 - Produktiver Beta-Release
- Gehaertete Produktionskonfiguration
- Vollstaendige Migrationsstrategie
- Deployment-Dokumentation

View file

@ -13,3 +13,5 @@ INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_FIRST_NAME=
INITIAL_ADMIN_LAST_NAME=
KNOWLEDGE_STORAGE_PATH=/data/knowledge
KNOWLEDGE_MAX_UPLOAD_MB=50

View file

@ -0,0 +1,219 @@
"""create knowledge module
Revision ID: b6f3d8c1a920
Revises: a5e4b7c9d012
Create Date: 2026-07-03 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "b6f3d8c1a920"
down_revision: Union[str, Sequence[str], None] = "a5e4b7c9d012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
KNOWLEDGE_PERMISSIONS = [
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
("knowledge.update", "Wissen bearbeiten", "Wissensdatenbank-Einträge aktualisieren", "knowledge"),
("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"),
("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"),
("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"),
]
ROLE_PERMISSIONS = {
"administrator": [permission[0] for permission in KNOWLEDGE_PERMISSIONS],
"management": ["knowledge.read", "knowledge.download"],
"technician": ["knowledge.read", "knowledge.create", "knowledge.update", "knowledge.upload", "knowledge.download"],
"support": ["knowledge.read", "knowledge.download"],
}
def insert_permission(name: str, display_name: str, description: str, module: str) -> None:
op.execute(
sa.text(
"""
INSERT INTO permissions (name, display_name, description, module)
SELECT :name, :display_name, :description, :module
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE name = :name)
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
def grant_permission(role_name: str, permission_name: str) -> None:
op.execute(
sa.text(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.name = :permission_name
WHERE roles.name = :role_name
AND NOT EXISTS (
SELECT 1 FROM role_permissions
WHERE role_permissions.role_id = roles.id
AND role_permissions.permission_id = permissions.id
)
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def upgrade() -> None:
op.create_table(
"knowledge_manufacturers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("slug", sa.String(length=180), nullable=False),
sa.Column("website", sa.String(length=255), server_default="", nullable=False),
sa.Column("notes", sa.Text(), server_default="", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_knowledge_manufacturers_name"), "knowledge_manufacturers", ["name"], unique=True)
op.create_index(op.f("ix_knowledge_manufacturers_slug"), "knowledge_manufacturers", ["slug"], unique=True)
op.create_table(
"knowledge_categories",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("slug", sa.String(length=140), nullable=False),
sa.Column("description", sa.Text(), server_default="", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_knowledge_categories_name"), "knowledge_categories", ["name"], unique=True)
op.create_index(op.f("ix_knowledge_categories_slug"), "knowledge_categories", ["slug"], unique=True)
op.create_table(
"knowledge_devices",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=180), nullable=False),
sa.Column("slug", sa.String(length=200), nullable=False),
sa.Column("model_number", sa.String(length=120), server_default="", nullable=False),
sa.Column("device_type", sa.String(length=80), server_default="", nullable=False),
sa.Column("frequency_range", sa.String(length=120), server_default="", nullable=False),
sa.Column("production_year_from", sa.Integer(), nullable=True),
sa.Column("production_year_to", sa.Integer(), nullable=True),
sa.Column("notes", sa.Text(), server_default="", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("manufacturer_id", "slug", name="uq_knowledge_devices_manufacturer_slug"),
)
op.create_index(op.f("ix_knowledge_devices_manufacturer_id"), "knowledge_devices", ["manufacturer_id"], unique=False)
op.create_index(op.f("ix_knowledge_devices_name"), "knowledge_devices", ["name"], unique=False)
op.create_index(op.f("ix_knowledge_devices_slug"), "knowledge_devices", ["slug"], unique=False)
op.create_index(op.f("ix_knowledge_devices_model_number"), "knowledge_devices", ["model_number"], unique=False)
op.create_index(op.f("ix_knowledge_devices_device_type"), "knowledge_devices", ["device_type"], unique=False)
op.create_table(
"knowledge_documents",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
sa.Column("device_id", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("slug", sa.String(length=280), nullable=False),
sa.Column("document_type", sa.String(length=80), nullable=False),
sa.Column("language", sa.String(length=20), server_default="de", nullable=False),
sa.Column("file_name", sa.String(length=255), server_default="", nullable=False),
sa.Column("file_path", sa.String(length=500), server_default="", nullable=False),
sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False),
sa.Column("file_size", sa.Integer(), server_default="0", nullable=False),
sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False),
sa.Column("external_url", sa.String(length=500), server_default="", nullable=False),
sa.Column("paperless_document_id", sa.String(length=120), server_default="", nullable=False),
sa.Column("description", sa.Text(), server_default="", nullable=False),
sa.Column("tags", sa.JSON(), server_default="[]", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["device_id"], ["knowledge_devices.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_knowledge_documents_manufacturer_id"), "knowledge_documents", ["manufacturer_id"], unique=False)
op.create_index(op.f("ix_knowledge_documents_device_id"), "knowledge_documents", ["device_id"], unique=False)
op.create_index(op.f("ix_knowledge_documents_title"), "knowledge_documents", ["title"], unique=False)
op.create_index(op.f("ix_knowledge_documents_slug"), "knowledge_documents", ["slug"], unique=True)
op.create_index(op.f("ix_knowledge_documents_document_type"), "knowledge_documents", ["document_type"], unique=False)
op.create_index(op.f("ix_knowledge_documents_language"), "knowledge_documents", ["language"], unique=False)
op.create_index(op.f("ix_knowledge_documents_checksum_sha256"), "knowledge_documents", ["checksum_sha256"], unique=False)
op.create_index(op.f("ix_knowledge_documents_paperless_document_id"), "knowledge_documents", ["paperless_document_id"], unique=False)
op.create_table(
"knowledge_notes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=True),
sa.Column("device_id", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("note_type", sa.String(length=80), nullable=False),
sa.Column("severity", sa.String(length=40), server_default="", nullable=False),
sa.Column("tags", sa.JSON(), server_default="[]", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["device_id"], ["knowledge_devices.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_knowledge_notes_manufacturer_id"), "knowledge_notes", ["manufacturer_id"], unique=False)
op.create_index(op.f("ix_knowledge_notes_device_id"), "knowledge_notes", ["device_id"], unique=False)
op.create_index(op.f("ix_knowledge_notes_title"), "knowledge_notes", ["title"], unique=False)
op.create_index(op.f("ix_knowledge_notes_note_type"), "knowledge_notes", ["note_type"], unique=False)
op.create_index(op.f("ix_knowledge_notes_severity"), "knowledge_notes", ["severity"], unique=False)
for permission in KNOWLEDGE_PERMISSIONS:
insert_permission(*permission)
for role_name, permission_names in ROLE_PERMISSIONS.items():
for permission_name in permission_names:
grant_permission(role_name, permission_name)
def downgrade() -> None:
op.execute(
"""
DELETE FROM role_permissions
USING permissions
WHERE role_permissions.permission_id = permissions.id
AND permissions.module = 'knowledge'
"""
)
op.execute("DELETE FROM permissions WHERE module = 'knowledge'")
op.drop_index(op.f("ix_knowledge_notes_severity"), table_name="knowledge_notes")
op.drop_index(op.f("ix_knowledge_notes_note_type"), table_name="knowledge_notes")
op.drop_index(op.f("ix_knowledge_notes_title"), table_name="knowledge_notes")
op.drop_index(op.f("ix_knowledge_notes_device_id"), table_name="knowledge_notes")
op.drop_index(op.f("ix_knowledge_notes_manufacturer_id"), table_name="knowledge_notes")
op.drop_table("knowledge_notes")
op.drop_index(op.f("ix_knowledge_documents_paperless_document_id"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_checksum_sha256"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_language"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_document_type"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_slug"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_title"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_device_id"), table_name="knowledge_documents")
op.drop_index(op.f("ix_knowledge_documents_manufacturer_id"), table_name="knowledge_documents")
op.drop_table("knowledge_documents")
op.drop_index(op.f("ix_knowledge_devices_device_type"), table_name="knowledge_devices")
op.drop_index(op.f("ix_knowledge_devices_model_number"), table_name="knowledge_devices")
op.drop_index(op.f("ix_knowledge_devices_slug"), table_name="knowledge_devices")
op.drop_index(op.f("ix_knowledge_devices_name"), table_name="knowledge_devices")
op.drop_index(op.f("ix_knowledge_devices_manufacturer_id"), table_name="knowledge_devices")
op.drop_table("knowledge_devices")
op.drop_index(op.f("ix_knowledge_categories_slug"), table_name="knowledge_categories")
op.drop_index(op.f("ix_knowledge_categories_name"), table_name="knowledge_categories")
op.drop_table("knowledge_categories")
op.drop_index(op.f("ix_knowledge_manufacturers_slug"), table_name="knowledge_manufacturers")
op.drop_index(op.f("ix_knowledge_manufacturers_name"), table_name="knowledge_manufacturers")
op.drop_table("knowledge_manufacturers")

View file

@ -0,0 +1,336 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.user import User
from app.repositories.knowledge_repository import KnowledgeRepository
from app.schemas.knowledge import (
DocumentType,
KnowledgeDeviceCreate,
KnowledgeDeviceResponse,
KnowledgeDeviceUpdate,
KnowledgeDocumentCreate,
KnowledgeDocumentResponse,
KnowledgeDocumentUpdate,
KnowledgeManufacturerCreate,
KnowledgeManufacturerResponse,
KnowledgeManufacturerUpdate,
KnowledgeNoteCreate,
KnowledgeNoteResponse,
KnowledgeNoteUpdate,
KnowledgeSearchResult,
NoteType,
normalize_tags,
)
from app.services.audit_service import sanitize, write_audit_log
from app.services.knowledge_service import KnowledgeService, assert_safe_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/knowledge", tags=["Knowledge"])
def get_manufacturer_or_404(db: Session, manufacturer_id: int):
manufacturer = KnowledgeRepository.get_manufacturer(db, manufacturer_id)
if manufacturer is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
return manufacturer
def get_device_or_404(db: Session, device_id: int):
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
return device
def get_document_or_404(db: Session, document_id: int):
document = KnowledgeRepository.get_document(db, document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
return document
def get_note_or_404(db: Session, note_id: int):
note = KnowledgeRepository.get_note(db, note_id)
if note is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notiz nicht gefunden")
return note
def delete_or_conflict(db: Session, entity) -> None:
try:
db.delete(entity)
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag wird noch verwendet") from exc
@router.get("/manufacturers", response_model=list[KnowledgeManufacturerResponse])
def list_manufacturers(
q: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.manufacturers.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_manufacturers(db, q=q, limit=limit, offset=offset)
@router.get("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse)
def get_manufacturer(manufacturer_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.manufacturers.detail", extra={"actor_user_id": current_user.id, "target_manufacturer_id": manufacturer_id})
return get_manufacturer_or_404(db, manufacturer_id)
@router.post("/manufacturers", response_model=KnowledgeManufacturerResponse, status_code=status.HTTP_201_CREATED)
def create_manufacturer(
payload: KnowledgeManufacturerCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.create")),
):
manufacturer = KnowledgeService.create_manufacturer(db, payload)
write_audit_log(db, action="knowledge.manufacturers.create", entity_type="knowledge_manufacturers", entity_id=manufacturer.id, entity_label=manufacturer.name, actor=current_user, request=request, after_data=manufacturer)
return manufacturer
@router.put("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse)
def update_manufacturer(
manufacturer_id: int,
payload: KnowledgeManufacturerUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.update")),
):
manufacturer = get_manufacturer_or_404(db, manufacturer_id)
before_data = sanitize(manufacturer)
updated = KnowledgeService.update_manufacturer(db, manufacturer, payload)
write_audit_log(db, action="knowledge.manufacturers.update", entity_type="knowledge_manufacturers", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/manufacturers/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_manufacturer(
manufacturer_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.delete")),
):
manufacturer = get_manufacturer_or_404(db, manufacturer_id)
before_data = sanitize(manufacturer)
label = manufacturer.name
delete_or_conflict(db, manufacturer)
write_audit_log(db, action="knowledge.manufacturers.delete", entity_type="knowledge_manufacturers", entity_id=manufacturer_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/devices", response_model=list[KnowledgeDeviceResponse])
def list_devices(
q: str = "",
manufacturer_id: int | None = None,
device_type: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.devices.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_devices(db, q=q, manufacturer_id=manufacturer_id, device_type=device_type, limit=limit, offset=offset)
@router.get("/devices/{device_id}", response_model=KnowledgeDeviceResponse)
def get_device(device_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.devices.detail", extra={"actor_user_id": current_user.id, "target_device_id": device_id})
return get_device_or_404(db, device_id)
@router.post("/devices", response_model=KnowledgeDeviceResponse, status_code=status.HTTP_201_CREATED)
def create_device(payload: KnowledgeDeviceCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
device = KnowledgeService.create_device(db, payload)
write_audit_log(db, action="knowledge.devices.create", entity_type="knowledge_devices", entity_id=device.id, entity_label=device.name, actor=current_user, request=request, after_data=device)
return device
@router.put("/devices/{device_id}", response_model=KnowledgeDeviceResponse)
def update_device(device_id: int, payload: KnowledgeDeviceUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
device = get_device_or_404(db, device_id)
before_data = sanitize(device)
updated = KnowledgeService.update_device(db, device, payload)
write_audit_log(db, action="knowledge.devices.update", entity_type="knowledge_devices", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_device(device_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
device = get_device_or_404(db, device_id)
before_data = sanitize(device)
label = device.name
delete_or_conflict(db, device)
write_audit_log(db, action="knowledge.devices.delete", entity_type="knowledge_devices", entity_id=device_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/documents", response_model=list[KnowledgeDocumentResponse])
def list_documents(
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
document_type: str = "",
language: str = "",
tag: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.documents.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_documents(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, document_type=document_type, language=language, tag=tag, limit=limit, offset=offset)
@router.get("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse)
def get_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.documents.detail", extra={"actor_user_id": current_user.id, "target_document_id": document_id})
return get_document_or_404(db, document_id)
@router.post("/documents", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED)
def create_document(payload: KnowledgeDocumentCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
document = KnowledgeService.create_document(db, payload)
write_audit_log(db, action="knowledge.documents.create", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data=document)
return document
@router.post("/documents/upload", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_document(
request: Request,
manufacturer_id: int = Form(...),
title: str = Form(...),
document_type: DocumentType = Form(...),
file: UploadFile = File(...),
device_id: int | None = Form(default=None),
language: str = Form(default="de"),
external_url: str = Form(default=""),
paperless_document_id: str = Form(default=""),
description: str = Form(default=""),
tags: str = Form(default=""),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.upload")),
):
payload = KnowledgeDocumentCreate(
manufacturer_id=manufacturer_id,
device_id=device_id,
title=title,
document_type=document_type,
language=language,
external_url=external_url or None,
paperless_document_id=paperless_document_id,
description=description,
tags=normalize_tags(tags),
)
document = await KnowledgeService.upload_document(db, file=file, payload=payload)
write_audit_log(db, action="knowledge.documents.upload", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data={key: value for key, value in sanitize(document).items() if key != "file_path"})
return document
@router.put("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse)
def update_document(document_id: int, payload: KnowledgeDocumentUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
document = get_document_or_404(db, document_id)
before_data = sanitize(document)
updated = KnowledgeService.update_document(db, document, payload)
write_audit_log(db, action="knowledge.documents.update", entity_type="knowledge_documents", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/documents/{document_id:int}", status_code=status.HTTP_204_NO_CONTENT)
def delete_document(document_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
document = get_document_or_404(db, document_id)
before_data = sanitize(document)
label = document.title
delete_or_conflict(db, document)
write_audit_log(db, action="knowledge.documents.delete", entity_type="knowledge_documents", entity_id=document_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/documents/{document_id:int}/download")
def download_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.download"))):
document = get_document_or_404(db, document_id)
if not document.file_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument hat keine lokale Datei")
path = assert_safe_path(Path(document.file_path))
if not path.exists() or not path.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden")
logger.info("knowledge.documents.download", extra={"actor_user_id": current_user.id, "target_document_id": document_id})
return FileResponse(path, media_type=document.mime_type or "application/octet-stream", filename=document.file_name)
@router.get("/notes", response_model=list[KnowledgeNoteResponse])
def list_notes(
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
note_type: str = "",
tag: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.notes.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_notes(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, note_type=note_type, tag=tag, limit=limit, offset=offset)
@router.get("/notes/{note_id}", response_model=KnowledgeNoteResponse)
def get_note(note_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.notes.detail", extra={"actor_user_id": current_user.id, "target_note_id": note_id})
return get_note_or_404(db, note_id)
@router.post("/notes", response_model=KnowledgeNoteResponse, status_code=status.HTTP_201_CREATED)
def create_note(payload: KnowledgeNoteCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
note = KnowledgeService.create_note(db, payload)
write_audit_log(db, action="knowledge.notes.create", entity_type="knowledge_notes", entity_id=note.id, entity_label=note.title, actor=current_user, request=request, after_data=note)
return note
@router.put("/notes/{note_id}", response_model=KnowledgeNoteResponse)
def update_note(note_id: int, payload: KnowledgeNoteUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
note = get_note_or_404(db, note_id)
before_data = sanitize(note)
updated = KnowledgeService.update_note(db, note, payload)
write_audit_log(db, action="knowledge.notes.update", entity_type="knowledge_notes", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_note(note_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
note = get_note_or_404(db, note_id)
before_data = sanitize(note)
label = note.title
delete_or_conflict(db, note)
write_audit_log(db, action="knowledge.notes.delete", entity_type="knowledge_notes", entity_id=note_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/search", response_model=list[KnowledgeSearchResult])
def search_knowledge(q: str = Query(..., min_length=1), db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.search", extra={"actor_user_id": current_user.id})
manufacturers = KnowledgeRepository.list_manufacturers(db, q=q, limit=10, offset=0)
devices = KnowledgeRepository.list_devices(db, q=q, limit=10, offset=0)
documents = KnowledgeRepository.list_documents(db, q=q, limit=10, offset=0)
notes = KnowledgeRepository.list_notes(db, q=q, limit=10, offset=0)
results: list[KnowledgeSearchResult] = []
results.extend(KnowledgeSearchResult(entity_type="manufacturer", id=item.id, title=item.name, href=f"/knowledge/manufacturers?selected={item.id}") for item in manufacturers)
results.extend(KnowledgeSearchResult(entity_type="device", id=item.id, title=item.name, subtitle=item.model_number, href=f"/knowledge/devices/{item.id}") for item in devices)
results.extend(KnowledgeSearchResult(entity_type="document", id=item.id, title=item.title, subtitle=item.document_type, href=f"/knowledge/documents/{item.id}") for item in documents)
results.extend(KnowledgeSearchResult(entity_type="note", id=item.id, title=item.title, subtitle=item.note_type, href=f"/knowledge/notes?selected={item.id}") for item in notes)
return results[:30]

View file

@ -15,6 +15,8 @@ class Settings(BaseSettings):
initial_admin_password: str | None = None
initial_admin_first_name: str = ""
initial_admin_last_name: str = ""
knowledge_storage_path: str = "/data/knowledge"
knowledge_max_upload_mb: int = 50
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -22,6 +22,7 @@ class Base(DeclarativeBase):
# <<< HIER IMPORTIEREN >>>
import app.models.rbac
import app.models.customer
import app.models.knowledge
import app.models.audit
import app.models.user

View file

@ -13,6 +13,7 @@ from app.api.auth import router as auth_router
from app.api.audit import router as audit_router
from app.api.customers import router as customers_router
from app.api.dashboard import router as dashboard_router
from app.api.knowledge import router as knowledge_router
from app.api.permissions import router as permissions_router
from app.api.roles import router as roles_router
from app.api.users import router as users_router
@ -36,6 +37,7 @@ app.include_router(users_router)
app.include_router(roles_router)
app.include_router(permissions_router)
app.include_router(customers_router)
app.include_router(knowledge_router)
app.include_router(dashboard_router)
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,101 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
class KnowledgeManufacturer(Base):
__tablename__ = "knowledge_manufacturers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
website: Mapped[str] = mapped_column(String(255), default="", server_default="")
notes: Mapped[str] = mapped_column(Text, default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
devices: Mapped[list["KnowledgeDevice"]] = relationship(back_populates="manufacturer", cascade="all, delete-orphan")
documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="manufacturer")
notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="manufacturer")
class KnowledgeDevice(Base):
__tablename__ = "knowledge_devices"
__table_args__ = (
UniqueConstraint("manufacturer_id", "slug", name="uq_knowledge_devices_manufacturer_slug"),
)
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(180), index=True)
slug: Mapped[str] = mapped_column(String(200), index=True)
model_number: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True)
device_type: Mapped[str] = mapped_column(String(80), default="", server_default="", index=True)
frequency_range: Mapped[str] = mapped_column(String(120), default="", server_default="")
production_year_from: Mapped[int | None] = mapped_column(Integer, nullable=True)
production_year_to: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="devices")
documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="device")
notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="device")
class KnowledgeDocument(Base):
__tablename__ = "knowledge_documents"
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="RESTRICT"), index=True)
device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255), index=True)
slug: Mapped[str] = mapped_column(String(280), unique=True, index=True)
document_type: Mapped[str] = mapped_column(String(80), index=True)
language: Mapped[str] = mapped_column(String(20), default="de", server_default="", index=True)
file_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
file_path: Mapped[str] = mapped_column(String(500), default="", server_default="")
mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
file_size: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
external_url: Mapped[str] = mapped_column(String(500), default="", server_default="")
paperless_document_id: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True)
description: Mapped[str] = mapped_column(Text, default="", server_default="")
tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="documents")
device: Mapped[KnowledgeDevice | None] = relationship(back_populates="documents")
class KnowledgeNote(Base):
__tablename__ = "knowledge_notes"
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="SET NULL"), nullable=True, index=True)
device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255), index=True)
content: Mapped[str] = mapped_column(Text)
note_type: Mapped[str] = mapped_column(String(80), index=True)
severity: Mapped[str] = mapped_column(String(40), default="", server_default="", index=True)
tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
manufacturer: Mapped[KnowledgeManufacturer | None] = relationship(back_populates="notes_rel")
device: Mapped[KnowledgeDevice | None] = relationship(back_populates="notes_rel")
class KnowledgeCategory(Base):
__tablename__ = "knowledge_categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(140), unique=True, index=True)
description: Mapped[str] = mapped_column(Text, default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View file

@ -70,6 +70,12 @@ STANDARD_PERMISSIONS = [
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
("knowledge.update", "Wissen bearbeiten", "Wissensdatenbank-Einträge aktualisieren", "knowledge"),
("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"),
("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"),
("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"),
]
ROLE_PERMISSION_NAMES = {
@ -78,6 +84,8 @@ ROLE_PERMISSION_NAMES = {
"dashboard.read",
"users.read",
"customers.read",
"knowledge.read",
"knowledge.download",
"projects.read",
"tickets.read",
},
@ -95,11 +103,18 @@ ROLE_PERMISSION_NAMES = {
"tickets.read",
"tickets.create",
"tickets.update",
"knowledge.read",
"knowledge.create",
"knowledge.update",
"knowledge.upload",
"knowledge.download",
},
"support": {
"dashboard.read",
"users.read",
"customers.read",
"knowledge.read",
"knowledge.download",
"tickets.read",
"tickets.create",
"tickets.update",

View file

@ -0,0 +1,177 @@
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, joinedload
from app.models.knowledge import (
KnowledgeDevice,
KnowledgeDocument,
KnowledgeManufacturer,
KnowledgeNote,
)
def paginate(query, limit: int, offset: int):
return query.limit(max(1, min(limit, 100))).offset(max(0, offset))
class KnowledgeRepository:
@staticmethod
def list_manufacturers(db: Session, *, q: str = "", limit: int = 50, offset: int = 0) -> list[KnowledgeManufacturer]:
query = select(KnowledgeManufacturer).order_by(KnowledgeManufacturer.name)
if q:
pattern = f"%{q.lower()}%"
query = query.where(func.lower(KnowledgeManufacturer.name).like(pattern))
return list(db.scalars(paginate(query, limit, offset)))
@staticmethod
def get_manufacturer(db: Session, manufacturer_id: int) -> KnowledgeManufacturer | None:
return db.get(KnowledgeManufacturer, manufacturer_id)
@staticmethod
def get_manufacturer_by_name(db: Session, name: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None:
query = select(KnowledgeManufacturer).where(func.lower(KnowledgeManufacturer.name) == name.lower())
if exclude_id is not None:
query = query.where(KnowledgeManufacturer.id != exclude_id)
return db.scalar(query)
@staticmethod
def get_manufacturer_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None:
query = select(KnowledgeManufacturer).where(KnowledgeManufacturer.slug == slug)
if exclude_id is not None:
query = query.where(KnowledgeManufacturer.id != exclude_id)
return db.scalar(query)
@staticmethod
def list_devices(
db: Session,
*,
q: str = "",
manufacturer_id: int | None = None,
device_type: str = "",
limit: int = 50,
offset: int = 0,
) -> list[KnowledgeDevice]:
query = select(KnowledgeDevice).options(joinedload(KnowledgeDevice.manufacturer)).order_by(KnowledgeDevice.name)
if manufacturer_id is not None:
query = query.where(KnowledgeDevice.manufacturer_id == manufacturer_id)
if device_type:
query = query.where(KnowledgeDevice.device_type == device_type)
if q:
pattern = f"%{q.lower()}%"
query = query.where(
or_(
func.lower(KnowledgeDevice.name).like(pattern),
func.lower(KnowledgeDevice.model_number).like(pattern),
)
)
return list(db.scalars(paginate(query, limit, offset)))
@staticmethod
def get_device(db: Session, device_id: int) -> KnowledgeDevice | None:
return db.scalar(
select(KnowledgeDevice)
.where(KnowledgeDevice.id == device_id)
.options(joinedload(KnowledgeDevice.manufacturer))
)
@staticmethod
def get_device_by_slug(
db: Session,
*,
manufacturer_id: int,
slug: str,
exclude_id: int | None = None,
) -> KnowledgeDevice | None:
query = select(KnowledgeDevice).where(KnowledgeDevice.manufacturer_id == manufacturer_id, KnowledgeDevice.slug == slug)
if exclude_id is not None:
query = query.where(KnowledgeDevice.id != exclude_id)
return db.scalar(query)
@staticmethod
def list_documents(
db: Session,
*,
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
document_type: str = "",
language: str = "",
tag: str = "",
limit: int = 50,
offset: int = 0,
) -> list[KnowledgeDocument]:
query = (
select(KnowledgeDocument)
.options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer))
.order_by(KnowledgeDocument.created_at.desc())
)
if manufacturer_id is not None:
query = query.where(KnowledgeDocument.manufacturer_id == manufacturer_id)
if device_id is not None:
query = query.where(KnowledgeDocument.device_id == device_id)
if document_type:
query = query.where(KnowledgeDocument.document_type == document_type)
if language:
query = query.where(KnowledgeDocument.language == language)
if q:
pattern = f"%{q.lower()}%"
query = query.where(func.lower(KnowledgeDocument.title).like(pattern))
documents = list(db.scalars(paginate(query, limit, offset)))
if tag:
tag_lower = tag.lower()
documents = [document for document in documents if any(item.lower() == tag_lower for item in document.tags)]
return documents
@staticmethod
def get_document(db: Session, document_id: int) -> KnowledgeDocument | None:
return db.scalar(
select(KnowledgeDocument)
.where(KnowledgeDocument.id == document_id)
.options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer))
)
@staticmethod
def get_document_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeDocument | None:
query = select(KnowledgeDocument).where(KnowledgeDocument.slug == slug)
if exclude_id is not None:
query = query.where(KnowledgeDocument.id != exclude_id)
return db.scalar(query)
@staticmethod
def list_notes(
db: Session,
*,
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
note_type: str = "",
tag: str = "",
limit: int = 50,
offset: int = 0,
) -> list[KnowledgeNote]:
query = (
select(KnowledgeNote)
.options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer))
.order_by(KnowledgeNote.created_at.desc())
)
if manufacturer_id is not None:
query = query.where(KnowledgeNote.manufacturer_id == manufacturer_id)
if device_id is not None:
query = query.where(KnowledgeNote.device_id == device_id)
if note_type:
query = query.where(KnowledgeNote.note_type == note_type)
if q:
pattern = f"%{q.lower()}%"
query = query.where(or_(func.lower(KnowledgeNote.title).like(pattern), func.lower(KnowledgeNote.content).like(pattern)))
notes = list(db.scalars(paginate(query, limit, offset)))
if tag:
tag_lower = tag.lower()
notes = [note for note in notes if any(item.lower() == tag_lower for item in note.tags)]
return notes
@staticmethod
def get_note(db: Session, note_id: int) -> KnowledgeNote | None:
return db.scalar(
select(KnowledgeNote)
.where(KnowledgeNote.id == note_id)
.options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer))
)

View file

@ -0,0 +1,200 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
DocumentType = Literal[
"manual",
"service_manual",
"schematic",
"alignment",
"parts_list",
"firmware",
"datasheet",
"service_bulletin",
"other",
]
NoteType = Literal["repair", "known_fault", "alignment", "spare_part", "general"]
NoteSeverity = Literal["low", "medium", "high", "critical", ""]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def normalize_tags(value: object) -> list[str]:
if value is None or value == "":
return []
if isinstance(value, str):
parts = value.split(",")
elif isinstance(value, list):
parts = value
else:
parts = []
seen: set[str] = set()
tags: list[str] = []
for part in parts:
tag = str(part).strip()
key = tag.lower()
if tag and key not in seen:
seen.add(key)
tags.append(tag)
return tags
class KnowledgeManufacturerBase(BaseModel):
name: str = Field(min_length=2, max_length=160)
website: HttpUrl | None = None
notes: str = ""
@field_validator("name", "notes", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
class KnowledgeManufacturerCreate(KnowledgeManufacturerBase):
pass
class KnowledgeManufacturerUpdate(KnowledgeManufacturerBase):
pass
class KnowledgeManufacturerResponse(KnowledgeManufacturerBase):
id: int
slug: str
website: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class KnowledgeDeviceBase(BaseModel):
manufacturer_id: int
name: str = Field(min_length=2, max_length=180)
model_number: str = Field(default="", max_length=120)
device_type: str = Field(default="", max_length=80)
frequency_range: str = Field(default="", max_length=120)
production_year_from: int | None = Field(default=None, ge=1900, le=2100)
production_year_to: int | None = Field(default=None, ge=1900, le=2100)
notes: str = ""
@field_validator("name", "model_number", "device_type", "frequency_range", "notes", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
class KnowledgeDeviceCreate(KnowledgeDeviceBase):
pass
class KnowledgeDeviceUpdate(KnowledgeDeviceBase):
pass
class KnowledgeDeviceResponse(KnowledgeDeviceBase):
id: int
slug: str
created_at: datetime
updated_at: datetime
manufacturer: KnowledgeManufacturerResponse
model_config = ConfigDict(from_attributes=True)
class KnowledgeDocumentBase(BaseModel):
manufacturer_id: int
device_id: int | None = None
title: str = Field(min_length=2, max_length=255)
document_type: DocumentType
language: str = Field(default="de", min_length=2, max_length=20)
external_url: HttpUrl | None = None
paperless_document_id: str = Field(default="", max_length=120)
description: str = ""
tags: list[str] = Field(default_factory=list)
@field_validator("title", "language", "paperless_document_id", "description", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
@field_validator("tags", mode="before")
@classmethod
def normalize_tag_fields(cls, value: object) -> list[str]:
return normalize_tags(value)
class KnowledgeDocumentCreate(KnowledgeDocumentBase):
pass
class KnowledgeDocumentUpdate(KnowledgeDocumentBase):
pass
class KnowledgeDocumentResponse(KnowledgeDocumentBase):
id: int
slug: str
external_url: str
file_name: str
file_path: str
mime_type: str
file_size: int
checksum_sha256: str
created_at: datetime
updated_at: datetime
manufacturer: KnowledgeManufacturerResponse
device: KnowledgeDeviceResponse | None = None
model_config = ConfigDict(from_attributes=True)
class KnowledgeNoteBase(BaseModel):
manufacturer_id: int | None = None
device_id: int | None = None
title: str = Field(min_length=2, max_length=255)
content: str = Field(min_length=1)
note_type: NoteType = "general"
severity: NoteSeverity = ""
tags: list[str] = Field(default_factory=list)
@field_validator("title", "content", "severity", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
@field_validator("tags", mode="before")
@classmethod
def normalize_tag_fields(cls, value: object) -> list[str]:
return normalize_tags(value)
class KnowledgeNoteCreate(KnowledgeNoteBase):
pass
class KnowledgeNoteUpdate(KnowledgeNoteBase):
pass
class KnowledgeNoteResponse(KnowledgeNoteBase):
id: int
created_at: datetime
updated_at: datetime
manufacturer: KnowledgeManufacturerResponse | None = None
device: KnowledgeDeviceResponse | None = None
model_config = ConfigDict(from_attributes=True)
class KnowledgeSearchResult(BaseModel):
entity_type: str
id: int
title: str
subtitle: str = ""
href: str

View file

@ -131,6 +131,19 @@ def action_title(action: str) -> str:
"customer_contacts.update": "Ansprechpartner bearbeitet",
"customer_contacts.delete": "Ansprechpartner gelöscht",
"users.initial_admin_bootstrap": "Initialer Administrator erstellt",
"knowledge.manufacturers.create": "Hersteller erstellt",
"knowledge.manufacturers.update": "Hersteller bearbeitet",
"knowledge.manufacturers.delete": "Hersteller gelöscht",
"knowledge.devices.create": "Gerät erstellt",
"knowledge.devices.update": "Gerät bearbeitet",
"knowledge.devices.delete": "Gerät gelöscht",
"knowledge.documents.create": "Dokument erstellt",
"knowledge.documents.upload": "Dokument hochgeladen",
"knowledge.documents.update": "Dokument bearbeitet",
"knowledge.documents.delete": "Dokument gelöscht",
"knowledge.notes.create": "Wissensnotiz erstellt",
"knowledge.notes.update": "Wissensnotiz bearbeitet",
"knowledge.notes.delete": "Wissensnotiz gelöscht",
}
return labels.get(action, action)

View file

@ -0,0 +1,247 @@
import hashlib
import mimetypes
import re
import uuid
from pathlib import Path
from fastapi import HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.knowledge import KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, KnowledgeNote
from app.repositories.knowledge_repository import KnowledgeRepository
from app.schemas.knowledge import (
KnowledgeDeviceCreate,
KnowledgeDeviceUpdate,
KnowledgeDocumentCreate,
KnowledgeDocumentUpdate,
KnowledgeManufacturerCreate,
KnowledgeManufacturerUpdate,
KnowledgeNoteCreate,
KnowledgeNoteUpdate,
)
ALLOWED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".txt", ".zip"}
ALLOWED_MIME_TYPES = {
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"text/plain",
"application/zip",
"application/x-zip-compressed",
}
def slugify(value: str) -> str:
normalized = value.strip().lower()
normalized = normalized.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
normalized = normalized.strip("-")
return normalized or uuid.uuid4().hex[:10]
def unique_slug(db: Session, base: str, exists) -> str:
slug = slugify(base)
candidate = slug
index = 2
while exists(db, candidate):
candidate = f"{slug}-{index}"
index += 1
return candidate
def storage_root() -> Path:
root = Path(settings.knowledge_storage_path).resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def assert_safe_path(path: Path) -> Path:
root = storage_root()
resolved = path.resolve()
if root != resolved and root not in resolved.parents:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad")
return resolved
def safe_file_name(file_name: str) -> str:
name = Path(file_name).name.strip()
stem = slugify(Path(name).stem)
suffix = Path(name).suffix.lower()
return f"{stem}{suffix}" if suffix else stem
def parse_tags(value: str) -> list[str]:
seen: set[str] = set()
tags: list[str] = []
for item in value.split(","):
tag = item.strip()
key = tag.lower()
if tag and key not in seen:
seen.add(key)
tags.append(tag)
return tags
async def read_upload(file: UploadFile) -> tuple[bytes, str, str]:
original_name = file.filename or ""
file_name = safe_file_name(original_name)
extension = Path(file_name).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt")
max_bytes = max(1, settings.knowledge_max_upload_mb) * 1024 * 1024
content = await file.read(max_bytes + 1)
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload-Datei ist leer")
if len(content) > max_bytes:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Upload-Datei ist zu groß")
mime_type = file.content_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream"
if mime_type not in ALLOWED_MIME_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt")
return content, file_name, mime_type
class KnowledgeService:
@staticmethod
def create_manufacturer(db: Session, payload: KnowledgeManufacturerCreate) -> KnowledgeManufacturer:
if KnowledgeRepository.get_manufacturer_by_name(db, payload.name):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben")
slug = unique_slug(db, payload.name, lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value) is not None)
manufacturer = KnowledgeManufacturer(name=payload.name, slug=slug, website=str(payload.website or ""), notes=payload.notes)
db.add(manufacturer)
db.commit()
db.refresh(manufacturer)
return manufacturer
@staticmethod
def update_manufacturer(db: Session, manufacturer: KnowledgeManufacturer, payload: KnowledgeManufacturerUpdate) -> KnowledgeManufacturer:
if KnowledgeRepository.get_manufacturer_by_name(db, payload.name, manufacturer.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben")
manufacturer.name = payload.name
manufacturer.slug = unique_slug(
db,
payload.name,
lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value, manufacturer.id) is not None,
)
manufacturer.website = str(payload.website or "")
manufacturer.notes = payload.notes
db.commit()
db.refresh(manufacturer)
return manufacturer
@staticmethod
def create_device(db: Session, payload: KnowledgeDeviceCreate) -> KnowledgeDevice:
if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
slug = slugify(payload.model_number or payload.name)
if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben")
device = KnowledgeDevice(slug=slug, **payload.model_dump())
db.add(device)
db.commit()
return KnowledgeRepository.get_device(db, device.id) or device
@staticmethod
def update_device(db: Session, device: KnowledgeDevice, payload: KnowledgeDeviceUpdate) -> KnowledgeDevice:
if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
slug = slugify(payload.model_number or payload.name)
if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug, exclude_id=device.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben")
for key, value in payload.model_dump().items():
setattr(device, key, value)
device.slug = slug
db.commit()
return KnowledgeRepository.get_device(db, device.id) or device
@staticmethod
def create_document(db: Session, payload: KnowledgeDocumentCreate) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None)
document = KnowledgeDocument(slug=slug, external_url=str(payload.external_url or ""), **payload.model_dump(exclude={"external_url"}))
db.add(document)
db.commit()
return KnowledgeRepository.get_document(db, document.id) or document
@staticmethod
async def upload_document(
db: Session,
*,
file: UploadFile,
payload: KnowledgeDocumentCreate,
) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
content, file_name, mime_type = await read_upload(file)
checksum = hashlib.sha256(content).hexdigest()
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None)
target_dir = storage_root() / str(payload.manufacturer_id)
target_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{uuid.uuid4().hex}-{file_name}"
file_path = assert_safe_path(target_dir / stored_name)
file_path.write_bytes(content)
document = KnowledgeDocument(
slug=slug,
external_url=str(payload.external_url or ""),
file_name=file_name,
file_path=str(file_path),
mime_type=mime_type,
file_size=len(content),
checksum_sha256=checksum,
**payload.model_dump(exclude={"external_url"}),
)
db.add(document)
db.commit()
return KnowledgeRepository.get_document(db, document.id) or document
@staticmethod
def update_document(db: Session, document: KnowledgeDocument, payload: KnowledgeDocumentUpdate) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value, document.id) is not None)
for key, value in payload.model_dump(exclude={"external_url"}).items():
setattr(document, key, value)
document.external_url = str(payload.external_url or "")
document.slug = slug
db.commit()
return KnowledgeRepository.get_document(db, document.id) or document
@staticmethod
def create_note(db: Session, payload: KnowledgeNoteCreate) -> KnowledgeNote:
KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id)
note = KnowledgeNote(**payload.model_dump())
db.add(note)
db.commit()
return KnowledgeRepository.get_note(db, note.id) or note
@staticmethod
def update_note(db: Session, note: KnowledgeNote, payload: KnowledgeNoteUpdate) -> KnowledgeNote:
KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id)
for key, value in payload.model_dump().items():
setattr(note, key, value)
db.commit()
return KnowledgeRepository.get_note(db, note.id) or note
@staticmethod
def _validate_document_links(db: Session, manufacturer_id: int, device_id: int | None) -> None:
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:
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
if 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_optional_links(db: Session, manufacturer_id: int | None, device_id: int | None) -> None:
if manufacturer_id is not None and 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:
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
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")

View file

@ -17,6 +17,11 @@ services:
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
volumes:
- knowledge-data:/data/knowledge
ports:
- "8000:8000"
@ -28,3 +33,6 @@ services:
networks:
olympus-network:
external: true
volumes:
knowledge-data:

View file

@ -20,6 +20,11 @@ services:
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-}
INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-}
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
volumes:
- knowledge-data:/data/knowledge
expose:
- "8000"
@ -53,3 +58,6 @@ services:
networks:
olympus-network:
external: true
volumes:
knowledge-data:

View file

@ -0,0 +1,51 @@
import { NextRequest } from "next/server";
import { assertSameOrigin } from "@/lib/server/request-guards";
import { proxyHermesRequest, proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
type Params = {
params: Promise<{
path: string[];
}>;
};
async function knowledgePath(request: NextRequest, context: Params) {
const { path } = await context.params;
return `/knowledge/${path.join("/")}${request.nextUrl.search}`;
}
function isDownloadPath(path: string) {
return /\/documents\/\d+\/download(?:\?|$)/.test(path);
}
export async function GET(request: NextRequest, context: Params) {
const path = await knowledgePath(request, context);
if (isDownloadPath(path)) {
return proxyHermesStreamRequest(request, path);
}
return proxyHermesRequest(request, path);
}
export async function POST(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, await knowledgePath(request, context));
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, await knowledgePath(request, context));
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, await knowledgePath(request, context));
}

View file

@ -0,0 +1,85 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Download } from "lucide-react";
import DetailSection from "@/components/common/DetailSection";
import DocumentTypeBadge from "@/components/knowledge/DocumentTypeBadge";
import TagList from "@/components/knowledge/TagList";
import { buttonVariants } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { KnowledgeDevice, KnowledgeDocument, KnowledgeNote } from "@/types/knowledge";
export default function KnowledgeDeviceDetailPage() {
const params = useParams<{ id: string }>();
const [device, setDevice] = useState<KnowledgeDevice | null>(null);
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
const [notes, setNotes] = useState<KnowledgeNote[]>([]);
const [error, setError] = useState("");
useEffect(() => {
Promise.all([
api.get<KnowledgeDevice>(`/knowledge/devices/${params.id}`),
api.get<KnowledgeDocument[]>(`/knowledge/documents?device_id=${params.id}&limit=100`),
api.get<KnowledgeNote[]>(`/knowledge/notes?device_id=${params.id}&limit=100`),
]).then(([deviceResponse, documentResponse, noteResponse]) => {
setDevice(deviceResponse.data);
setDocuments(documentResponse.data);
setNotes(noteResponse.data);
}).catch(() => setError("Gerät konnte nicht geladen werden"));
}, [params.id]);
const yearRange = useMemo(() => {
if (!device) return "-";
if (!device.production_year_from && !device.production_year_to) return "-";
return `${device.production_year_from ?? "?"} - ${device.production_year_to ?? "?"}`;
}, [device]);
if (error) return <p className="text-red-600">{error}</p>;
if (!device) return <p className="text-slate-500">Gerät wird geladen...</p>;
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-slate-950">{device.name}</h1>
<p className="mt-1 text-sm text-slate-500">{device.manufacturer.name}{device.model_number ? ` · ${device.model_number}` : ""}</p>
</div>
<DetailSection title="Stammdaten">
<dl className="grid gap-3 text-sm md:grid-cols-2">
<div><dt className="text-slate-500">Gerätetyp</dt><dd className="font-medium">{device.device_type || "-"}</dd></div>
<div><dt className="text-slate-500">Frequenzbereich</dt><dd className="font-medium">{device.frequency_range || "-"}</dd></div>
<div><dt className="text-slate-500">Baujahre</dt><dd className="font-medium">{yearRange}</dd></div>
<div><dt className="text-slate-500">Slug</dt><dd className="font-medium">{device.slug}</dd></div>
</dl>
{device.notes && <p className="mt-4 whitespace-pre-wrap text-sm text-slate-700">{device.notes}</p>}
</DetailSection>
<DetailSection title="Dokumente">
<div className="space-y-3">
{documents.map((document) => (
<div key={document.id} className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<Link href={`/knowledge/documents/${document.id}`} className="font-medium text-slate-950 hover:underline">{document.title}</Link>
<div className="mt-2 flex flex-wrap items-center gap-2"><DocumentTypeBadge type={document.document_type} /><TagList tags={document.tags} /></div>
</div>
{document.file_name && <Link href={`/api/knowledge/documents/${document.id}/download`} className={buttonVariants({ variant: "outline", size: "icon-sm" })} title="Download"><Download size={16} /></Link>}
</div>
))}
{documents.length === 0 && <p className="text-sm text-slate-500">Keine verknüpften Dokumente.</p>}
</div>
</DetailSection>
<DetailSection title="Notizen">
<div className="space-y-3">
{notes.map((note) => (
<div key={note.id} className="rounded-lg border p-3">
<p className="font-medium">{note.title}</p>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-600">{note.content}</p>
</div>
))}
{notes.length === 0 && <p className="text-sm text-slate-500">Keine verknüpften Notizen.</p>}
</div>
</DetailSection>
</div>
);
}

View file

@ -0,0 +1,149 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Edit, Eye, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { DeviceFormDialog } from "@/components/knowledge/KnowledgeForms";
import { Button, buttonVariants } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { DevicePayload, KnowledgeDevice, KnowledgeManufacturer } from "@/types/knowledge";
function errorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
return (error as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Aktion fehlgeschlagen";
}
return "Aktion fehlgeschlagen";
}
export default function KnowledgeDevicesPage() {
const { showToast } = useToast();
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
const [pending, setPending] = useState(false);
const [search, setSearch] = useState("");
const [manufacturerFilter, setManufacturerFilter] = useState("all");
const [sortKey, setSortKey] = useState("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [formOpen, setFormOpen] = useState(false);
const [selected, setSelected] = useState<KnowledgeDevice | null>(null);
const [deleteItem, setDeleteItem] = useState<KnowledgeDevice | null>(null);
const loadItems = useCallback(async () => {
setLoading(true);
try {
const [manufacturerResponse, deviceResponse] = await Promise.all([
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
]);
setManufacturers(manufacturerResponse.data);
setDevices(deviceResponse.data);
setError("");
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadItems();
});
}, [loadItems]);
const rows = useMemo(() => devices
.filter((item) => manufacturerFilter === "all" || item.manufacturer_id === Number(manufacturerFilter))
.filter((item) => [item.name, item.model_number, item.device_type, item.manufacturer.name].some((value) => value.toLowerCase().includes(search.toLowerCase())))
.sort((a, b) => {
const av = sortKey === "manufacturer" ? a.manufacturer.name : String(a[sortKey as keyof KnowledgeDevice] ?? "");
const bv = sortKey === "manufacturer" ? b.manufacturer.name : String(b[sortKey as keyof KnowledgeDevice] ?? "");
const result = av.toLowerCase() > bv.toLowerCase() ? 1 : av.toLowerCase() < bv.toLowerCase() ? -1 : 0;
return sortDirection === "asc" ? result : -result;
}), [devices, manufacturerFilter, search, sortDirection, sortKey]);
const columns: DataTableColumn<KnowledgeDevice>[] = [
{ key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer.name },
{ key: "name", label: "Gerät", sortable: true, render: (item) => <span className="font-medium">{item.name}</span> },
{ key: "model_number", label: "Modell", sortable: true, render: (item) => item.model_number || "-" },
{ key: "device_type", label: "Typ", sortable: true, render: (item) => item.device_type || "-" },
{ key: "frequency_range", label: "Frequenz", render: (item) => item.frequency_range || "-" },
{
key: "actions",
label: "Aktionen",
className: "px-4 py-3 text-right",
render: (item) => (
<div className="flex justify-end gap-2">
<Link href={`/knowledge/devices/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
<Button variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelected(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>
<Button variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteItem(item)}><Trash2 size={16} /></Button>
</div>
),
},
];
async function save(payload: DevicePayload) {
setPending(true);
setFormError("");
try {
if (selected) {
await api.put(`/knowledge/devices/${selected.id}`, payload);
showToast({ type: "success", title: "Gerät aktualisiert", description: payload.name });
} else {
await api.post("/knowledge/devices", payload);
showToast({ type: "success", title: "Gerät erstellt", description: payload.name });
}
setFormOpen(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);
}
}
async function confirmDelete() {
if (!deleteItem) return;
setPending(true);
try {
await api.delete(`/knowledge/devices/${deleteItem.id}`);
showToast({ type: "success", title: "Gerät gelöscht", description: deleteItem.name });
setDeleteItem(null);
await loadItems();
} catch (err) {
showToast({ type: "error", title: "Gerät konnte nicht gelöscht werden", description: errorMessage(err) });
} finally {
setPending(false);
}
}
return (
<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">Geräte</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Modelle</p></div>
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={manufacturers.length === 0}><Plus size={16} />Gerät</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="Geräte suchen" />
<select className="h-8 rounded-lg border px-2 text-sm" value={manufacturerFilter} onChange={(event) => setManufacturerFilter(event.target.value)}>
<option value="all">Alle Hersteller</option>
{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"); }} />
<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>}
</ConfirmDialog>
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Download, ExternalLink } from "lucide-react";
import DetailSection from "@/components/common/DetailSection";
import DocumentTypeBadge from "@/components/knowledge/DocumentTypeBadge";
import TagList from "@/components/knowledge/TagList";
import { buttonVariants } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { KnowledgeDocument } from "@/types/knowledge";
function fileSize(value: number) {
if (!value) return "-";
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
export default function KnowledgeDocumentDetailPage() {
const params = useParams<{ id: string }>();
const [document, setDocument] = useState<KnowledgeDocument | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api.get<KnowledgeDocument>(`/knowledge/documents/${params.id}`)
.then((response) => setDocument(response.data))
.catch(() => setError("Dokument konnte nicht geladen werden"));
}, [params.id]);
if (error) return <p className="text-red-600">{error}</p>;
if (!document) return <p className="text-slate-500">Dokument wird geladen...</p>;
return (
<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">{document.title}</h1>
<p className="mt-1 text-sm text-slate-500">{document.manufacturer.name}{document.device ? ` · ${document.device.name}` : ""}</p>
</div>
<div className="flex gap-2">
{document.external_url && <Link href={document.external_url} target="_blank" className={buttonVariants({ variant: "outline" })}><ExternalLink size={16} />Extern</Link>}
{document.file_name && <Link href={`/api/knowledge/documents/${document.id}/download`} className={buttonVariants()}><Download size={16} />Download</Link>}
</div>
</div>
<DetailSection title="Dokument">
<dl className="grid gap-3 text-sm md:grid-cols-2">
<div><dt className="text-slate-500">Typ</dt><dd className="mt-1"><DocumentTypeBadge type={document.document_type} /></dd></div>
<div><dt className="text-slate-500">Sprache</dt><dd className="font-medium">{document.language}</dd></div>
<div><dt className="text-slate-500">Dateiname</dt><dd className="font-medium">{document.file_name || "-"}</dd></div>
<div><dt className="text-slate-500">Dateigröße</dt><dd className="font-medium">{fileSize(document.file_size)}</dd></div>
<div><dt className="text-slate-500">SHA-256</dt><dd className="break-all font-medium">{document.checksum_sha256 || "-"}</dd></div>
<div><dt className="text-slate-500">Paperless-ID</dt><dd className="font-medium">{document.paperless_document_id || "-"}</dd></div>
</dl>
<div className="mt-4"><TagList tags={document.tags} /></div>
{document.description && <p className="mt-4 whitespace-pre-wrap text-sm text-slate-700">{document.description}</p>}
</DetailSection>
</div>
);
}

View file

@ -0,0 +1,184 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Download, Edit, Eye, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
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 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";
function errorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
return (error as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Aktion fehlgeschlagen";
}
return "Aktion fehlgeschlagen";
}
function fileSize(value: number) {
if (!value) return "-";
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
export default function KnowledgeDocumentsPage() {
const { showToast } = useToast();
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
const [pending, setPending] = useState(false);
const [search, setSearch] = useState("");
const [manufacturerFilter, setManufacturerFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState<DocumentType | "all">("all");
const [sortKey, setSortKey] = useState("created_at");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [formOpen, setFormOpen] = useState(false);
const [selected, setSelected] = useState<KnowledgeDocument | null>(null);
const [deleteItem, setDeleteItem] = useState<KnowledgeDocument | null>(null);
const loadItems = useCallback(async () => {
setLoading(true);
try {
const [manufacturerResponse, deviceResponse, documentResponse] = await Promise.all([
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
api.get<KnowledgeDocument[]>("/knowledge/documents?limit=100"),
]);
setManufacturers(manufacturerResponse.data);
setDevices(deviceResponse.data);
setDocuments(documentResponse.data);
setError("");
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadItems();
});
}, [loadItems]);
const rows = useMemo(() => documents
.filter((item) => manufacturerFilter === "all" || item.manufacturer_id === Number(manufacturerFilter))
.filter((item) => typeFilter === "all" || item.document_type === typeFilter)
.filter((item) => [item.title, item.manufacturer.name, item.device?.name ?? "", ...item.tags].some((value) => value.toLowerCase().includes(search.toLowerCase())))
.sort((a, b) => {
const av = sortKey === "manufacturer" ? a.manufacturer.name : String(a[sortKey as keyof KnowledgeDocument] ?? "");
const bv = sortKey === "manufacturer" ? b.manufacturer.name : String(b[sortKey as keyof KnowledgeDocument] ?? "");
const result = av.toLowerCase() > bv.toLowerCase() ? 1 : av.toLowerCase() < bv.toLowerCase() ? -1 : 0;
return sortDirection === "asc" ? result : -result;
}), [documents, manufacturerFilter, search, sortDirection, sortKey, typeFilter]);
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 },
{ key: "device", label: "Gerät", render: (item) => item.device?.name ?? "-" },
{ key: "document_type", label: "Typ", sortable: true, render: (item) => <DocumentTypeBadge type={item.document_type} /> },
{ key: "language", label: "Sprache", sortable: true, render: (item) => item.language },
{ key: "file_size", label: "Größe", render: (item) => fileSize(item.file_size) },
{ key: "tags", label: "Tags", render: (item) => <TagList tags={item.tags} /> },
{
key: "actions",
label: "Aktionen",
className: "px-4 py-3 text-right",
render: (item) => (
<div className="flex justify-end gap-2">
<Link href={`/knowledge/documents/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
{item.file_name && <Link href={`/api/knowledge/documents/${item.id}/download`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Download"><Download size={16} /></Link>}
<Button variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelected(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>
<Button variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteItem(item)}><Trash2 size={16} /></Button>
</div>
),
},
];
async function save(payload: DocumentPayload, file: File | null) {
setPending(true);
setFormError("");
try {
if (selected) {
await api.put(`/knowledge/documents/${selected.id}`, payload);
showToast({ type: "success", title: "Dokument aktualisiert", description: payload.title });
} else {
if (!file) return;
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => {
if (key === "tags") formData.append(key, (value as string[]).join(","));
else if (value !== null && value !== undefined) formData.append(key, String(value));
});
formData.append("file", file);
await api.post("/knowledge/documents/upload", formData);
showToast({ type: "success", title: "Dokument hochgeladen", description: payload.title });
}
setFormOpen(false);
await loadItems();
} catch (err) {
const message = errorMessage(err);
setFormError(message);
showToast({ type: "error", title: "Dokument konnte nicht gespeichert werden", description: message });
} finally {
setPending(false);
}
}
async function confirmDelete() {
if (!deleteItem) return;
setPending(true);
try {
await api.delete(`/knowledge/documents/${deleteItem.id}`);
showToast({ type: "success", title: "Dokument gelöscht", description: deleteItem.title });
setDeleteItem(null);
await loadItems();
} catch (err) {
showToast({ type: "error", title: "Dokument konnte nicht gelöscht werden", description: errorMessage(err) });
} finally {
setPending(false);
}
}
return (
<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>
</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" />
<select className="h-8 rounded-lg border px-2 text-sm" value={manufacturerFilter} onChange={(event) => setManufacturerFilter(event.target.value)}>
<option value="all">Alle Hersteller</option>
{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select className="h-8 rounded-lg border px-2 text-sm" value={typeFilter} onChange={(event) => setTypeFilter(event.target.value as DocumentType | "all")}>
<option value="all">Alle Typen</option>
<option value="manual">Bedienungsanleitung</option>
<option value="service_manual">Service Manual</option>
<option value="schematic">Schaltplan</option>
<option value="alignment">Abgleich</option>
<option value="parts_list">Ersatzteile</option>
<option value="firmware">Firmware</option>
<option value="datasheet">Datenblatt</option>
<option value="service_bulletin">Bulletin</option>
<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}>
{deleteItem && <p className="text-sm font-medium">{deleteItem.title}</p>}
</ConfirmDialog>
</div>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Edit, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { ManufacturerFormDialog } from "@/components/knowledge/KnowledgeForms";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { KnowledgeManufacturer, ManufacturerPayload } from "@/types/knowledge";
function errorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
return (error as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Aktion fehlgeschlagen";
}
return "Aktion fehlgeschlagen";
}
export default function KnowledgeManufacturersPage() {
const { showToast } = useToast();
const [items, setItems] = useState<KnowledgeManufacturer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
const [pending, setPending] = useState(false);
const [search, setSearch] = useState("");
const [sortKey, setSortKey] = useState("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [formOpen, setFormOpen] = useState(false);
const [selected, setSelected] = useState<KnowledgeManufacturer | null>(null);
const [deleteItem, setDeleteItem] = useState<KnowledgeManufacturer | null>(null);
const loadItems = useCallback(async () => {
setLoading(true);
try {
const response = await api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100");
setItems(response.data);
setError("");
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadItems();
});
}, [loadItems]);
const rows = useMemo(() => items
.filter((item) => item.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => {
const av = String(a[sortKey as keyof KnowledgeManufacturer] ?? "").toLowerCase();
const bv = String(b[sortKey as keyof KnowledgeManufacturer] ?? "").toLowerCase();
const result = av > bv ? 1 : av < bv ? -1 : 0;
return sortDirection === "asc" ? result : -result;
}), [items, search, sortDirection, sortKey]);
const columns: DataTableColumn<KnowledgeManufacturer>[] = [
{ key: "name", label: "Name", sortable: true, render: (item) => <span className="font-medium">{item.name}</span> },
{ key: "website", label: "Website", sortable: true, render: (item) => item.website || "-" },
{ key: "notes", label: "Notizen", render: (item) => item.notes || "-" },
{
key: "actions",
label: "Aktionen",
className: "px-4 py-3 text-right",
render: (item) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelected(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>
<Button variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteItem(item)}><Trash2 size={16} /></Button>
</div>
),
},
];
async function save(payload: ManufacturerPayload) {
setPending(true);
setFormError("");
try {
if (selected) {
await api.put(`/knowledge/manufacturers/${selected.id}`, payload);
showToast({ type: "success", title: "Hersteller aktualisiert", description: payload.name });
} else {
await api.post("/knowledge/manufacturers", payload);
showToast({ type: "success", title: "Hersteller erstellt", description: payload.name });
}
setFormOpen(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 confirmDelete() {
if (!deleteItem) return;
setPending(true);
try {
await api.delete(`/knowledge/manufacturers/${deleteItem.id}`);
showToast({ type: "success", title: "Hersteller gelöscht", description: deleteItem.name });
setDeleteItem(null);
await loadItems();
} catch (err) {
showToast({ type: "error", title: "Hersteller konnte nicht gelöscht werden", description: errorMessage(err) });
} finally {
setPending(false);
}
}
return (
<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">Hersteller</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Einträge</p></div>
<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"); }} />
<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>}
</ConfirmDialog>
</div>
);
}

View file

@ -0,0 +1,152 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Edit, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { KnowledgeNoteFormDialog } from "@/components/knowledge/KnowledgeForms";
import TagList from "@/components/knowledge/TagList";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { KnowledgeDevice, KnowledgeManufacturer, KnowledgeNote, NotePayload } from "@/types/knowledge";
function errorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
return (error as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Aktion fehlgeschlagen";
}
return "Aktion fehlgeschlagen";
}
const noteTypeLabels: Record<string, string> = {
repair: "Reparaturhinweis",
known_fault: "Bekannter Fehler",
alignment: "Abgleichhinweis",
spare_part: "Ersatzteilhinweis",
general: "Allgemein",
};
export default function KnowledgeNotesPage() {
const { showToast } = useToast();
const [notes, setNotes] = useState<KnowledgeNote[]>([]);
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
const [pending, setPending] = useState(false);
const [search, setSearch] = useState("");
const [sortKey, setSortKey] = useState("created_at");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [formOpen, setFormOpen] = useState(false);
const [selected, setSelected] = useState<KnowledgeNote | null>(null);
const [deleteItem, setDeleteItem] = useState<KnowledgeNote | null>(null);
const loadItems = useCallback(async () => {
setLoading(true);
try {
const [manufacturerResponse, deviceResponse, noteResponse] = await Promise.all([
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
api.get<KnowledgeNote[]>("/knowledge/notes?limit=100"),
]);
setManufacturers(manufacturerResponse.data);
setDevices(deviceResponse.data);
setNotes(noteResponse.data);
setError("");
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadItems();
});
}, [loadItems]);
const rows = useMemo(() => notes
.filter((item) => [item.title, item.content, item.device?.name ?? "", item.manufacturer?.name ?? "", ...item.tags].some((value) => value.toLowerCase().includes(search.toLowerCase())))
.sort((a, b) => {
const av = String(a[sortKey as keyof KnowledgeNote] ?? "");
const bv = String(b[sortKey as keyof KnowledgeNote] ?? "");
const result = av.toLowerCase() > bv.toLowerCase() ? 1 : av.toLowerCase() < bv.toLowerCase() ? -1 : 0;
return sortDirection === "asc" ? result : -result;
}), [notes, search, sortDirection, sortKey]);
const columns: DataTableColumn<KnowledgeNote>[] = [
{ key: "title", label: "Titel", sortable: true, render: (item) => <span className="font-medium">{item.title}</span> },
{ key: "note_type", label: "Typ", sortable: true, render: (item) => noteTypeLabels[item.note_type] ?? item.note_type },
{ key: "manufacturer", label: "Hersteller", render: (item) => item.manufacturer?.name ?? "-" },
{ key: "device", label: "Gerät", render: (item) => item.device?.name ?? "-" },
{ key: "severity", label: "Schweregrad", sortable: true, render: (item) => item.severity || "-" },
{ key: "tags", label: "Tags", render: (item) => <TagList tags={item.tags} /> },
{
key: "actions",
label: "Aktionen",
className: "px-4 py-3 text-right",
render: (item) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelected(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>
<Button variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteItem(item)}><Trash2 size={16} /></Button>
</div>
),
},
];
async function save(payload: NotePayload) {
setPending(true);
setFormError("");
try {
if (selected) {
await api.put(`/knowledge/notes/${selected.id}`, payload);
showToast({ type: "success", title: "Notiz aktualisiert", description: payload.title });
} else {
await api.post("/knowledge/notes", payload);
showToast({ type: "success", title: "Notiz erstellt", description: payload.title });
}
setFormOpen(false);
await loadItems();
} catch (err) {
const message = errorMessage(err);
setFormError(message);
showToast({ type: "error", title: "Notiz konnte nicht gespeichert werden", description: message });
} finally {
setPending(false);
}
}
async function confirmDelete() {
if (!deleteItem) return;
setPending(true);
try {
await api.delete(`/knowledge/notes/${deleteItem.id}`);
showToast({ type: "success", title: "Notiz gelöscht", description: deleteItem.title });
setDeleteItem(null);
await loadItems();
} catch (err) {
showToast({ type: "error", title: "Notiz konnte nicht gelöscht werden", description: errorMessage(err) });
} finally {
setPending(false);
}
}
return (
<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">Knowledge Notes</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Reparatur- und Servicehinweise</p></div>
<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"); }} />
<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>}
</ConfirmDialog>
</div>
);
}

View file

@ -0,0 +1,121 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { BookOpen, Cpu, Factory, FileText, StickyNote } from "lucide-react";
import SearchInput from "@/components/common/SearchInput";
import SummaryCard from "@/components/common/SummaryCard";
import { buttonVariants } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, KnowledgeNote, KnowledgeSearchResult } from "@/types/knowledge";
export default function KnowledgePage() {
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
const [notes, setNotes] = useState<KnowledgeNote[]>([]);
const [results, setResults] = useState<KnowledgeSearchResult[]>([]);
const [query, setQuery] = useState("");
const [error, setError] = useState("");
useEffect(() => {
queueMicrotask(() => {
Promise.all([
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
api.get<KnowledgeDocument[]>("/knowledge/documents?limit=10"),
api.get<KnowledgeNote[]>("/knowledge/notes?limit=10"),
]).then(([manufacturerResponse, deviceResponse, documentResponse, noteResponse]) => {
setManufacturers(manufacturerResponse.data);
setDevices(deviceResponse.data);
setDocuments(documentResponse.data);
setNotes(noteResponse.data);
}).catch(() => setError("Wissensdatenbank konnte nicht geladen werden"));
});
}, []);
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
queueMicrotask(() => setResults([]));
return;
}
const timeout = window.setTimeout(() => {
api.get<KnowledgeSearchResult[]>(`/knowledge/search?q=${encodeURIComponent(trimmed)}`)
.then((response) => setResults(response.data))
.catch(() => setResults([]));
}, 250);
return () => window.clearTimeout(timeout);
}, [query]);
const latestDocuments = useMemo(() => documents.slice(0, 5), [documents]);
const latestNotes = useMemo(() => notes.slice(0, 5), [notes]);
return (
<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">Wissensdatenbank</h1>
<p className="mt-1 text-sm text-slate-500">Serviceunterlagen, Modelle, Schaltpläne und Reparaturwissen</p>
</div>
<div className="flex flex-wrap gap-2">
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/manufacturers"><Factory size={16} />Hersteller</Link>
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/devices"><Cpu size={16} />Geräte</Link>
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/documents"><FileText size={16} />Dokumente</Link>
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/notes"><StickyNote size={16} />Notizen</Link>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<SummaryCard label="Hersteller" value={manufacturers.length} />
<SummaryCard label="Geräte" value={devices.length} />
<SummaryCard label="Dokumente" value={documents.length} />
<SummaryCard label="Notizen" value={notes.length} />
</div>
<section className="rounded-lg border bg-white p-4">
<SearchInput value={query} onChange={setQuery} placeholder="Hersteller, Modell, Dokument oder Notiz suchen" />
{results.length > 0 && (
<div className="mt-4 divide-y rounded-lg border">
{results.map((result) => (
<Link key={`${result.entity_type}-${result.id}`} href={result.href} className="flex items-center gap-3 p-3 hover:bg-slate-50">
<BookOpen size={16} className="text-slate-500" />
<span className="font-medium text-slate-950">{result.title}</span>
<span className="text-sm text-slate-500">{result.subtitle}</span>
</Link>
))}
</div>
)}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</section>
<div className="grid gap-6 lg:grid-cols-2">
<section className="rounded-lg border bg-white p-5">
<h2 className="text-lg font-semibold text-slate-950">Letzte Dokumente</h2>
<div className="mt-4 space-y-3">
{latestDocuments.map((document) => (
<Link key={document.id} href={`/knowledge/documents/${document.id}`} className="block rounded-lg border p-3 hover:bg-slate-50">
<p className="font-medium">{document.title}</p>
<p className="text-sm text-slate-500">{document.manufacturer.name}{document.device ? ` · ${document.device.name}` : ""}</p>
</Link>
))}
{latestDocuments.length === 0 && <p className="text-sm text-slate-500">Noch keine Dokumente vorhanden.</p>}
</div>
</section>
<section className="rounded-lg border bg-white p-5">
<h2 className="text-lg font-semibold text-slate-950">Letzte Notizen</h2>
<div className="mt-4 space-y-3">
{latestNotes.map((note) => (
<Link key={note.id} href="/knowledge/notes" className="block rounded-lg border p-3 hover:bg-slate-50">
<p className="font-medium">{note.title}</p>
<p className="line-clamp-2 text-sm text-slate-500">{note.content}</p>
</Link>
))}
{latestNotes.length === 0 && <p className="text-sm text-slate-500">Noch keine Notizen vorhanden.</p>}
</div>
</section>
</div>
</div>
);
}

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
BookOpen,
ClipboardList,
FileText,
LayoutDashboard,
@ -49,6 +50,12 @@ const menu = [
href: "/customers",
permission: "customers.read",
},
{
icon: BookOpen,
name: "Wissensdatenbank",
href: "/knowledge",
permission: "knowledge.read",
},
{
icon: Wrench,
name: "Reparaturen",

View file

@ -0,0 +1,25 @@
import type { DocumentType } from "@/types/knowledge";
const labels: Record<DocumentType, string> = {
manual: "Bedienungsanleitung",
service_manual: "Service Manual",
schematic: "Schaltplan",
alignment: "Abgleich",
parts_list: "Ersatzteile",
firmware: "Firmware",
datasheet: "Datenblatt",
service_bulletin: "Bulletin",
other: "Sonstiges",
};
export function documentTypeLabel(type: DocumentType) {
return labels[type] ?? type;
}
export default function DocumentTypeBadge({ type }: { type: DocumentType }) {
return (
<span className="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">
{documentTypeLabel(type)}
</span>
);
}

View file

@ -0,0 +1,325 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type {
DevicePayload,
DocumentPayload,
DocumentType,
KnowledgeDevice,
KnowledgeDocument,
KnowledgeManufacturer,
KnowledgeNote,
ManufacturerPayload,
NotePayload,
NoteType,
} from "@/types/knowledge";
const documentTypes: Array<[DocumentType, string]> = [
["manual", "Bedienungsanleitung"],
["service_manual", "Service Manual"],
["schematic", "Schaltplan"],
["alignment", "Abgleichanleitung"],
["parts_list", "Ersatzteilliste"],
["firmware", "Firmware"],
["datasheet", "Datenblatt"],
["service_bulletin", "Service Bulletin"],
["other", "Sonstiges"],
];
const noteTypes: Array<[NoteType, string]> = [
["repair", "Reparaturhinweis"],
["known_fault", "Bekannter Fehler"],
["alignment", "Abgleichhinweis"],
["spare_part", "Ersatzteilhinweis"],
["general", "Allgemein"],
];
function tagsToText(tags: string[]) {
return tags.join(", ");
}
function textToTags(value: string) {
return value.split(",").map((tag) => tag.trim()).filter(Boolean);
}
function fieldClass() {
return "grid gap-2";
}
export function ManufacturerFormDialog({
open,
manufacturer,
pending,
error,
onOpenChange,
onSubmit,
}: {
open: boolean;
manufacturer: KnowledgeManufacturer | null;
pending: boolean;
error: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: ManufacturerPayload) => void;
}) {
const [name, setName] = useState("");
const [website, setWebsite] = useState("");
const [notes, setNotes] = useState("");
useEffect(() => {
queueMicrotask(() => {
setName(manufacturer?.name ?? "");
setWebsite(manufacturer?.website ?? "");
setNotes(manufacturer?.notes ?? "");
});
}, [manufacturer, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>{manufacturer ? "Hersteller bearbeiten" : "Hersteller erstellen"}</DialogTitle></DialogHeader>
<div className="grid gap-4">
<div className={fieldClass()}><Label>Name</Label><Input value={name} onChange={(event) => setName(event.target.value)} /></div>
<div className={fieldClass()}><Label>Website</Label><Input value={website} onChange={(event) => setWebsite(event.target.value)} /></div>
<div className={fieldClass()}><Label>Notizen</Label><textarea className="min-h-24 rounded-lg border border-input p-2 text-sm" value={notes} onChange={(event) => setNotes(event.target.value)} /></div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button onClick={() => onSubmit({ name, website: website || null, notes })} disabled={pending}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function DeviceFormDialog({
open,
device,
manufacturers,
pending,
error,
onOpenChange,
onSubmit,
}: {
open: boolean;
device: KnowledgeDevice | null;
manufacturers: KnowledgeManufacturer[];
pending: boolean;
error: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: DevicePayload) => void;
}) {
const [payload, setPayload] = useState<DevicePayload>({
manufacturer_id: 0,
name: "",
model_number: "",
device_type: "",
frequency_range: "",
production_year_from: null,
production_year_to: null,
notes: "",
});
useEffect(() => {
queueMicrotask(() => {
setPayload(device ? {
manufacturer_id: device.manufacturer_id,
name: device.name,
model_number: device.model_number,
device_type: device.device_type,
frequency_range: device.frequency_range,
production_year_from: device.production_year_from,
production_year_to: device.production_year_to,
notes: device.notes,
} : {
manufacturer_id: manufacturers[0]?.id ?? 0,
name: "",
model_number: "",
device_type: "",
frequency_range: "",
production_year_from: null,
production_year_to: null,
notes: "",
});
});
}, [device, manufacturers, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader><DialogTitle>{device ? "Gerät bearbeiten" : "Gerät erstellen"}</DialogTitle></DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className={fieldClass()}><Label>Hersteller</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.manufacturer_id} onChange={(event) => setPayload({ ...payload, manufacturer_id: Number(event.target.value) })}>{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
<div className={fieldClass()}><Label>Name</Label><Input value={payload.name} onChange={(event) => setPayload({ ...payload, name: event.target.value })} /></div>
<div className={fieldClass()}><Label>Modellnummer</Label><Input value={payload.model_number} onChange={(event) => setPayload({ ...payload, model_number: event.target.value })} /></div>
<div className={fieldClass()}><Label>Gerätetyp</Label><Input value={payload.device_type} onChange={(event) => setPayload({ ...payload, device_type: event.target.value })} /></div>
<div className={fieldClass()}><Label>Frequenzbereich</Label><Input value={payload.frequency_range} onChange={(event) => setPayload({ ...payload, frequency_range: event.target.value })} /></div>
<div className={fieldClass()}><Label>Baujahr von</Label><Input type="number" value={payload.production_year_from ?? ""} onChange={(event) => setPayload({ ...payload, production_year_from: event.target.value ? Number(event.target.value) : null })} /></div>
<div className={fieldClass()}><Label>Baujahr bis</Label><Input type="number" value={payload.production_year_to ?? ""} onChange={(event) => setPayload({ ...payload, production_year_to: event.target.value ? Number(event.target.value) : null })} /></div>
<div className="grid gap-2 sm:col-span-2"><Label>Notizen</Label><textarea className="min-h-24 rounded-lg border border-input p-2 text-sm" value={payload.notes} onChange={(event) => setPayload({ ...payload, notes: event.target.value })} /></div>
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button onClick={() => onSubmit(payload)} disabled={pending || !payload.manufacturer_id}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function DocumentUploadDialog({
open,
document,
manufacturers,
devices,
pending,
error,
onOpenChange,
onSubmit,
}: {
open: boolean;
document: KnowledgeDocument | null;
manufacturers: KnowledgeManufacturer[];
devices: KnowledgeDevice[];
pending: boolean;
error: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: DocumentPayload, file: File | null) => void;
}) {
const [payload, setPayload] = useState<DocumentPayload>({
manufacturer_id: 0,
device_id: null,
title: "",
document_type: "service_manual",
language: "de",
external_url: null,
paperless_document_id: "",
description: "",
tags: [],
});
const [tags, setTags] = useState("");
const [file, setFile] = useState<File | null>(null);
useEffect(() => {
queueMicrotask(() => {
setPayload(document ? {
manufacturer_id: document.manufacturer_id,
device_id: document.device_id,
title: document.title,
document_type: document.document_type,
language: document.language,
external_url: document.external_url || null,
paperless_document_id: document.paperless_document_id,
description: document.description,
tags: document.tags,
} : {
manufacturer_id: manufacturers[0]?.id ?? 0,
device_id: null,
title: "",
document_type: "service_manual",
language: "de",
external_url: null,
paperless_document_id: "",
description: "",
tags: [],
});
setTags(document ? tagsToText(document.tags) : "");
setFile(null);
});
}, [document, manufacturers, open]);
const filteredDevices = devices.filter((device) => device.manufacturer_id === payload.manufacturer_id);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader><DialogTitle>{document ? "Dokument bearbeiten" : "Dokument hochladen"}</DialogTitle></DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className={fieldClass()}><Label>Hersteller</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.manufacturer_id} onChange={(event) => setPayload({ ...payload, manufacturer_id: Number(event.target.value), device_id: null })}>{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
<div className={fieldClass()}><Label>Gerät</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.device_id ?? ""} onChange={(event) => setPayload({ ...payload, device_id: event.target.value ? Number(event.target.value) : null })}><option value="">Kein Gerät</option>{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
<div className={fieldClass()}><Label>Titel</Label><Input value={payload.title} onChange={(event) => setPayload({ ...payload, title: event.target.value })} /></div>
<div className={fieldClass()}><Label>Typ</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.document_type} onChange={(event) => setPayload({ ...payload, document_type: event.target.value as DocumentType })}>{documentTypes.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div>
<div className={fieldClass()}><Label>Sprache</Label><Input value={payload.language} onChange={(event) => setPayload({ ...payload, language: event.target.value })} /></div>
<div className={fieldClass()}><Label>Paperless-ID</Label><Input value={payload.paperless_document_id} onChange={(event) => setPayload({ ...payload, paperless_document_id: event.target.value })} /></div>
<div className={fieldClass()}><Label>Externe URL</Label><Input value={payload.external_url ?? ""} onChange={(event) => setPayload({ ...payload, external_url: event.target.value || null })} /></div>
{!document && <div className={fieldClass()}><Label>Datei</Label><input type="file" className="h-9 rounded-lg border px-2 py-1 text-sm" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></div>}
<div className={fieldClass()}><Label>Tags</Label><Input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="cb-funk, schaltplan" /></div>
<div className="grid gap-2 sm:col-span-2"><Label>Beschreibung</Label><textarea className="min-h-24 rounded-lg border border-input p-2 text-sm" value={payload.description} onChange={(event) => setPayload({ ...payload, description: event.target.value })} /></div>
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button onClick={() => onSubmit({ ...payload, tags: textToTags(tags) }, file)} disabled={pending || !payload.manufacturer_id || (!document && !file)}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function KnowledgeNoteFormDialog({
open,
note,
manufacturers,
devices,
pending,
error,
onOpenChange,
onSubmit,
}: {
open: boolean;
note: KnowledgeNote | null;
manufacturers: KnowledgeManufacturer[];
devices: KnowledgeDevice[];
pending: boolean;
error: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: NotePayload) => void;
}) {
const [payload, setPayload] = useState<NotePayload>({ manufacturer_id: null, device_id: null, title: "", content: "", note_type: "general", severity: "", tags: [] });
const [tags, setTags] = useState("");
useEffect(() => {
queueMicrotask(() => {
setPayload(note ? {
manufacturer_id: note.manufacturer_id,
device_id: note.device_id,
title: note.title,
content: note.content,
note_type: note.note_type,
severity: note.severity,
tags: note.tags,
} : { manufacturer_id: null, device_id: null, title: "", content: "", note_type: "general", severity: "", tags: [] });
setTags(note ? tagsToText(note.tags) : "");
});
}, [note, open]);
const filteredDevices = payload.manufacturer_id ? devices.filter((device) => device.manufacturer_id === payload.manufacturer_id) : devices;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader><DialogTitle>{note ? "Notiz bearbeiten" : "Notiz erstellen"}</DialogTitle></DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className={fieldClass()}><Label>Hersteller</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.manufacturer_id ?? ""} onChange={(event) => setPayload({ ...payload, manufacturer_id: event.target.value ? Number(event.target.value) : null, device_id: null })}><option value="">Kein Hersteller</option>{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
<div className={fieldClass()}><Label>Gerät</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.device_id ?? ""} onChange={(event) => setPayload({ ...payload, device_id: event.target.value ? Number(event.target.value) : null })}><option value="">Kein Gerät</option>{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
<div className={fieldClass()}><Label>Titel</Label><Input value={payload.title} onChange={(event) => setPayload({ ...payload, title: event.target.value })} /></div>
<div className={fieldClass()}><Label>Typ</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.note_type} onChange={(event) => setPayload({ ...payload, note_type: event.target.value as NoteType })}>{noteTypes.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div>
<div className={fieldClass()}><Label>Schweregrad</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.severity} onChange={(event) => setPayload({ ...payload, severity: event.target.value as NotePayload["severity"] })}><option value="">Keiner</option><option value="low">Niedrig</option><option value="medium">Mittel</option><option value="high">Hoch</option><option value="critical">Kritisch</option></select></div>
<div className={fieldClass()}><Label>Tags</Label><Input value={tags} onChange={(event) => setTags(event.target.value)} /></div>
<div className="grid gap-2 sm:col-span-2"><Label>Inhalt</Label><textarea className="min-h-40 rounded-lg border border-input p-2 text-sm" value={payload.content} onChange={(event) => setPayload({ ...payload, content: event.target.value })} /></div>
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button onClick={() => onSubmit({ ...payload, tags: textToTags(tags) })} disabled={pending}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,15 @@
export default function TagList({ tags }: { tags: string[] }) {
if (tags.length === 0) {
return <span className="text-slate-400">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<span key={tag} className="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
{tag}
</span>
))}
</div>
);
}

View file

@ -57,3 +57,47 @@ export async function proxyHermesRequest(
return hermesJsonResponse(hermesResponse);
}
export async function proxyHermesStreamRequest(
request: NextRequest,
path: string,
) {
const token = await getAccessToken();
if (!token) {
return unauthorizedResponse();
}
const hermesUrl = getHermesUrl();
if (!hermesUrl) {
return upstreamConfigurationErrorResponse();
}
let hermesResponse: Response;
try {
hermesResponse = await fetch(`${hermesUrl}${path}`, {
method: request.method,
headers: {
Authorization: `Bearer ${token}`,
Accept: request.headers.get("accept") ?? "*/*",
},
cache: "no-store",
});
} catch {
return upstreamUnavailableResponse();
}
if (!hermesResponse.ok) {
return hermesJsonResponse(hermesResponse);
}
return new Response(hermesResponse.body, {
status: hermesResponse.status,
headers: {
"Content-Type": hermesResponse.headers.get("content-type") ?? "application/octet-stream",
"Content-Disposition": hermesResponse.headers.get("content-disposition") ?? "attachment",
},
});
}

View file

@ -0,0 +1,79 @@
export type DocumentType = "manual" | "service_manual" | "schematic" | "alignment" | "parts_list" | "firmware" | "datasheet" | "service_bulletin" | "other";
export type NoteType = "repair" | "known_fault" | "alignment" | "spare_part" | "general";
export interface KnowledgeManufacturer {
id: number;
name: string;
slug: string;
website: string;
notes: string;
created_at: string;
updated_at: string;
}
export interface KnowledgeDevice {
id: number;
manufacturer_id: number;
name: string;
slug: string;
model_number: string;
device_type: string;
frequency_range: string;
production_year_from: number | null;
production_year_to: number | null;
notes: string;
created_at: string;
updated_at: string;
manufacturer: KnowledgeManufacturer;
}
export interface KnowledgeDocument {
id: number;
manufacturer_id: number;
device_id: number | null;
title: string;
slug: string;
document_type: DocumentType;
language: string;
file_name: string;
file_path: string;
mime_type: string;
file_size: number;
checksum_sha256: string;
external_url: string;
paperless_document_id: string;
description: string;
tags: string[];
created_at: string;
updated_at: string;
manufacturer: KnowledgeManufacturer;
device: KnowledgeDevice | null;
}
export interface KnowledgeNote {
id: number;
manufacturer_id: number | null;
device_id: number | null;
title: string;
content: string;
note_type: NoteType;
severity: "" | "low" | "medium" | "high" | "critical";
tags: string[];
created_at: string;
updated_at: string;
manufacturer: KnowledgeManufacturer | null;
device: KnowledgeDevice | null;
}
export interface KnowledgeSearchResult {
entity_type: string;
id: number;
title: string;
subtitle: string;
href: string;
}
export type ManufacturerPayload = Pick<KnowledgeManufacturer, "name" | "notes"> & { website?: string | null };
export type DevicePayload = Omit<KnowledgeDevice, "id" | "slug" | "created_at" | "updated_at" | "manufacturer">;
export type DocumentPayload = Pick<KnowledgeDocument, "manufacturer_id" | "device_id" | "title" | "document_type" | "language" | "paperless_document_id" | "description" | "tags"> & { external_url?: string | null };
export type NotePayload = Pick<KnowledgeNote, "manufacturer_id" | "device_id" | "title" | "content" | "note_type" | "severity" | "tags">;