feat(repairs): add repair management foundation

This commit is contained in:
Schubert Ferenc 2026-07-04 11:46:58 +02:00
parent 22e54f212c
commit e9ec207617
37 changed files with 2149 additions and 7 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -24,3 +24,7 @@ STORAGE_HOST_PATH=./storage
# Legacy-Fallback fuer bestehende Knowledge-Installationen. # Legacy-Fallback fuer bestehende Knowledge-Installationen.
KNOWLEDGE_STORAGE_PATH=/data/knowledge KNOWLEDGE_STORAGE_PATH=/data/knowledge
KNOWLEDGE_MAX_UPLOAD_MB=50 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=

View file

@ -177,6 +177,7 @@ Beispiele:
- Kundenstatistiken nur mit `customers.read` - Kundenstatistiken nur mit `customers.read`
- Benutzerstatistiken nur mit `users.read` - Benutzerstatistiken nur mit `users.read`
- Rollenstatus nur mit `roles.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. 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. 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<jahr>-<laufende nummer sechsstellig>`, 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 ## RBAC: Rollen und Berechtigungen
Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module. Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module.
@ -774,6 +829,9 @@ docker-compose.yml
`KNOWLEDGE_MAX_UPLOAD_MB` `KNOWLEDGE_MAX_UPLOAD_MB`
: Legacy-Fallback fuer die maximale Knowledge-Uploadgroesse. : 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 ### Athena
`HERMES_INTERNAL_URL` `HERMES_INTERNAL_URL`

View file

@ -158,6 +158,7 @@ Neue CRM-Module sollen die bestehenden Muster wiederverwenden.
Beispiele fuer spaetere Module: Beispiele fuer spaetere Module:
- Kunden - Kunden
- Reparaturen
- Projekte - Projekte
- Lieferanten - Lieferanten
- Kontakte - Kontakte

View file

@ -33,6 +33,7 @@ STORAGE_PROVIDER=local
STORAGE_BASE_PATH=/data/storage STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50 STORAGE_MAX_UPLOAD_MB=50
STORAGE_HOST_PATH=./storage 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. 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. 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 ## Typische Fehler
Hermes restartet wegen fehlender Migration: Hermes restartet wegen fehlender Migration:

View file

@ -74,13 +74,23 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Idempotentes Standard-Hersteller-Seeding - Idempotentes Standard-Hersteller-Seeding
- Klarere Hermes-Fehler fuer unvollstaendige Dokumentzuordnungen - Klarere Hermes-Fehler fuer unvollstaendige Dokumentzuordnungen
## v0.8.0 - Projektmodul, geplant ## v0.8.0 - Reparaturmanagement
- Projektstammdaten - Reparaturstammdaten und Werkstattvorgaenge
- Projektstatus und Verantwortliche - Reparaturnummern im Format `R<jahr>-000001`
- Verknuepfung mit Kunden - Statusmodell und Statushistorie
- RBAC-Permissions fuer Projekte - Website Repair Intake API vorbereitet
- Audit Logs fuer Projektaktionen - 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 ## v0.9.0 - Tickets, geplant

View file

@ -18,3 +18,4 @@ STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50 STORAGE_MAX_UPLOAD_MB=50
KNOWLEDGE_STORAGE_PATH=/data/knowledge KNOWLEDGE_STORAGE_PATH=/data/knowledge
KNOWLEDGE_MAX_UPLOAD_MB=50 KNOWLEDGE_MAX_UPLOAD_MB=50
OLYMPUS_REPAIR_INTAKE_TOKEN=

View file

@ -8,6 +8,11 @@ from alembic import context
from app.core.config import settings from app.core.config import settings
from app.db.database import Base from app.db.database import Base
import app.models.user 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 config = context.config

View file

@ -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")

View file

@ -56,6 +56,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
return "roles.read" in permissions return "roles.read" in permissions
if action.startswith("knowledge."): if action.startswith("knowledge."):
return "knowledge.read" in permissions return "knowledge.read" in permissions
if action.startswith("repairs."):
return "repairs.read" in permissions
if action.startswith("audit_logs."): if action.startswith("audit_logs."):
return "audit_logs.read" in permissions return "audit_logs.read" in permissions
if action.startswith("auth."): if action.startswith("auth."):

View file

@ -9,6 +9,7 @@ from app.db.database import get_db
from app.models.rbac import Role from app.models.rbac import Role
from app.models.user import User from app.models.user import User
from app.repositories.customer_repository import CustomerRepository from app.repositories.customer_repository import CustomerRepository
from app.repositories.repair_repository import RepairRepository
from app.repositories.user_repository import UserRepository from app.repositories.user_repository import UserRepository
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard
@ -30,6 +31,7 @@ def get_dashboard_summary(
latest_customers = [] latest_customers = []
users: list[MetricCard] = [] users: list[MetricCard] = []
roles: list[MetricCard] = [] roles: list[MetricCard] = []
repairs: list[MetricCard] = []
if "customers.read" in permissions: if "customers.read" in permissions:
customers = [ 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}) logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
return DashboardSummary( return DashboardSummary(
@ -61,6 +71,7 @@ def get_dashboard_summary(
latest_customers=latest_customers, latest_customers=latest_customers,
users=users, users=users,
roles=roles, roles=roles,
repairs=repairs,
activities=EmptyWidget( activities=EmptyWidget(
title="Letzte Aktivitäten", title="Letzte Aktivitäten",
message="Noch keine Aktivitäten vorhanden.", message="Noch keine Aktivitäten vorhanden.",

View file

@ -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",
)

View file

@ -23,6 +23,7 @@ class Settings(BaseSettings):
storage_max_upload_mb: int = 50 storage_max_upload_mb: int = 50
knowledge_storage_path: str = "/data/knowledge" knowledge_storage_path: str = "/data/knowledge"
knowledge_max_upload_mb: int = 50 knowledge_max_upload_mb: int = 50
olympus_repair_intake_token: str | None = None
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View file

@ -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.dashboard import router as dashboard_router
from app.api.knowledge import router as knowledge_router from app.api.knowledge import router as knowledge_router
from app.api.permissions import router as permissions_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.roles import router as roles_router
from app.api.users import router as users_router from app.api.users import router as users_router
from app.db.database import SessionLocal from app.db.database import SessionLocal
@ -39,6 +40,7 @@ app.include_router(roles_router)
app.include_router(permissions_router) app.include_router(permissions_router)
app.include_router(customers_router) app.include_router(customers_router)
app.include_router(knowledge_router) app.include_router(knowledge_router)
app.include_router(repairs_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -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())

View file

@ -76,6 +76,13 @@ STANDARD_PERMISSIONS = [
("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"), ("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"),
("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"), ("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"),
("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "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 = { ROLE_PERMISSION_NAMES = {
@ -88,6 +95,10 @@ ROLE_PERMISSION_NAMES = {
"knowledge.download", "knowledge.download",
"projects.read", "projects.read",
"tickets.read", "tickets.read",
"repairs.read",
"repairs.create",
"repairs.update",
"repairs.status.update",
}, },
"sales": { "sales": {
"dashboard.read", "dashboard.read",
@ -108,6 +119,9 @@ ROLE_PERMISSION_NAMES = {
"knowledge.update", "knowledge.update",
"knowledge.upload", "knowledge.upload",
"knowledge.download", "knowledge.download",
"repairs.read",
"repairs.update",
"repairs.status.update",
}, },
"support": { "support": {
"dashboard.read", "dashboard.read",
@ -118,6 +132,11 @@ ROLE_PERMISSION_NAMES = {
"tickets.read", "tickets.read",
"tickets.create", "tickets.create",
"tickets.update", "tickets.update",
"repairs.read",
"repairs.create",
"repairs.update",
"repairs.status.update",
"repairs.intake",
}, },
"warehouse": { "warehouse": {
"dashboard.read", "dashboard.read",

View file

@ -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)

View file

@ -18,6 +18,7 @@ class DashboardSummary(BaseModel):
latest_customers: list[CustomerResponse] = [] latest_customers: list[CustomerResponse] = []
users: list[MetricCard] = [] users: list[MetricCard] = []
roles: list[MetricCard] = [] roles: list[MetricCard] = []
repairs: list[MetricCard] = []
activities: EmptyWidget activities: EmptyWidget
tasks: EmptyWidget tasks: EmptyWidget
tickets: EmptyWidget tickets: EmptyWidget

View file

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

View file

@ -157,6 +157,12 @@ def action_title(action: str) -> str:
"knowledge.notes.create": "Wissensnotiz erstellt", "knowledge.notes.create": "Wissensnotiz erstellt",
"knowledge.notes.update": "Wissensnotiz bearbeitet", "knowledge.notes.update": "Wissensnotiz bearbeitet",
"knowledge.notes.delete": "Wissensnotiz gelöscht", "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) return labels.get(action, action)

View file

@ -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")

View file

@ -25,6 +25,7 @@ services:
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50} STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-}
volumes: volumes:
- ${STORAGE_HOST_PATH:-./storage}:/data/storage - ${STORAGE_HOST_PATH:-./storage}:/data/storage

View file

@ -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`);
}

View file

@ -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);
}

View file

@ -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`);
}

View file

@ -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");
}

View file

@ -62,7 +62,7 @@ export default function DashboardPage() {
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>; return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
} }
const cards = [...summary.customers, ...summary.users, ...summary.roles]; const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles];
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View file

@ -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 (
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-950">{typeof value === "boolean" ? (value ? "Ja" : "Nein") : value || "-"}</dd>
</div>
);
}
export default function RepairDetailPage({ params }: Params) {
const { showToast } = useToast();
const [repairId, setRepairId] = useState("");
const [repair, setRepair] = useState<Repair | null>(null);
const [history, setHistory] = useState<RepairStatusHistory[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(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<Repair>(`/repairs/${repairId}`),
api.get<RepairStatusHistory[]>(`/repairs/${repairId}/history`),
api.get<CurrentUser>("/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<Repair>(`/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<Repair>(`/repairs/${repair.id}/status`, { status, note });
const historyResponse = await api.get<RepairStatusHistory[]>(`/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 <div className="rounded-lg border bg-white p-8 text-slate-500">Reparatur wird geladen...</div>;
}
if (error || !repair) {
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Reparatur nicht gefunden"}</div>;
}
const canUpdate = hasPermission(currentUser, "repairs.update");
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<Link href="/repairs" className="mb-3 inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-950"><ArrowLeft size={16} />Zurück</Link>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold text-slate-950">{repair.repair_number}</h1>
<RepairStatusBadge status={repair.status} />
<RepairPriorityBadge priority={repair.priority} />
</div>
<p className="mt-1 text-sm text-slate-500">{repair.customer_name} · {repair.device_manufacturer} {repair.device_model}</p>
</div>
<div className="flex flex-wrap gap-2">
{canUpdate && <Button type="button" variant="outline" onClick={() => setFormOpen(true)}><Edit size={16} />Bearbeiten</Button>}
{canUpdateStatus && <Button type="button" onClick={() => setStatusOpen(true)}><Wrench size={16} />Status ändern</Button>}
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<DetailSection title="Kundendaten">
<dl className="grid gap-4 md:grid-cols-2">
<DetailItem label="Name" value={repair.customer_name} />
<DetailItem label="E-Mail" value={repair.customer_email} />
<DetailItem label="Telefon" value={repair.customer_phone} />
<DetailItem label="Kunden-ID" value={repair.customer_id} />
</dl>
</DetailSection>
<DetailSection title="Gerätedaten">
<dl className="grid gap-4 md:grid-cols-2">
<DetailItem label="Hersteller" value={repair.device_manufacturer} />
<DetailItem label="Modell" value={repair.device_model} />
<DetailItem label="Seriennummer" value={repair.device_serial_number} />
<DetailItem label="Gerätetyp" value={repair.device_type} />
<DetailItem label="Gerät geöffnet" value={repair.device_opened} />
</dl>
</DetailSection>
</div>
<DetailSection title="Fehler und Annahme">
<dl className="grid gap-4">
<DetailItem label="Fehlerbeschreibung" value={repair.fault_description} />
<DetailItem label="Zubehör" value={repair.accessories} />
<DetailItem label="Vorarbeiten" value={repair.previous_work} />
<DetailItem label="Annahmenotizen" value={repair.intake_notes} />
</dl>
</DetailSection>
<DetailSection title="Werkstatt">
<dl className="grid gap-4 md:grid-cols-2">
<DetailItem label="Diagnose" value={repair.diagnosis_notes} />
<DetailItem label="Reparaturnotizen" value={repair.repair_notes} />
<DetailItem label="Kostenschätzung" value={repair.estimate_notes} />
<DetailItem label="Geschätzte Kosten" value={repair.estimated_cost_cents !== null ? `${(repair.estimated_cost_cents / 100).toFixed(2)}` : ""} />
</dl>
</DetailSection>
<DetailSection title="Metadaten">
<dl className="grid gap-4 md:grid-cols-3">
<DetailItem label="Quelle" value={repair.source} />
<DetailItem label="Quellreferenz" value={repair.source_reference} />
<DetailItem label="Erstellt" value={formatDate(repair.created_at)} />
<DetailItem label="Aktualisiert" value={formatDate(repair.updated_at)} />
<DetailItem label="Freigegeben" value={formatDate(repair.approved_at)} />
<DetailItem label="Abgeschlossen" value={formatDate(repair.completed_at)} />
</dl>
</DetailSection>
<DetailSection title="Statushistorie">
{history.length === 0 ? (
<p className="text-sm text-slate-500">Noch keine Statushistorie vorhanden.</p>
) : (
<div className="divide-y">
{history.map((item) => (
<div key={item.id} className="py-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<p className="font-medium text-slate-950">{item.old_status || "Start"} {item.new_status}</p>
<time className="text-xs text-slate-500" dateTime={item.created_at}>{formatDate(item.created_at)}</time>
</div>
{item.note && <p className="mt-1 text-sm text-slate-500">{item.note}</p>}
</div>
))}
</div>
)}
</DetailSection>
<DetailSection title="Audit und Aktivität vorbereitet">
<p className="text-sm text-slate-500">Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.</p>
<Link href="/audit-logs" className={buttonVariants({ variant: "outline", size: "sm", className: "mt-4" })}>Audit Logs öffnen</Link>
</DetailSection>
<RepairFormDialog key={`form-${repair.id}-${formOpen}`} open={formOpen} repair={repair} pending={pending} serverError={formError} onOpenChange={setFormOpen} onSubmit={saveRepair} />
<RepairStatusDialog key={`status-${repair.id}-${statusOpen}`} open={statusOpen} repair={repair} pending={pending} serverError={statusError} onOpenChange={setStatusOpen} onSubmit={saveStatus} />
</div>
);
}

View file

@ -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<Repair[]>([]);
const [total, setTotal] = useState(0);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(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<RepairStatus | "all">("all");
const [sourceFilter, setSourceFilter] = useState<RepairSource | "all">("all");
const [priorityFilter, setPriorityFilter] = useState<RepairPriority | "all">("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<Repair | null>(null);
const [cancelRepair, setCancelRepair] = useState<Repair | null>(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<RepairListResponse>(`/repairs?${params.toString()}`),
api.get<CurrentUser>("/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<Repair>[] = [
{ key: "repair_number", label: "Reparaturnummer", sortable: true, render: (repair) => <span className="font-medium text-slate-950">{repair.repair_number}</span> },
{ key: "status", label: "Status", sortable: true, render: (repair) => <RepairStatusBadge status={repair.status} /> },
{ key: "priority", label: "Priorität", sortable: true, render: (repair) => <RepairPriorityBadge priority={repair.priority} /> },
{ 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) => <span title={repair.fault_description}>{repair.fault_description.slice(0, 80)}{repair.fault_description.length > 80 ? "..." : ""}</span> },
{ 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) => (
<div className="flex justify-end gap-2">
<Link href={`/repairs/${repair.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
{canUpdate && <Button type="button" variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelectedRepair(repair); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>}
{canUpdateStatus && <Button type="button" variant="ghost" size="icon-sm" title="Status ändern" onClick={() => { setSelectedRepair(repair); setStatusError(""); setStatusOpen(true); }}><Wrench size={16} /></Button>}
{canDelete && <Button type="button" variant="destructive" size="icon-sm" title="Stornieren" onClick={() => setCancelRepair(repair)}><Trash2 size={16} /></Button>}
</div>
),
},
];
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<Repair>(`/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<Repair>("/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<Repair>(`/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<Repair>(`/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 (
<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">Reparaturen</h1>
<p className="mt-1 text-sm text-slate-500">{total} Werkstattvorgänge</p>
</div>
{canCreate && <Button type="button" onClick={() => { setSelectedRepair(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Reparatur anlegen</Button>}
</div>
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 xl:flex-row xl:items-center">
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Reparaturen suchen" />
<select value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value as RepairStatus | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
<option value="all">Alle Status</option>
{["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
<select value={sourceFilter} onChange={(event) => { setSourceFilter(event.target.value as RepairSource | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
<option value="all">Alle Quellen</option>
{["manual", "website", "customer_portal", "email", "phone"].map((source) => <option key={source} value={source}>{source}</option>)}
</select>
<select value={priorityFilter} onChange={(event) => { setPriorityFilter(event.target.value as RepairPriority | "all"); setPage(1); }} className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm">
<option value="all">Alle Prioritäten</option>
{["low", "normal", "high", "urgent"].map((priority) => <option key={priority} value={priority}>{priority}</option>)}
</select>
</div>
{!loading && !error && total === 0 && !search && statusFilter === "all" && sourceFilter === "all" && priorityFilter === "all" ? (
<EmptyState
title="Noch keine Reparaturen vorhanden."
description="Lege den ersten Werkstattvorgang manuell an oder nutze später die Website-Intake-Schnittstelle."
action={canCreate ? <Button type="button" onClick={() => setFormOpen(true)}><Plus size={16} />Reparatur anlegen</Button> : undefined}
/>
) : (
<DataTable
columns={columns}
rows={sortedRepairs}
rowKey={(repair) => 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}
/>
)}
<div className="flex items-center justify-between text-sm text-slate-600">
<span>Seite {currentPage} von {pageCount}</span>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={currentPage === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>Zurück</Button>
<Button type="button" variant="outline" disabled={currentPage === pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>Weiter</Button>
</div>
</div>
<RepairFormDialog key={`form-${selectedRepair?.id ?? "new"}-${formOpen}`} open={formOpen} repair={selectedRepair} pending={pending} serverError={formError} onOpenChange={setFormOpen} onSubmit={saveRepair} />
<RepairStatusDialog key={`status-${selectedRepair?.id ?? "none"}-${statusOpen}`} open={statusOpen} repair={selectedRepair} pending={pending} serverError={statusError} onOpenChange={setStatusOpen} onSubmit={saveStatus} />
<ConfirmDialog
open={Boolean(cancelRepair)}
title="Reparatur stornieren"
description="Die Reparatur wird nicht physisch gelöscht, sondern auf storniert gesetzt."
confirmLabel="Stornieren"
pending={pending}
onOpenChange={(open) => {
if (!open) {
setCancelRepair(null);
setCancelError("");
}
}}
onConfirm={confirmCancel}
>
{cancelRepair && (
<div className="space-y-2 text-sm">
<p className="font-medium">{cancelRepair.repair_number}</p>
<p>{cancelRepair.customer_name} · {deviceLabel(cancelRepair)}</p>
{cancelError && <p className="text-red-600">{cancelError}</p>}
</div>
)}
</ConfirmDialog>
</div>
);
}

View file

@ -60,6 +60,7 @@ const menu = [
icon: Wrench, icon: Wrench,
name: "Reparaturen", name: "Reparaturen",
href: "/repairs", href: "/repairs",
permission: "repairs.read",
}, },
{ {
icon: Package, icon: Package,

View file

@ -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<RepairPayload>(payloadFromRepair(repair));
const [clientError, setClientError] = useState("");
const [customers, setCustomers] = useState<Customer[]>([]);
useEffect(() => {
if (!open) {
return;
}
let active = true;
async function loadCustomers() {
try {
const response = await api.get<Customer[]>("/customers");
if (active) {
setCustomers(response.data);
}
} catch {
if (active) {
setCustomers([]);
}
}
}
void loadCustomers();
return () => {
active = false;
};
}, [open]);
function update<K extends keyof RepairPayload>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{repair ? "Reparatur bearbeiten" : "Reparatur anlegen"}</DialogTitle>
<DialogDescription>Erfasse Kundendaten, Gerätedaten und Werkstattnotizen.</DialogDescription>
</DialogHeader>
<div className="grid gap-5">
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Kunde</h3>
<label className="grid gap-1 text-sm">Bestehender Kunde
<select className="h-8 rounded-lg border px-2 text-sm" value={payload.customer_id ?? ""} onChange={(event) => selectCustomer(event.target.value)}>
<option value="">Manuelle Kundendaten</option>
{customers.map((customer) => <option key={customer.id} value={customer.id}>{customer.company_name}</option>)}
</select>
</label>
<div className="grid gap-3 md:grid-cols-3">
<label className="grid gap-1 text-sm">Name<Input value={payload.customer_name} onChange={(event) => update("customer_name", event.target.value)} /></label>
<label className="grid gap-1 text-sm">E-Mail<Input type="email" value={payload.customer_email ?? ""} onChange={(event) => update("customer_email", event.target.value || null)} /></label>
<label className="grid gap-1 text-sm">Telefon<Input value={payload.customer_phone} onChange={(event) => update("customer_phone", event.target.value)} /></label>
</div>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Gerät</h3>
<div className="grid gap-3 md:grid-cols-4">
<label className="grid gap-1 text-sm">Hersteller<Input value={payload.device_manufacturer} onChange={(event) => update("device_manufacturer", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Modell<Input value={payload.device_model} onChange={(event) => update("device_model", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Seriennummer<Input value={payload.device_serial_number ?? ""} onChange={(event) => update("device_serial_number", event.target.value || null)} /></label>
<label className="grid gap-1 text-sm">Gerätetyp<Input value={payload.device_type} onChange={(event) => update("device_type", event.target.value)} /></label>
</div>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Vorgang</h3>
<div className="grid gap-3 md:grid-cols-3">
<label className="grid gap-1 text-sm">Status<select className="h-8 rounded-lg border px-2 text-sm" value={payload.status} onChange={(event) => update("status", event.target.value as RepairStatus)}>
{["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"].map((status) => <option key={status} value={status}>{status}</option>)}
</select></label>
<label className="grid gap-1 text-sm">Priorität<select className="h-8 rounded-lg border px-2 text-sm" value={payload.priority} onChange={(event) => update("priority", event.target.value as RepairPriority)}>
{["low", "normal", "high", "urgent"].map((priority) => <option key={priority} value={priority}>{priority}</option>)}
</select></label>
<label className="grid gap-1 text-sm">Quelle<select className="h-8 rounded-lg border px-2 text-sm" value={payload.source} onChange={(event) => update("source", event.target.value as RepairSource)}>
{["manual", "website", "customer_portal", "email", "phone"].map((source) => <option key={source} value={source}>{source}</option>)}
</select></label>
</div>
<label className="grid gap-1 text-sm">Quellreferenz<Input value={payload.source_reference ?? ""} onChange={(event) => update("source_reference", event.target.value || null)} /></label>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Beschreibung</h3>
<label className="grid gap-1 text-sm">Fehlerbeschreibung<textarea className="min-h-24 rounded-lg border p-2 text-sm" value={payload.fault_description} onChange={(event) => update("fault_description", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Zubehör<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.accessories} onChange={(event) => update("accessories", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Vorarbeiten<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.previous_work} onChange={(event) => update("previous_work", event.target.value)} /></label>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={payload.device_opened} onChange={(event) => update("device_opened", event.target.checked)} /> Gerät wurde bereits geöffnet</label>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Werkstattnotizen</h3>
<label className="grid gap-1 text-sm">Annahme<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.intake_notes} onChange={(event) => update("intake_notes", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Diagnose<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.diagnosis_notes} onChange={(event) => update("diagnosis_notes", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Reparatur<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.repair_notes} onChange={(event) => update("repair_notes", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Kostenschätzung<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.estimate_notes} onChange={(event) => update("estimate_notes", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Geschätzte Kosten in Cent<Input type="number" min={0} value={payload.estimated_cost_cents ?? ""} onChange={(event) => update("estimated_cost_cents", event.target.value ? Number(event.target.value) : null)} /></label>
</section>
</div>
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,59 @@
import type { RepairPriority, RepairStatus } from "@/types/repair";
const statusLabels: Record<RepairStatus, string> = {
new: "Neu",
accepted: "Angenommen",
diagnosis: "Diagnose",
estimate: "Kostenvoranschlag",
waiting_for_customer: "Wartet auf Kunde",
approved: "Freigegeben",
repair: "Reparatur",
final_test: "Endprüfung",
ready_for_pickup: "Abholbereit",
shipped: "Versendet",
completed: "Abgeschlossen",
cancelled: "Storniert",
};
const priorityLabels: Record<RepairPriority, string> = {
low: "Niedrig",
normal: "Normal",
high: "Hoch",
urgent: "Dringend",
};
const statusColors: Record<RepairStatus, string> = {
new: "bg-blue-50 text-blue-700 ring-blue-600/20",
accepted: "bg-sky-50 text-sky-700 ring-sky-600/20",
diagnosis: "bg-violet-50 text-violet-700 ring-violet-600/20",
estimate: "bg-amber-50 text-amber-700 ring-amber-600/20",
waiting_for_customer: "bg-orange-50 text-orange-700 ring-orange-600/20",
approved: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
repair: "bg-cyan-50 text-cyan-700 ring-cyan-600/20",
final_test: "bg-indigo-50 text-indigo-700 ring-indigo-600/20",
ready_for_pickup: "bg-lime-50 text-lime-700 ring-lime-600/20",
shipped: "bg-teal-50 text-teal-700 ring-teal-600/20",
completed: "bg-slate-100 text-slate-700 ring-slate-500/20",
cancelled: "bg-red-50 text-red-700 ring-red-600/20",
};
const priorityColors: Record<RepairPriority, string> = {
low: "bg-slate-100 text-slate-700 ring-slate-500/20",
normal: "bg-blue-50 text-blue-700 ring-blue-600/20",
high: "bg-amber-50 text-amber-700 ring-amber-600/20",
urgent: "bg-red-50 text-red-700 ring-red-600/20",
};
function Badge({ label, color }: { label: string; color: string }) {
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ring-1 ${color}`}>{label}</span>;
}
export function RepairStatusBadge({ status }: { status: RepairStatus }) {
return <Badge label={statusLabels[status]} color={statusColors[status]} />;
}
export function RepairPriorityBadge({ priority }: { priority: RepairPriority }) {
return <Badge label={priorityLabels[priority]} color={priorityColors[priority]} />;
}
export { priorityLabels, statusLabels };

View file

@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { Repair, RepairStatus } from "@/types/repair";
type Props = {
open: boolean;
repair: Repair | null;
pending: boolean;
serverError: string;
onOpenChange: (open: boolean) => void;
onSubmit: (status: RepairStatus, note: string) => void;
};
const statuses: RepairStatus[] = ["new", "accepted", "diagnosis", "estimate", "waiting_for_customer", "approved", "repair", "final_test", "ready_for_pickup", "shipped", "completed", "cancelled"];
export default function RepairStatusDialog({ open, repair, pending, serverError, onOpenChange, onSubmit }: Props) {
const [status, setStatus] = useState<RepairStatus>("new");
const [note, setNote] = useState("");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Status ändern</DialogTitle>
<DialogDescription>{repair ? `${repair.repair_number} · ${repair.customer_name}` : "Reparaturstatus aktualisieren"}</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<label className="grid gap-1 text-sm">Neuer Status<select className="h-8 rounded-lg border px-2 text-sm" value={status} onChange={(event) => setStatus(event.target.value as RepairStatus)}>
{statuses.map((item) => <option key={item} value={item}>{item}</option>)}
</select></label>
<label className="grid gap-1 text-sm">Notiz<textarea className="min-h-24 rounded-lg border p-2 text-sm" value={note} onChange={(event) => setNote(event.target.value)} /></label>
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button type="button" onClick={() => onSubmit(status, note)} disabled={pending}>{pending ? "Speichert..." : "Status speichern"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -22,5 +22,6 @@ export const config = {
"/users/:path*", "/users/:path*",
"/roles/:path*", "/roles/:path*",
"/customers/:path*", "/customers/:path*",
"/repairs/:path*",
], ],
}; };

View file

@ -15,6 +15,7 @@ export interface DashboardSummary {
latest_customers: Customer[]; latest_customers: Customer[];
users: MetricCard[]; users: MetricCard[];
roles: MetricCard[]; roles: MetricCard[];
repairs: MetricCard[];
activities: EmptyWidget; activities: EmptyWidget;
tasks: EmptyWidget; tasks: EmptyWidget;
tickets: EmptyWidget; tickets: EmptyWidget;

View file

@ -0,0 +1,89 @@
export type RepairStatus =
| "new"
| "accepted"
| "diagnosis"
| "estimate"
| "waiting_for_customer"
| "approved"
| "repair"
| "final_test"
| "ready_for_pickup"
| "shipped"
| "completed"
| "cancelled";
export type RepairPriority = "low" | "normal" | "high" | "urgent";
export type RepairSource = "manual" | "website" | "customer_portal" | "email" | "phone";
export interface Repair {
id: number;
repair_number: string;
customer_id: number | null;
contact_id: number | null;
status: RepairStatus;
priority: RepairPriority;
source: RepairSource;
source_reference: string | null;
customer_name: string;
customer_email: string;
customer_phone: string;
device_manufacturer: string;
device_model: string;
device_serial_number: string | null;
device_type: string;
accessories: string;
fault_description: string;
previous_work: string;
device_opened: boolean;
intake_notes: string;
diagnosis_notes: string;
repair_notes: string;
estimate_notes: string;
estimated_cost_cents: number | null;
approved_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
}
export interface RepairPayload {
customer_id: number | null;
contact_id: number | null;
status: RepairStatus;
priority: RepairPriority;
source: RepairSource;
source_reference: string | null;
customer_name: string;
customer_email: string | null;
customer_phone: string;
device_manufacturer: string;
device_model: string;
device_serial_number: string | null;
device_type: string;
accessories: string;
fault_description: string;
previous_work: string;
device_opened: boolean;
intake_notes: string;
diagnosis_notes: string;
repair_notes: string;
estimate_notes: string;
estimated_cost_cents: number | null;
}
export interface RepairListResponse {
items: Repair[];
total: number;
limit: number;
offset: number;
}
export interface RepairStatusHistory {
id: number;
repair_id: number;
old_status: string | null;
new_status: RepairStatus;
note: string | null;
actor_user_id: number | null;
created_at: string;
}

@ -0,0 +1 @@
Subproject commit a284bbc1886b26091bea7ea7a5aacee71b4507fe