diff --git a/.DS_Store b/.DS_Store index 20ebb06..3ff066b 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.env.example b/.env.example index 372e4a0..e6e06b3 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,7 @@ STORAGE_HOST_PATH=./storage # Legacy-Fallback fuer bestehende Knowledge-Installationen. KNOWLEDGE_STORAGE_PATH=/data/knowledge KNOWLEDGE_MAX_UPLOAD_MB=50 + +# Server-zu-Server Token fuer spaetere Website-Reparaturannahme. +# Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden. +OLYMPUS_REPAIR_INTAKE_TOKEN= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1f410d3..3725053 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -177,6 +177,7 @@ Beispiele: - Kundenstatistiken nur mit `customers.read` - Benutzerstatistiken nur mit `users.read` - Rollenstatus nur mit `roles.read` +- Reparaturkennzahlen nur mit `repairs.read` Nicht vorhandene Module wie Aufgaben, Tickets oder Projekte werden nicht mit Fake-Daten gefuellt. Stattdessen liefert das Dashboard leere Widgets mit klarer Meldung. @@ -188,6 +189,60 @@ Athena ruft `GET /api/activity-feed` auf. Die BFF-Route ruft serverseitig Hermes Der Feed basiert auf persistenten Audit Logs und zeigt die letzten relevanten Aktivitaeten. Hermes filtert die Eintraege anhand der Berechtigungen des aktuellen Benutzers. Ein Benutzer sieht dadurch nur Aktivitaeten zu Bereichen, fuer die er Leserechte besitzt. +## Reparaturmanagement + +Das Modul `Reparaturen` verwaltet interne Werkstattvorgaenge. Es liegt fachlich in Hermes und wird im Browser ausschliesslich ueber Athena erreicht. + +Datenfluss: + +```text +Browser -> Athena /api/repairs... -> Hermes /repairs... -> PostgreSQL +``` + +Kernfunktionen: + +- Reparaturen manuell anlegen und bearbeiten +- eindeutige Reparaturnummer im Format `R-`, z. B. `R2026-000001` +- Statusmodell von `new` bis `completed` oder `cancelled` +- Statushistorie in `repair_status_history` +- Audit Logs fuer Erstellung, Bearbeitung, Statuswechsel, Storno und Intake +- Dashboard-Kennzahlen fuer neue Reparaturen, Diagnose, wartende Kundenfreigaben und abgeschlossene Reparaturen + +Tabellen: + +- `repairs` +- `repair_status_history` +- `repair_intake_events` +- `repair_documents` + +Hermes-Endpunkte: + +- `GET /repairs` +- `GET /repairs/{id}` +- `POST /repairs` +- `PUT /repairs/{id}` +- `DELETE /repairs/{id}` als fachliches Storno +- `PUT /repairs/{id}/status` +- `GET /repairs/{id}/history` + +Athena-BFF-Routen spiegeln diese Endpunkte unter `/api/repairs...`. Mutierende Athena-Routen behalten die Same-Origin-Pruefung bei. + +### Website Repair Intake + +Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit. + +Sicherheitsmodell: + +- keine Browser-Cookie- oder JWT-Authentifizierung +- Token per Header `X-Olympus-Intake-Token` +- Token aus `OLYMPUS_REPAIR_INTAKE_TOKEN` +- keine CORS-Freigabe fuer Browser erforderlich +- Token wird nicht geloggt + +Der Intake speichert immer ein `repair_intake_events`-Ereignis und erzeugt bei erfolgreicher Verarbeitung einen Reparaturdatensatz mit `source = website` und `status = new`. + +Eine spaetere Kundenportal-Statusseite ist architektonisch vorbereitet, aber noch nicht oeffentlich implementiert. + ## RBAC: Rollen und Berechtigungen Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module. @@ -774,6 +829,9 @@ docker-compose.yml `KNOWLEDGE_MAX_UPLOAD_MB` : Legacy-Fallback fuer die maximale Knowledge-Uploadgroesse. +`OLYMPUS_REPAIR_INTAKE_TOKEN` +: Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen. + ### Athena `HERMES_INTERNAL_URL` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d514c09..6acf9f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -158,6 +158,7 @@ Neue CRM-Module sollen die bestehenden Muster wiederverwenden. Beispiele fuer spaetere Module: - Kunden +- Reparaturen - Projekte - Lieferanten - Kontakte diff --git a/README-DEV.md b/README-DEV.md index 408518b..d977ce5 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -33,6 +33,7 @@ STORAGE_PROVIDER=local STORAGE_BASE_PATH=/data/storage STORAGE_MAX_UPLOAD_MB=50 STORAGE_HOST_PATH=./storage +OLYMPUS_REPAIR_INTAKE_TOKEN= ``` Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git. @@ -128,6 +129,32 @@ Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis Hermes legt beim Startup Standard-Hersteller an, wenn sie noch fehlen. Danach koennen Geraete angelegt werden. Dokumentuploads sind erst sinnvoll, wenn Hersteller und Geraet vorhanden sind; Athena blockiert unvollstaendige Uploads direkt im Formular. +## Reparaturmanagement + +Das Reparaturmodul ist ab v0.8.0 aktiv. + +Lokaler Ablauf: + +```bash +cd backend/hermes +SECRET_KEY=local-check uv run alembic upgrade head +``` + +Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen: + +- `/api/repairs` +- `/api/repairs/[id]` +- `/api/repairs/[id]/status` +- `/api/repairs/[id]/history` + +Hermes stellt zusaetzlich `POST /public/repair-intake` fuer die spaetere Website-Anbindung bereit. Der Endpunkt ist fuer Server-zu-Server-Kommunikation gedacht und erwartet `X-Olympus-Intake-Token`. Der Token wird ueber `OLYMPUS_REPAIR_INTAKE_TOKEN` gesetzt und darf nicht im Browser verwendet werden. + +Vorbereitete Kundenportal-Routen fuer spaeter: + +- `/status` +- `/reparatur/status` +- `/portal/login` + ## Typische Fehler Hermes restartet wegen fehlender Migration: diff --git a/ROADMAP.md b/ROADMAP.md index 5bbf234..3cfaf17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -74,13 +74,23 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Idempotentes Standard-Hersteller-Seeding - Klarere Hermes-Fehler fuer unvollstaendige Dokumentzuordnungen -## v0.8.0 - Projektmodul, geplant +## v0.8.0 - Reparaturmanagement -- Projektstammdaten -- Projektstatus und Verantwortliche -- Verknuepfung mit Kunden -- RBAC-Permissions fuer Projekte -- Audit Logs fuer Projektaktionen +- Reparaturstammdaten und Werkstattvorgaenge +- Reparaturnummern im Format `R-000001` +- Statusmodell und Statushistorie +- Website Repair Intake API vorbereitet +- RBAC-Permissions fuer Reparaturen +- Audit Logs und Activity Feed fuer Reparaturaktionen +- Dashboard-Kennzahlen fuer Reparaturen +- Athena-BFF-Routen und Reparatur-UI + +## v0.8.1 - Kundenportal-Status, geplant + +- `/status` als Einstieg fuer Statusabfragen +- `/reparatur/status` fuer Reparaturstatus +- `/portal/login` fuer spaeteren Kundenlogin +- Keine Browser-Secrets und keine direkten Hermes-Zugriffe ## v0.9.0 - Tickets, geplant diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index 72e203e..33eff2d 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -18,3 +18,4 @@ STORAGE_BASE_PATH=/data/storage STORAGE_MAX_UPLOAD_MB=50 KNOWLEDGE_STORAGE_PATH=/data/knowledge KNOWLEDGE_MAX_UPLOAD_MB=50 +OLYMPUS_REPAIR_INTAKE_TOKEN= diff --git a/backend/hermes/alembic/env.py b/backend/hermes/alembic/env.py index 7ca767a..fb89de0 100644 --- a/backend/hermes/alembic/env.py +++ b/backend/hermes/alembic/env.py @@ -8,6 +8,11 @@ from alembic import context from app.core.config import settings from app.db.database import Base import app.models.user +import app.models.customer +import app.models.knowledge +import app.models.audit +import app.models.rbac +import app.models.repair config = context.config diff --git a/backend/hermes/alembic/versions/c8e2f1a9b704_create_repairs.py b/backend/hermes/alembic/versions/c8e2f1a9b704_create_repairs.py new file mode 100644 index 0000000..d4f12ec --- /dev/null +++ b/backend/hermes/alembic/versions/c8e2f1a9b704_create_repairs.py @@ -0,0 +1,204 @@ +"""create repairs module + +Revision ID: c8e2f1a9b704 +Revises: b6f3d8c1a920 +Create Date: 2026-07-04 11:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c8e2f1a9b704" +down_revision: Union[str, Sequence[str], None] = "b6f3d8c1a920" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +REPAIR_PERMISSIONS = [ + ("repairs.read", "Reparaturen lesen", "Reparaturen anzeigen", "repairs"), + ("repairs.create", "Reparaturen erstellen", "Reparaturen anlegen", "repairs"), + ("repairs.update", "Reparaturen bearbeiten", "Reparaturen aktualisieren", "repairs"), + ("repairs.delete", "Reparaturen löschen", "Reparaturen stornieren", "repairs"), + ("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"), + ("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"), + ("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"), +] + +ROLE_PERMISSIONS = { + "administrator": [permission[0] for permission in REPAIR_PERMISSIONS], + "management": ["repairs.read", "repairs.create", "repairs.update", "repairs.status.update"], + "technician": ["repairs.read", "repairs.update", "repairs.status.update"], + "support": ["repairs.read", "repairs.create", "repairs.update", "repairs.status.update", "repairs.intake"], +} + + +def upgrade() -> None: + op.create_table( + "repairs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("repair_number", sa.String(length=20), nullable=False), + sa.Column("customer_id", sa.Integer(), nullable=True), + sa.Column("contact_id", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=50), nullable=False), + sa.Column("priority", sa.String(length=30), nullable=False), + sa.Column("source", sa.String(length=50), nullable=False), + sa.Column("source_reference", sa.String(length=120), nullable=True), + sa.Column("customer_name", sa.String(length=255), nullable=False), + sa.Column("customer_email", sa.String(length=255), server_default="", nullable=False), + sa.Column("customer_phone", sa.String(length=80), server_default="", nullable=False), + sa.Column("device_manufacturer", sa.String(length=160), nullable=False), + sa.Column("device_model", sa.String(length=160), nullable=False), + sa.Column("device_serial_number", sa.String(length=160), nullable=True), + sa.Column("device_type", sa.String(length=120), server_default="", nullable=False), + sa.Column("accessories", sa.Text(), server_default="", nullable=False), + sa.Column("fault_description", sa.Text(), nullable=False), + sa.Column("previous_work", sa.Text(), server_default="", nullable=False), + sa.Column("device_opened", sa.Boolean(), server_default="false", nullable=False), + sa.Column("intake_notes", sa.Text(), server_default="", nullable=False), + sa.Column("diagnosis_notes", sa.Text(), server_default="", nullable=False), + sa.Column("repair_notes", sa.Text(), server_default="", nullable=False), + sa.Column("estimate_notes", sa.Text(), server_default="", nullable=False), + sa.Column("estimated_cost_cents", sa.Integer(), nullable=True), + sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + 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.CheckConstraint("status in ('new','accepted','diagnosis','estimate','waiting_for_customer','approved','repair','final_test','ready_for_pickup','shipped','completed','cancelled')", name="ck_repairs_status"), + sa.CheckConstraint("priority in ('low','normal','high','urgent')", name="ck_repairs_priority"), + sa.CheckConstraint("source in ('manual','website','customer_portal','email','phone')", name="ck_repairs_source"), + sa.ForeignKeyConstraint(["contact_id"], ["customer_contacts.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("repair_number"), + ) + op.create_index(op.f("ix_repairs_contact_id"), "repairs", ["contact_id"], unique=False) + op.create_index(op.f("ix_repairs_customer_id"), "repairs", ["customer_id"], unique=False) + op.create_index(op.f("ix_repairs_customer_name"), "repairs", ["customer_name"], unique=False) + op.create_index(op.f("ix_repairs_device_manufacturer"), "repairs", ["device_manufacturer"], unique=False) + op.create_index(op.f("ix_repairs_device_model"), "repairs", ["device_model"], unique=False) + op.create_index(op.f("ix_repairs_priority"), "repairs", ["priority"], unique=False) + op.create_index(op.f("ix_repairs_repair_number"), "repairs", ["repair_number"], unique=True) + op.create_index(op.f("ix_repairs_source"), "repairs", ["source"], unique=False) + op.create_index(op.f("ix_repairs_source_reference"), "repairs", ["source_reference"], unique=False) + op.create_index(op.f("ix_repairs_status"), "repairs", ["status"], unique=False) + + op.create_table( + "repair_status_history", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("repair_id", sa.Integer(), nullable=False), + sa.Column("old_status", sa.String(length=50), nullable=True), + sa.Column("new_status", sa.String(length=50), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_repair_status_history_actor_user_id"), "repair_status_history", ["actor_user_id"], unique=False) + op.create_index(op.f("ix_repair_status_history_new_status"), "repair_status_history", ["new_status"], unique=False) + op.create_index(op.f("ix_repair_status_history_repair_id"), "repair_status_history", ["repair_id"], unique=False) + + op.create_table( + "repair_intake_events", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("repair_id", sa.Integer(), nullable=True), + sa.Column("external_source", sa.String(length=80), nullable=False), + sa.Column("external_reference", sa.String(length=160), nullable=True), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("status", sa.String(length=50), nullable=False), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_repair_intake_events_external_reference"), "repair_intake_events", ["external_reference"], unique=False) + op.create_index(op.f("ix_repair_intake_events_external_source"), "repair_intake_events", ["external_source"], unique=False) + op.create_index(op.f("ix_repair_intake_events_repair_id"), "repair_intake_events", ["repair_id"], unique=False) + op.create_index(op.f("ix_repair_intake_events_status"), "repair_intake_events", ["status"], unique=False) + + op.create_table( + "repair_documents", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("repair_id", sa.Integer(), nullable=False), + sa.Column("file_id", sa.Integer(), nullable=True), + sa.Column("storage_path", sa.String(length=500), nullable=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("document_type", sa.String(length=80), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_repair_documents_document_type"), "repair_documents", ["document_type"], unique=False) + op.create_index(op.f("ix_repair_documents_repair_id"), "repair_documents", ["repair_id"], unique=False) + + for name, display_name, description, module in REPAIR_PERMISSIONS: + op.execute( + sa.text( + """ + insert into permissions (name, display_name, description, module) + values (:name, :display_name, :description, :module) + on conflict (name) do update set + display_name = excluded.display_name, + description = excluded.description, + module = excluded.module + """ + ).bindparams(name=name, display_name=display_name, description=description, module=module) + ) + + for role_name, permissions in ROLE_PERMISSIONS.items(): + for permission_name in permissions: + op.execute( + sa.text( + """ + insert into role_permissions (role_id, permission_id) + select roles.id, permissions.id + from roles, permissions + where roles.name = :role_name and permissions.name = :permission_name + on conflict do nothing + """ + ).bindparams(role_name=role_name, permission_name=permission_name) + ) + + +def downgrade() -> None: + for permission_name, *_ in REPAIR_PERMISSIONS: + op.execute( + sa.text( + """ + delete from role_permissions + using permissions + where role_permissions.permission_id = permissions.id + and permissions.name = :permission_name + """ + ).bindparams(permission_name=permission_name) + ) + op.execute(sa.text("delete from permissions where name = :name").bindparams(name=permission_name)) + + op.drop_index(op.f("ix_repair_documents_repair_id"), table_name="repair_documents") + op.drop_index(op.f("ix_repair_documents_document_type"), table_name="repair_documents") + op.drop_table("repair_documents") + op.drop_index(op.f("ix_repair_intake_events_status"), table_name="repair_intake_events") + op.drop_index(op.f("ix_repair_intake_events_repair_id"), table_name="repair_intake_events") + op.drop_index(op.f("ix_repair_intake_events_external_source"), table_name="repair_intake_events") + op.drop_index(op.f("ix_repair_intake_events_external_reference"), table_name="repair_intake_events") + op.drop_table("repair_intake_events") + op.drop_index(op.f("ix_repair_status_history_repair_id"), table_name="repair_status_history") + op.drop_index(op.f("ix_repair_status_history_new_status"), table_name="repair_status_history") + op.drop_index(op.f("ix_repair_status_history_actor_user_id"), table_name="repair_status_history") + op.drop_table("repair_status_history") + op.drop_index(op.f("ix_repairs_status"), table_name="repairs") + op.drop_index(op.f("ix_repairs_source_reference"), table_name="repairs") + op.drop_index(op.f("ix_repairs_source"), table_name="repairs") + op.drop_index(op.f("ix_repairs_repair_number"), table_name="repairs") + op.drop_index(op.f("ix_repairs_priority"), table_name="repairs") + op.drop_index(op.f("ix_repairs_device_model"), table_name="repairs") + op.drop_index(op.f("ix_repairs_device_manufacturer"), table_name="repairs") + op.drop_index(op.f("ix_repairs_customer_name"), table_name="repairs") + op.drop_index(op.f("ix_repairs_customer_id"), table_name="repairs") + op.drop_index(op.f("ix_repairs_contact_id"), table_name="repairs") + op.drop_table("repairs") diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index 6929337..efa3d8e 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -56,6 +56,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool: return "roles.read" in permissions if action.startswith("knowledge."): return "knowledge.read" in permissions + if action.startswith("repairs."): + return "repairs.read" in permissions if action.startswith("audit_logs."): return "audit_logs.read" in permissions if action.startswith("auth."): diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 4b1a644..05cb601 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -9,6 +9,7 @@ from app.db.database import get_db from app.models.rbac import Role from app.models.user import User from app.repositories.customer_repository import CustomerRepository +from app.repositories.repair_repository import RepairRepository from app.repositories.user_repository import UserRepository from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard @@ -30,6 +31,7 @@ def get_dashboard_summary( latest_customers = [] users: list[MetricCard] = [] roles: list[MetricCard] = [] + repairs: list[MetricCard] = [] if "customers.read" in permissions: customers = [ @@ -54,6 +56,14 @@ def get_dashboard_summary( ) ] + if "repairs.read" in permissions: + repairs = [ + MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")), + MetricCard(label="In Diagnose", value=RepairRepository.count_by_status(db, "diagnosis")), + MetricCard(label="Warten auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")), + MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")), + ] + logger.info("dashboard.summary", extra={"actor_user_id": current_user.id}) return DashboardSummary( @@ -61,6 +71,7 @@ def get_dashboard_summary( latest_customers=latest_customers, users=users, roles=roles, + repairs=repairs, activities=EmptyWidget( title="Letzte Aktivitäten", message="Noch keine Aktivitäten vorhanden.", diff --git a/backend/hermes/app/api/repairs.py b/backend/hermes/app/api/repairs.py new file mode 100644 index 0000000..0973960 --- /dev/null +++ b/backend/hermes/app/api/repairs.py @@ -0,0 +1,142 @@ +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from sqlalchemy.orm import Session +from starlette.requests import Request +from typing import cast + +from app.core.config import settings +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.repair import Repair +from app.models.user import User +from app.repositories.repair_repository import RepairRepository +from app.schemas.repair import ( + RepairCreate, + RepairIntakePayload, + RepairIntakeResponse, + RepairListResponse, + RepairResponse, + RepairStatus, + RepairStatusHistoryResponse, + RepairStatusUpdate, + RepairUpdate, +) +from app.services.repair_service import RepairService + +router = APIRouter(tags=["Repairs"]) + + +def get_repair_or_404(db: Session, repair_id: int) -> Repair: + repair = RepairRepository.get_by_id(db, repair_id) + if repair is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden") + return repair + + +@router.get("/repairs", response_model=RepairListResponse) +@router.get("/repairs/", response_model=RepairListResponse, include_in_schema=False) +def get_repairs( + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = None, + customer_id: int | None = None, + source: str | None = None, + priority: str | None = None, + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + items, total = RepairRepository.list( + db, + status=status_filter, + q=q, + customer_id=customer_id, + source=source, + priority=priority, + limit=limit, + offset=offset, + ) + return RepairListResponse(items=items, total=total, limit=limit, offset=offset) + + +@router.get("/repairs/{repair_id}", response_model=RepairResponse) +def get_repair( + repair_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + return get_repair_or_404(db, repair_id) + + +@router.post("/repairs", response_model=RepairResponse, status_code=status.HTTP_201_CREATED) +@router.post("/repairs/", response_model=RepairResponse, status_code=status.HTTP_201_CREATED, include_in_schema=False) +def create_repair( + repair: RepairCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.create")), +): + return RepairService.create(db, repair, actor=current_user, request=request) + + +@router.put("/repairs/{repair_id}", response_model=RepairResponse) +def update_repair( + repair_id: int, + repair: RepairUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + return RepairService.update(db, db_repair, repair, actor=current_user, request=request) + + +@router.delete("/repairs/{repair_id}", response_model=RepairResponse) +def delete_repair( + repair_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.delete")), +): + db_repair = get_repair_or_404(db, repair_id) + return RepairService.cancel(db, db_repair, actor=current_user, request=request) + + +@router.put("/repairs/{repair_id}/status", response_model=RepairResponse) +def update_repair_status( + repair_id: int, + payload: RepairStatusUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.status.update")), +): + db_repair = get_repair_or_404(db, repair_id) + return RepairService.update_status(db, db_repair, payload, actor=current_user, request=request) + + +@router.get("/repairs/{repair_id}/history", response_model=list[RepairStatusHistoryResponse]) +def get_repair_history( + repair_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + return RepairRepository.get_history(db, repair_id) + + +@router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED) +def create_repair_intake( + payload: RepairIntakePayload, + request: Request, + x_olympus_intake_token: str | None = Header(default=None, alias="X-Olympus-Intake-Token"), + db: Session = Depends(get_db), +): + expected_token = settings.olympus_repair_intake_token + if not expected_token or not x_olympus_intake_token or x_olympus_intake_token != expected_token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht autorisiert") + + repair = RepairService.create_from_intake(db, payload, request=request) + return RepairIntakeResponse( + repair_number=repair.repair_number, + status=cast(RepairStatus, repair.status), + message="Reparaturanfrage übernommen", + ) diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index c336295..ba364cf 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -23,6 +23,7 @@ class Settings(BaseSettings): storage_max_upload_mb: int = 50 knowledge_storage_path: str = "/data/knowledge" knowledge_max_upload_mb: int = 50 + olympus_repair_intake_token: str | None = None model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index b7f2f70..fbcc986 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -15,6 +15,7 @@ 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.repairs import router as repairs_router from app.api.roles import router as roles_router from app.api.users import router as users_router from app.db.database import SessionLocal @@ -39,6 +40,7 @@ app.include_router(roles_router) app.include_router(permissions_router) app.include_router(customers_router) app.include_router(knowledge_router) +app.include_router(repairs_router) app.include_router(dashboard_router) logger = logging.getLogger(__name__) diff --git a/backend/hermes/app/models/repair.py b/backend/hermes/app/models/repair.py new file mode 100644 index 0000000..806992e --- /dev/null +++ b/backend/hermes/app/models/repair.py @@ -0,0 +1,81 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.database import Base + + +class Repair(Base): + __tablename__ = "repairs" + + id: Mapped[int] = mapped_column(primary_key=True) + repair_number: Mapped[str] = mapped_column(String(20), unique=True, index=True) + customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id", ondelete="SET NULL"), nullable=True, index=True) + contact_id: Mapped[int | None] = mapped_column(ForeignKey("customer_contacts.id", ondelete="SET NULL"), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(50), index=True) + priority: Mapped[str] = mapped_column(String(30), index=True) + source: Mapped[str] = mapped_column(String(50), index=True) + source_reference: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + customer_name: Mapped[str] = mapped_column(String(255), index=True) + customer_email: Mapped[str] = mapped_column(String(255), default="", server_default="") + customer_phone: Mapped[str] = mapped_column(String(80), default="", server_default="") + device_manufacturer: Mapped[str] = mapped_column(String(160), index=True) + device_model: Mapped[str] = mapped_column(String(160), index=True) + device_serial_number: Mapped[str | None] = mapped_column(String(160), nullable=True) + device_type: Mapped[str] = mapped_column(String(120), default="", server_default="") + accessories: Mapped[str] = mapped_column(Text, default="", server_default="") + fault_description: Mapped[str] = mapped_column(Text) + previous_work: Mapped[str] = mapped_column(Text, default="", server_default="") + device_opened: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + intake_notes: Mapped[str] = mapped_column(Text, default="", server_default="") + diagnosis_notes: Mapped[str] = mapped_column(Text, default="", server_default="") + repair_notes: Mapped[str] = mapped_column(Text, default="", server_default="") + estimate_notes: Mapped[str] = mapped_column(Text, default="", server_default="") + estimated_cost_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + 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()) + + history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin") + + +class RepairStatusHistory(Base): + __tablename__ = "repair_status_history" + + id: Mapped[int] = mapped_column(primary_key=True) + repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True) + old_status: Mapped[str | None] = mapped_column(String(50), nullable=True) + new_status: Mapped[str] = mapped_column(String(50), index=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + repair: Mapped[Repair] = relationship(back_populates="history") + + +class RepairIntakeEvent(Base): + __tablename__ = "repair_intake_events" + + id: Mapped[int] = mapped_column(primary_key=True) + repair_id: Mapped[int | None] = mapped_column(ForeignKey("repairs.id", ondelete="SET NULL"), nullable=True, index=True) + external_source: Mapped[str] = mapped_column(String(80), index=True) + external_reference: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True) + payload: Mapped[dict] = mapped_column(JSON) + status: Mapped[str] = mapped_column(String(50), index=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class RepairDocument(Base): + __tablename__ = "repair_documents" + + id: Mapped[int] = mapped_column(primary_key=True) + repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True) + file_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + title: Mapped[str] = mapped_column(String(255)) + document_type: Mapped[str] = mapped_column(String(80), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 2a67941..3289856 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -76,6 +76,13 @@ STANDARD_PERMISSIONS = [ ("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"), ("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"), ("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"), + ("repairs.read", "Reparaturen lesen", "Reparaturen anzeigen", "repairs"), + ("repairs.create", "Reparaturen erstellen", "Reparaturen anlegen", "repairs"), + ("repairs.update", "Reparaturen bearbeiten", "Reparaturen aktualisieren", "repairs"), + ("repairs.delete", "Reparaturen löschen", "Reparaturen stornieren", "repairs"), + ("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"), + ("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"), + ("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"), ] ROLE_PERMISSION_NAMES = { @@ -88,6 +95,10 @@ ROLE_PERMISSION_NAMES = { "knowledge.download", "projects.read", "tickets.read", + "repairs.read", + "repairs.create", + "repairs.update", + "repairs.status.update", }, "sales": { "dashboard.read", @@ -108,6 +119,9 @@ ROLE_PERMISSION_NAMES = { "knowledge.update", "knowledge.upload", "knowledge.download", + "repairs.read", + "repairs.update", + "repairs.status.update", }, "support": { "dashboard.read", @@ -118,6 +132,11 @@ ROLE_PERMISSION_NAMES = { "tickets.read", "tickets.create", "tickets.update", + "repairs.read", + "repairs.create", + "repairs.update", + "repairs.status.update", + "repairs.intake", }, "warehouse": { "dashboard.read", diff --git a/backend/hermes/app/repositories/repair_repository.py b/backend/hermes/app/repositories/repair_repository.py new file mode 100644 index 0000000..20e860c --- /dev/null +++ b/backend/hermes/app/repositories/repair_repository.py @@ -0,0 +1,219 @@ +from datetime import UTC, datetime + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.models.repair import Repair, RepairIntakeEvent, RepairStatusHistory +from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate + + +class RepairRepository: + @staticmethod + def query( + *, + status: str | None = None, + q: str | None = None, + customer_id: int | None = None, + source: str | None = None, + priority: str | None = None, + ) -> Select[tuple[Repair]]: + query = select(Repair).options(selectinload(Repair.history)) + if status: + query = query.where(Repair.status == status) + if customer_id is not None: + query = query.where(Repair.customer_id == customer_id) + if source: + query = query.where(Repair.source == source) + if priority: + query = query.where(Repair.priority == priority) + if q: + term = f"%{q.strip()}%" + query = query.where( + or_( + Repair.repair_number.ilike(term), + Repair.customer_name.ilike(term), + Repair.device_manufacturer.ilike(term), + Repair.device_model.ilike(term), + Repair.fault_description.ilike(term), + Repair.source_reference.ilike(term), + ) + ) + return query + + @staticmethod + def list( + db: Session, + *, + status: str | None = None, + q: str | None = None, + customer_id: int | None = None, + source: str | None = None, + priority: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[Repair], int]: + base_query = RepairRepository.query( + status=status, + q=q, + customer_id=customer_id, + source=source, + priority=priority, + ) + total = db.scalar(select(func.count()).select_from(base_query.subquery())) or 0 + items = list( + db.scalars( + base_query + .order_by(Repair.created_at.desc(), Repair.id.desc()) + .offset(offset) + .limit(limit) + ) + ) + return items, total + + @staticmethod + def get_by_id(db: Session, repair_id: int) -> Repair | None: + return db.scalar(select(Repair).options(selectinload(Repair.history)).where(Repair.id == repair_id)) + + @staticmethod + def get_next_number(db: Session, year: int) -> str: + prefix = f"R{year}-" + latest = db.scalar( + select(Repair.repair_number) + .where(Repair.repair_number.like(f"{prefix}%")) + .order_by(Repair.repair_number.desc()) + .limit(1) + ) + next_number = 1 + if latest: + next_number = int(latest.split("-")[-1]) + 1 + return f"{prefix}{next_number:06d}" + + @staticmethod + def create(db: Session, payload: RepairCreate, *, repair_number: str) -> Repair: + repair = Repair(repair_number=repair_number, **RepairRepository._payload_data(payload)) + db.add(repair) + db.flush() + RepairRepository.add_history(db, repair.id, old_status=None, new_status=repair.status, note="Reparatur angelegt", actor_user_id=None) + db.commit() + db.refresh(repair) + return RepairRepository.get_by_id(db, repair.id) or repair + + @staticmethod + def update(db: Session, repair: Repair, payload: RepairUpdate) -> Repair: + for key, value in RepairRepository._payload_data(payload).items(): + setattr(repair, key, value) + RepairRepository._apply_status_timestamps(repair) + db.commit() + db.refresh(repair) + return RepairRepository.get_by_id(db, repair.id) or repair + + @staticmethod + def update_status(db: Session, repair: Repair, payload: RepairStatusUpdate, *, actor_user_id: int | None) -> Repair: + old_status = repair.status + repair.status = payload.status + RepairRepository._apply_status_timestamps(repair) + RepairRepository.add_history( + db, + repair.id, + old_status=old_status, + new_status=payload.status, + note=payload.note, + actor_user_id=actor_user_id, + ) + db.commit() + db.refresh(repair) + return RepairRepository.get_by_id(db, repair.id) or repair + + @staticmethod + def cancel(db: Session, repair: Repair, *, actor_user_id: int | None) -> Repair: + old_status = repair.status + repair.status = "cancelled" + repair.completed_at = datetime.now(UTC) + RepairRepository.add_history( + db, + repair.id, + old_status=old_status, + new_status="cancelled", + note="Reparatur storniert", + actor_user_id=actor_user_id, + ) + db.commit() + db.refresh(repair) + return repair + + @staticmethod + def add_history(db: Session, repair_id: int, *, old_status: str | None, new_status: str, note: str | None, actor_user_id: int | None) -> RepairStatusHistory: + history = RepairStatusHistory( + repair_id=repair_id, + old_status=old_status, + new_status=new_status, + note=note, + actor_user_id=actor_user_id, + ) + db.add(history) + return history + + @staticmethod + def get_history(db: Session, repair_id: int) -> list[RepairStatusHistory]: + return list( + db.scalars( + select(RepairStatusHistory) + .where(RepairStatusHistory.repair_id == repair_id) + .order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc()) + ) + ) + + @staticmethod + def count_by_status(db: Session, status: str) -> int: + return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0 + + @staticmethod + def create_intake_event( + db: Session, + *, + external_source: str, + external_reference: str | None, + payload: dict, + status: str, + error_message: str | None = None, + repair_id: int | None = None, + processed_at: datetime | None = None, + ) -> RepairIntakeEvent: + event = RepairIntakeEvent( + external_source=external_source, + external_reference=external_reference, + payload=payload, + status=status, + error_message=error_message, + repair_id=repair_id, + processed_at=processed_at, + ) + db.add(event) + db.commit() + db.refresh(event) + return event + + @staticmethod + def update_intake_event(db: Session, event: RepairIntakeEvent, *, status: str, repair_id: int | None = None, error_message: str | None = None) -> RepairIntakeEvent: + event.status = status + event.repair_id = repair_id + event.error_message = error_message + event.processed_at = datetime.now(UTC) + db.commit() + db.refresh(event) + return event + + @staticmethod + def _payload_data(payload: RepairCreate | RepairUpdate) -> dict: + data = payload.model_dump() + data["customer_email"] = str(payload.customer_email or "") + data["device_serial_number"] = payload.device_serial_number or None + data["source_reference"] = payload.source_reference or None + return data + + @staticmethod + def _apply_status_timestamps(repair: Repair) -> None: + if repair.status == "approved" and repair.approved_at is None: + repair.approved_at = datetime.now(UTC) + if repair.status in {"completed", "cancelled"} and repair.completed_at is None: + repair.completed_at = datetime.now(UTC) diff --git a/backend/hermes/app/schemas/dashboard.py b/backend/hermes/app/schemas/dashboard.py index 89dedcc..ae89527 100644 --- a/backend/hermes/app/schemas/dashboard.py +++ b/backend/hermes/app/schemas/dashboard.py @@ -18,6 +18,7 @@ class DashboardSummary(BaseModel): latest_customers: list[CustomerResponse] = [] users: list[MetricCard] = [] roles: list[MetricCard] = [] + repairs: list[MetricCard] = [] activities: EmptyWidget tasks: EmptyWidget tickets: EmptyWidget diff --git a/backend/hermes/app/schemas/repair.py b/backend/hermes/app/schemas/repair.py new file mode 100644 index 0000000..2f89adf --- /dev/null +++ b/backend/hermes/app/schemas/repair.py @@ -0,0 +1,162 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator + +RepairStatus = Literal[ + "new", + "accepted", + "diagnosis", + "estimate", + "waiting_for_customer", + "approved", + "repair", + "final_test", + "ready_for_pickup", + "shipped", + "completed", + "cancelled", +] +RepairPriority = Literal["low", "normal", "high", "urgent"] +RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"] + + +def normalize_text(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +class RepairBase(BaseModel): + customer_id: int | None = None + contact_id: int | None = None + status: RepairStatus = "new" + priority: RepairPriority = "normal" + source: RepairSource = "manual" + source_reference: str | None = Field(default=None, max_length=120) + customer_name: str = Field(default="", max_length=255) + customer_email: EmailStr | None = None + customer_phone: str = Field(default="", max_length=80) + device_manufacturer: str = Field(min_length=1, max_length=160) + device_model: str = Field(min_length=1, max_length=160) + device_serial_number: str | None = Field(default=None, max_length=160) + device_type: str = Field(default="", max_length=120) + accessories: str = "" + fault_description: str = Field(min_length=1) + previous_work: str = "" + device_opened: bool = False + intake_notes: str = "" + diagnosis_notes: str = "" + repair_notes: str = "" + estimate_notes: str = "" + estimated_cost_cents: int | None = Field(default=None, ge=0) + + @field_validator( + "source_reference", + "customer_name", + "customer_phone", + "device_manufacturer", + "device_model", + "device_serial_number", + "device_type", + "accessories", + "fault_description", + "previous_work", + "intake_notes", + "diagnosis_notes", + "repair_notes", + "estimate_notes", + mode="before", + ) + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + @model_validator(mode="after") + def validate_customer_identity(self): + if self.customer_id is None and not self.customer_name: + raise ValueError("Kundenname ist erforderlich, wenn kein Kunde ausgewählt ist") + return self + + +class RepairCreate(RepairBase): + pass + + +class RepairUpdate(RepairBase): + pass + + +class RepairStatusUpdate(BaseModel): + status: RepairStatus + note: str | None = None + + @field_validator("note", mode="before") + @classmethod + def normalize_note(cls, value: object) -> str | None: + if value is None: + return None + text = normalize_text(value) + return text or None + + +class RepairResponse(RepairBase): + id: int + repair_number: str + customer_email: str + device_serial_number: str | None + approved_at: datetime | None + completed_at: datetime | None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class RepairListResponse(BaseModel): + items: list[RepairResponse] + total: int + limit: int + offset: int + + +class RepairStatusHistoryResponse(BaseModel): + id: int + repair_id: int + old_status: str | None + new_status: str + note: str | None + actor_user_id: int | None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class RepairIntakePayload(BaseModel): + name: str = Field(min_length=1, max_length=255) + email: EmailStr | None = None + phone: str = Field(default="", max_length=80) + manufacturer: str = Field(min_length=1, max_length=160) + model: str = Field(min_length=1, max_length=160) + serial_number: str | None = Field(default=None, max_length=160) + device_type: str = Field(default="", max_length=120) + accessories: str = "" + fault_description: str = Field(min_length=1) + previous_work: str = "" + device_opened: bool = False + source_reference: str | None = Field(default=None, max_length=120) + + @field_validator("*", mode="before") + @classmethod + def normalize_payload(cls, value: object) -> object: + if isinstance(value, str): + return value.strip() + return value + + +class RepairIntakeResponse(BaseModel): + repair_number: str + status: RepairStatus + message: str diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 772e8e1..746d242 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -157,6 +157,12 @@ def action_title(action: str) -> str: "knowledge.notes.create": "Wissensnotiz erstellt", "knowledge.notes.update": "Wissensnotiz bearbeitet", "knowledge.notes.delete": "Wissensnotiz gelöscht", + "repairs.create": "Reparatur erstellt", + "repairs.update": "Reparatur bearbeitet", + "repairs.status_update": "Reparaturstatus geändert", + "repairs.cancel": "Reparatur storniert", + "repairs.intake_processed": "Reparatur-Intake verarbeitet", + "repairs.intake_failed": "Reparatur-Intake fehlgeschlagen", } return labels.get(action, action) diff --git a/backend/hermes/app/services/repair_service.py b/backend/hermes/app/services/repair_service.py new file mode 100644 index 0000000..d84c9f4 --- /dev/null +++ b/backend/hermes/app/services/repair_service.py @@ -0,0 +1,157 @@ +from datetime import UTC, datetime + +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.models.repair import Repair +from app.models.user import User +from app.repositories.customer_repository import CustomerRepository +from app.repositories.repair_repository import RepairRepository +from app.schemas.repair import RepairCreate, RepairIntakePayload, RepairStatusUpdate, RepairUpdate +from app.services.audit_service import sanitize, write_audit_log + + +def repair_label(repair: Repair) -> str: + return f"{repair.repair_number} · {repair.customer_name}" + + +class RepairService: + @staticmethod + def create(db: Session, payload: RepairCreate, *, actor: User | None, request: Request | None = None) -> Repair: + RepairService._validate_customer(db, payload.customer_id) + repair_number = RepairRepository.get_next_number(db, datetime.now(UTC).year) + try: + repair = RepairRepository.create(db, payload, repair_number=repair_number) + except IntegrityError: + db.rollback() + repair_number = RepairRepository.get_next_number(db, datetime.now(UTC).year) + repair = RepairRepository.create(db, payload, repair_number=repair_number) + write_audit_log( + db, + action="repairs.create", + entity_type="repairs", + entity_id=repair.id, + entity_label=repair_label(repair), + actor=actor, + request=request, + after_data=repair, + ) + return repair + + @staticmethod + def update(db: Session, repair: Repair, payload: RepairUpdate, *, actor: User, request: Request) -> Repair: + RepairService._validate_customer(db, payload.customer_id) + before_data = sanitize(repair) + updated = RepairRepository.update(db, repair, payload) + write_audit_log( + db, + action="repairs.update", + entity_type="repairs", + entity_id=updated.id, + entity_label=repair_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=updated, + ) + return updated + + @staticmethod + def update_status(db: Session, repair: Repair, payload: RepairStatusUpdate, *, actor: User, request: Request) -> Repair: + before_data = sanitize(repair) + updated = RepairRepository.update_status(db, repair, payload, actor_user_id=actor.id) + write_audit_log( + db, + action="repairs.status_update", + entity_type="repairs", + entity_id=updated.id, + entity_label=repair_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=updated, + metadata={"old_status": before_data.get("status") if isinstance(before_data, dict) else None, "new_status": updated.status}, + ) + return updated + + @staticmethod + def cancel(db: Session, repair: Repair, *, actor: User, request: Request) -> Repair: + before_data = sanitize(repair) + updated = RepairRepository.cancel(db, repair, actor_user_id=actor.id) + write_audit_log( + db, + action="repairs.cancel", + entity_type="repairs", + entity_id=updated.id, + entity_label=repair_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=updated, + ) + return updated + + @staticmethod + def create_from_intake(db: Session, payload: RepairIntakePayload, *, request: Request | None = None) -> Repair: + event = RepairRepository.create_intake_event( + db, + external_source="website", + external_reference=payload.source_reference, + payload=payload.model_dump(mode="json"), + status="received", + ) + try: + repair_payload = RepairCreate( + customer_id=None, + contact_id=None, + status="new", + priority="normal", + source="website", + source_reference=payload.source_reference, + customer_name=payload.name, + customer_email=payload.email, + customer_phone=payload.phone, + device_manufacturer=payload.manufacturer, + device_model=payload.model, + device_serial_number=payload.serial_number, + device_type=payload.device_type, + accessories=payload.accessories, + fault_description=payload.fault_description, + previous_work=payload.previous_work, + device_opened=payload.device_opened, + intake_notes="Website Intake", + ) + repair = RepairService.create(db, repair_payload, actor=None, request=request) + RepairRepository.update_intake_event(db, event, status="processed", repair_id=repair.id) + write_audit_log( + db, + action="repairs.intake_processed", + entity_type="repairs", + entity_id=repair.id, + entity_label=repair_label(repair), + request=request, + metadata={"external_source": "website", "external_reference": payload.source_reference}, + ) + return repair + except Exception as exc: + RepairRepository.update_intake_event(db, event, status="failed", error_message="Intake konnte nicht verarbeitet werden") + write_audit_log( + db, + action="repairs.intake_failed", + entity_type="repairs", + request=request, + metadata={"external_source": "website", "external_reference": payload.source_reference}, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Reparaturannahme konnte nicht verarbeitet werden", + ) from exc + + @staticmethod + def _validate_customer(db: Session, customer_id: int | None) -> None: + if customer_id is None: + return + if CustomerRepository.get_by_id(db, customer_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kunde nicht gefunden") diff --git a/docker-compose.yml b/docker-compose.yml index 3b1d3d4..36aa4e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50} KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} + OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-} volumes: - ${STORAGE_HOST_PATH:-./storage}:/data/storage diff --git a/frontend/athena/app/api/repairs/[id]/history/route.ts b/frontend/athena/app/api/repairs/[id]/history/route.ts new file mode 100644 index 0000000..22f47c3 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/history/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/history`); +} diff --git a/frontend/athena/app/api/repairs/[id]/route.ts b/frontend/athena/app/api/repairs/[id]/route.ts new file mode 100644 index 0000000..7f6846e --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +async function proxyRepairRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyRepairRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyRepairRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyRepairRequest(request, context); +} diff --git a/frontend/athena/app/api/repairs/[id]/status/route.ts b/frontend/athena/app/api/repairs/[id]/status/route.ts new file mode 100644 index 0000000..2400efa --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/status/route.ts @@ -0,0 +1,21 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function PUT(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/status`); +} diff --git a/frontend/athena/app/api/repairs/route.ts b/frontend/athena/app/api/repairs/route.ts new file mode 100644 index 0000000..1eff9d8 --- /dev/null +++ b/frontend/athena/app/api/repairs/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, `/repairs${request.nextUrl.search}`); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/repairs"); +} diff --git a/frontend/athena/app/dashboard/page.tsx b/frontend/athena/app/dashboard/page.tsx index be833b6..ddfb4c0 100644 --- a/frontend/athena/app/dashboard/page.tsx +++ b/frontend/athena/app/dashboard/page.tsx @@ -62,7 +62,7 @@ export default function DashboardPage() { return
{error || "Keine Dashboarddaten verfügbar"}
; } - const cards = [...summary.customers, ...summary.users, ...summary.roles]; + const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles]; return (
diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx new file mode 100644 index 0000000..3c877c5 --- /dev/null +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { ArrowLeft, Edit, Wrench } from "lucide-react"; + +import DetailSection from "@/components/common/DetailSection"; +import { useToast } from "@/components/common/ToastProvider"; +import RepairFormDialog from "@/components/repairs/RepairFormDialog"; +import { RepairPriorityBadge, RepairStatusBadge } from "@/components/repairs/RepairStatusBadge"; +import RepairStatusDialog from "@/components/repairs/RepairStatusDialog"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { hasPermission } from "@/lib/permissions"; +import type { CurrentUser } from "@/types/rbac"; +import type { Repair, RepairPayload, RepairStatus, RepairStatusHistory } from "@/types/repair"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +function getErrorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden"; + } + return "Aktion konnte nicht abgeschlossen werden"; +} + +function formatDate(value?: string | null) { + if (!value) return "-"; + return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); +} + +function DetailItem({ label, value }: { label: string; value?: string | number | boolean | null }) { + return ( +
+
{label}
+
{typeof value === "boolean" ? (value ? "Ja" : "Nein") : value || "-"}
+
+ ); +} + +export default function RepairDetailPage({ params }: Params) { + const { showToast } = useToast(); + const [repairId, setRepairId] = useState(""); + const [repair, setRepair] = useState(null); + const [history, setHistory] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + const [formOpen, setFormOpen] = useState(false); + const [statusOpen, setStatusOpen] = useState(false); + const [formError, setFormError] = useState(""); + const [statusError, setStatusError] = useState(""); + + useEffect(() => { + params.then((resolved) => setRepairId(resolved.id)); + }, [params]); + + const loadRepair = useCallback(async () => { + if (!repairId) return; + setLoading(true); + setError(""); + try { + const [repairResponse, historyResponse, meResponse] = await Promise.all([ + api.get(`/repairs/${repairId}`), + api.get(`/repairs/${repairId}/history`), + api.get("/me"), + ]); + setRepair(repairResponse.data); + setHistory(historyResponse.data); + setCurrentUser(meResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [repairId]); + + useEffect(() => { + queueMicrotask(() => { + void loadRepair(); + }); + }, [loadRepair]); + + async function saveRepair(payload: RepairPayload) { + if (!repair) return; + setPending(true); + setFormError(""); + try { + const response = await api.put(`/repairs/${repair.id}`, payload); + setRepair(response.data); + setFormOpen(false); + showToast({ type: "success", title: "Reparatur aktualisiert", description: response.data.repair_number }); + } catch (err) { + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Reparatur konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function saveStatus(status: RepairStatus, note: string) { + if (!repair) return; + setPending(true); + setStatusError(""); + try { + const response = await api.put(`/repairs/${repair.id}/status`, { status, note }); + const historyResponse = await api.get(`/repairs/${repair.id}/history`); + setRepair(response.data); + setHistory(historyResponse.data); + setStatusOpen(false); + showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number }); + } catch (err) { + const message = getErrorMessage(err); + setStatusError(message); + showToast({ type: "error", title: "Status konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + if (loading) { + return
Reparatur wird geladen...
; + } + + if (error || !repair) { + return
{error || "Reparatur nicht gefunden"}
; + } + + const canUpdate = hasPermission(currentUser, "repairs.update"); + const canUpdateStatus = hasPermission(currentUser, "repairs.status.update"); + + return ( +
+
+
+ Zurück +
+

{repair.repair_number}

+ + +
+

{repair.customer_name} · {repair.device_manufacturer} {repair.device_model}

+
+
+ {canUpdate && } + {canUpdateStatus && } +
+
+ +
+ +
+ + + + +
+
+ + +
+ + + + + +
+
+
+ + +
+ + + + +
+
+ + +
+ + + + +
+
+ + +
+ + + + + + +
+
+ + + {history.length === 0 ? ( +

Noch keine Statushistorie vorhanden.

+ ) : ( +
+ {history.map((item) => ( +
+
+

{item.old_status || "Start"} → {item.new_status}

+ +
+ {item.note &&

{item.note}

} +
+ ))} +
+ )} +
+ + +

Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.

+ Audit Logs öffnen +
+ + + +
+ ); +} diff --git a/frontend/athena/app/repairs/page.tsx b/frontend/athena/app/repairs/page.tsx new file mode 100644 index 0000000..c09445f --- /dev/null +++ b/frontend/athena/app/repairs/page.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Edit, Eye, Plus, Trash2, Wrench } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import EmptyState from "@/components/common/EmptyState"; +import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; +import RepairFormDialog from "@/components/repairs/RepairFormDialog"; +import { RepairPriorityBadge, RepairStatusBadge } from "@/components/repairs/RepairStatusBadge"; +import RepairStatusDialog from "@/components/repairs/RepairStatusDialog"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { hasPermission } from "@/lib/permissions"; +import type { CurrentUser } from "@/types/rbac"; +import type { Repair, RepairListResponse, RepairPayload, RepairPriority, RepairSource, RepairStatus } from "@/types/repair"; + +const pageSize = 10; + +function getErrorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden"; + } + return "Aktion konnte nicht abgeschlossen werden"; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); +} + +function deviceLabel(repair: Repair) { + return `${repair.device_manufacturer} ${repair.device_model}`.trim(); +} + +export default function RepairsPage() { + const { showToast } = useToast(); + const [repairs, setRepairs] = useState([]); + const [total, setTotal] = useState(0); + const [currentUser, setCurrentUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formError, setFormError] = useState(""); + const [statusError, setStatusError] = useState(""); + const [cancelError, setCancelError] = useState(""); + const [pending, setPending] = useState(false); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [sourceFilter, setSourceFilter] = useState("all"); + const [priorityFilter, setPriorityFilter] = useState("all"); + const [page, setPage] = useState(1); + const [sortKey, setSortKey] = useState("created_at"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + const [formOpen, setFormOpen] = useState(false); + const [statusOpen, setStatusOpen] = useState(false); + const [selectedRepair, setSelectedRepair] = useState(null); + const [cancelRepair, setCancelRepair] = useState(null); + + const loadRepairs = useCallback(async () => { + setLoading(true); + setError(""); + try { + const params = new URLSearchParams(); + params.set("limit", String(pageSize)); + params.set("offset", String((page - 1) * pageSize)); + if (search.trim()) params.set("q", search.trim()); + if (statusFilter !== "all") params.set("status", statusFilter); + if (sourceFilter !== "all") params.set("source", sourceFilter); + if (priorityFilter !== "all") params.set("priority", priorityFilter); + const [repairResponse, meResponse] = await Promise.all([ + api.get(`/repairs?${params.toString()}`), + api.get("/me"), + ]); + setRepairs(repairResponse.data.items); + setTotal(repairResponse.data.total); + setCurrentUser(meResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [page, priorityFilter, search, sourceFilter, statusFilter]); + + useEffect(() => { + queueMicrotask(() => { + void loadRepairs(); + }); + }, [loadRepairs]); + + const sortedRepairs = useMemo(() => [...repairs].sort((first, second) => { + const firstValue = String(first[sortKey as keyof Repair] ?? "").toLowerCase(); + const secondValue = String(second[sortKey as keyof Repair] ?? "").toLowerCase(); + if (firstValue === secondValue) return 0; + const result = firstValue > secondValue ? 1 : -1; + return sortDirection === "asc" ? result : -result; + }), [repairs, sortDirection, sortKey]); + + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(page, pageCount); + const canCreate = hasPermission(currentUser, "repairs.create"); + const canUpdate = hasPermission(currentUser, "repairs.update"); + const canUpdateStatus = hasPermission(currentUser, "repairs.status.update"); + const canDelete = hasPermission(currentUser, "repairs.delete"); + + const columns: DataTableColumn[] = [ + { key: "repair_number", label: "Reparaturnummer", sortable: true, render: (repair) => {repair.repair_number} }, + { key: "status", label: "Status", sortable: true, render: (repair) => }, + { key: "priority", label: "Priorität", sortable: true, render: (repair) => }, + { key: "customer_name", label: "Kunde", sortable: true, render: (repair) => repair.customer_name }, + { key: "device_model", label: "Gerät", sortable: true, render: (repair) => deviceLabel(repair) }, + { key: "fault_description", label: "Fehler", render: (repair) => {repair.fault_description.slice(0, 80)}{repair.fault_description.length > 80 ? "..." : ""} }, + { key: "source", label: "Quelle", sortable: true, render: (repair) => repair.source }, + { key: "created_at", label: "Erstellt", sortable: true, render: (repair) => formatDate(repair.created_at) }, + { + key: "actions", + label: "Aktionen", + className: "px-4 py-3 text-right", + render: (repair) => ( +
+ + {canUpdate && } + {canUpdateStatus && } + {canDelete && } +
+ ), + }, + ]; + + function handleSort(key: string) { + if (sortKey === key) { + setSortDirection((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSortKey(key); + setSortDirection("asc"); + } + + async function saveRepair(payload: RepairPayload) { + setPending(true); + setFormError(""); + try { + if (selectedRepair) { + const response = await api.put(`/repairs/${selectedRepair.id}`, payload); + setRepairs((current) => current.map((repair) => repair.id === selectedRepair.id ? response.data : repair)); + showToast({ type: "success", title: "Reparatur aktualisiert", description: response.data.repair_number }); + } else { + const response = await api.post("/repairs", payload); + setRepairs((current) => [response.data, ...current].slice(0, pageSize)); + setTotal((current) => current + 1); + showToast({ type: "success", title: "Reparatur angelegt", description: response.data.repair_number }); + } + setFormOpen(false); + } catch (err) { + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Reparatur konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function saveStatus(status: RepairStatus, note: string) { + if (!selectedRepair) return; + setPending(true); + setStatusError(""); + try { + const response = await api.put(`/repairs/${selectedRepair.id}/status`, { status, note }); + setRepairs((current) => current.map((repair) => repair.id === selectedRepair.id ? response.data : repair)); + showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number }); + setStatusOpen(false); + } catch (err) { + const message = getErrorMessage(err); + setStatusError(message); + showToast({ type: "error", title: "Status konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function confirmCancel() { + if (!cancelRepair) return; + setPending(true); + setCancelError(""); + try { + const response = await api.delete(`/repairs/${cancelRepair.id}`); + setRepairs((current) => current.map((repair) => repair.id === cancelRepair.id ? response.data : repair)); + showToast({ type: "success", title: "Reparatur storniert", description: cancelRepair.repair_number }); + setCancelRepair(null); + } catch (err) { + const message = getErrorMessage(err); + setCancelError(message); + showToast({ type: "error", title: "Reparatur konnte nicht storniert werden", description: message }); + } finally { + setPending(false); + } + } + + return ( +
+
+
+

Reparaturen

+

{total} Werkstattvorgänge

+
+ {canCreate && } +
+ +
+ { setSearch(value); setPage(1); }} placeholder="Reparaturen suchen" /> + + + +
+ + {!loading && !error && total === 0 && !search && statusFilter === "all" && sourceFilter === "all" && priorityFilter === "all" ? ( + setFormOpen(true)}>Reparatur anlegen : undefined} + /> + ) : ( + repair.id} + sortKey={sortKey} + sortDirection={sortDirection} + loading={loading} + error={error} + emptyTitle="Noch keine Reparaturen vorhanden." + emptyDescription="Passe Suche oder Filter an oder lege eine Reparatur an." + onSort={handleSort} + /> + )} + +
+ Seite {currentPage} von {pageCount} +
+ + +
+
+ + + + + { + if (!open) { + setCancelRepair(null); + setCancelError(""); + } + }} + onConfirm={confirmCancel} + > + {cancelRepair && ( +
+

{cancelRepair.repair_number}

+

{cancelRepair.customer_name} · {deviceLabel(cancelRepair)}

+ {cancelError &&

{cancelError}

} +
+ )} +
+
+ ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index 44a0e29..c04c193 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -60,6 +60,7 @@ const menu = [ icon: Wrench, name: "Reparaturen", href: "/repairs", + permission: "repairs.read", }, { icon: Package, diff --git a/frontend/athena/components/repairs/RepairFormDialog.tsx b/frontend/athena/components/repairs/RepairFormDialog.tsx new file mode 100644 index 0000000..73cbd62 --- /dev/null +++ b/frontend/athena/components/repairs/RepairFormDialog.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/api"; +import type { Customer } from "@/types/customer"; +import type { Repair, RepairPayload, RepairPriority, RepairSource, RepairStatus } from "@/types/repair"; + +type Props = { + open: boolean; + repair?: Repair | null; + pending: boolean; + serverError: string; + onOpenChange: (open: boolean) => void; + onSubmit: (payload: RepairPayload) => void; +}; + +const initialPayload: RepairPayload = { + customer_id: null, + contact_id: null, + status: "new", + priority: "normal", + source: "manual", + source_reference: null, + customer_name: "", + customer_email: null, + customer_phone: "", + device_manufacturer: "", + device_model: "", + device_serial_number: null, + device_type: "", + accessories: "", + fault_description: "", + previous_work: "", + device_opened: false, + intake_notes: "", + diagnosis_notes: "", + repair_notes: "", + estimate_notes: "", + estimated_cost_cents: null, +}; + +function payloadFromRepair(repair?: Repair | null): RepairPayload { + if (!repair) { + return initialPayload; + } + return { + customer_id: repair.customer_id, + contact_id: repair.contact_id, + status: repair.status, + priority: repair.priority, + source: repair.source, + source_reference: repair.source_reference, + customer_name: repair.customer_name, + customer_email: repair.customer_email || null, + customer_phone: repair.customer_phone, + device_manufacturer: repair.device_manufacturer, + device_model: repair.device_model, + device_serial_number: repair.device_serial_number, + device_type: repair.device_type, + accessories: repair.accessories, + fault_description: repair.fault_description, + previous_work: repair.previous_work, + device_opened: repair.device_opened, + intake_notes: repair.intake_notes, + diagnosis_notes: repair.diagnosis_notes, + repair_notes: repair.repair_notes, + estimate_notes: repair.estimate_notes, + estimated_cost_cents: repair.estimated_cost_cents, + }; +} + +export default function RepairFormDialog({ open, repair, pending, serverError, onOpenChange, onSubmit }: Props) { + const [payload, setPayload] = useState(payloadFromRepair(repair)); + const [clientError, setClientError] = useState(""); + const [customers, setCustomers] = useState([]); + + useEffect(() => { + if (!open) { + return; + } + + let active = true; + + async function loadCustomers() { + try { + const response = await api.get("/customers"); + if (active) { + setCustomers(response.data); + } + } catch { + if (active) { + setCustomers([]); + } + } + } + + void loadCustomers(); + return () => { + active = false; + }; + }, [open]); + + function update(key: K, value: RepairPayload[K]) { + setPayload((current) => ({ ...current, [key]: value })); + } + + function submit() { + if (!payload.customer_name.trim() || !payload.device_manufacturer.trim() || !payload.device_model.trim() || !payload.fault_description.trim()) { + setClientError("Bitte Kundennamen, Hersteller, Modell und Fehlerbeschreibung ausfüllen."); + return; + } + setClientError(""); + onSubmit(payload); + } + + function selectCustomer(customerId: string) { + if (!customerId) { + update("customer_id", null); + return; + } + const customer = customers.find((item) => String(item.id) === customerId); + if (!customer) { + return; + } + setPayload((current) => ({ + ...current, + customer_id: customer.id, + customer_name: customer.company_name, + customer_email: customer.email || current.customer_email, + customer_phone: customer.phone || current.customer_phone, + })); + } + + return ( + + + + {repair ? "Reparatur bearbeiten" : "Reparatur anlegen"} + Erfasse Kundendaten, Gerätedaten und Werkstattnotizen. + + +
+
+

Kunde

+ +
+ + + +
+
+ +
+

Gerät

+
+ + + + +
+
+ +
+

Vorgang

+
+ + + +
+ +
+ +
+

Beschreibung

+