feat(repairs): add repair management foundation
This commit is contained in:
parent
22e54f212c
commit
e9ec207617
37 changed files with 2149 additions and 7 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
|
|
@ -24,3 +24,7 @@ STORAGE_HOST_PATH=./storage
|
|||
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
|
||||
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||
KNOWLEDGE_MAX_UPLOAD_MB=50
|
||||
|
||||
# Server-zu-Server Token fuer spaetere Website-Reparaturannahme.
|
||||
# Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden.
|
||||
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ Beispiele:
|
|||
- Kundenstatistiken nur mit `customers.read`
|
||||
- Benutzerstatistiken nur mit `users.read`
|
||||
- Rollenstatus nur mit `roles.read`
|
||||
- Reparaturkennzahlen nur mit `repairs.read`
|
||||
|
||||
Nicht vorhandene Module wie Aufgaben, Tickets oder Projekte werden nicht mit Fake-Daten gefuellt. Stattdessen liefert das Dashboard leere Widgets mit klarer Meldung.
|
||||
|
||||
|
|
@ -188,6 +189,60 @@ Athena ruft `GET /api/activity-feed` auf. Die BFF-Route ruft serverseitig Hermes
|
|||
|
||||
Der Feed basiert auf persistenten Audit Logs und zeigt die letzten relevanten Aktivitaeten. Hermes filtert die Eintraege anhand der Berechtigungen des aktuellen Benutzers. Ein Benutzer sieht dadurch nur Aktivitaeten zu Bereichen, fuer die er Leserechte besitzt.
|
||||
|
||||
## Reparaturmanagement
|
||||
|
||||
Das Modul `Reparaturen` verwaltet interne Werkstattvorgaenge. Es liegt fachlich in Hermes und wird im Browser ausschliesslich ueber Athena erreicht.
|
||||
|
||||
Datenfluss:
|
||||
|
||||
```text
|
||||
Browser -> Athena /api/repairs... -> Hermes /repairs... -> PostgreSQL
|
||||
```
|
||||
|
||||
Kernfunktionen:
|
||||
|
||||
- Reparaturen manuell anlegen und bearbeiten
|
||||
- eindeutige Reparaturnummer im Format `R<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
|
||||
|
||||
Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module.
|
||||
|
|
@ -774,6 +829,9 @@ docker-compose.yml
|
|||
`KNOWLEDGE_MAX_UPLOAD_MB`
|
||||
: Legacy-Fallback fuer die maximale Knowledge-Uploadgroesse.
|
||||
|
||||
`OLYMPUS_REPAIR_INTAKE_TOKEN`
|
||||
: Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen.
|
||||
|
||||
### Athena
|
||||
|
||||
`HERMES_INTERNAL_URL`
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ Neue CRM-Module sollen die bestehenden Muster wiederverwenden.
|
|||
Beispiele fuer spaetere Module:
|
||||
|
||||
- Kunden
|
||||
- Reparaturen
|
||||
- Projekte
|
||||
- Lieferanten
|
||||
- Kontakte
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ STORAGE_PROVIDER=local
|
|||
STORAGE_BASE_PATH=/data/storage
|
||||
STORAGE_MAX_UPLOAD_MB=50
|
||||
STORAGE_HOST_PATH=./storage
|
||||
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
||||
```
|
||||
|
||||
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
|
||||
|
|
@ -128,6 +129,32 @@ Hersteller -> Gerät -> Dokument -> Notiz/Reparaturhinweis
|
|||
|
||||
Hermes legt beim Startup Standard-Hersteller an, wenn sie noch fehlen. Danach koennen Geraete angelegt werden. Dokumentuploads sind erst sinnvoll, wenn Hersteller und Geraet vorhanden sind; Athena blockiert unvollstaendige Uploads direkt im Formular.
|
||||
|
||||
## Reparaturmanagement
|
||||
|
||||
Das Reparaturmodul ist ab v0.8.0 aktiv.
|
||||
|
||||
Lokaler Ablauf:
|
||||
|
||||
```bash
|
||||
cd backend/hermes
|
||||
SECRET_KEY=local-check uv run alembic upgrade head
|
||||
```
|
||||
|
||||
Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen:
|
||||
|
||||
- `/api/repairs`
|
||||
- `/api/repairs/[id]`
|
||||
- `/api/repairs/[id]/status`
|
||||
- `/api/repairs/[id]/history`
|
||||
|
||||
Hermes stellt zusaetzlich `POST /public/repair-intake` fuer die spaetere Website-Anbindung bereit. Der Endpunkt ist fuer Server-zu-Server-Kommunikation gedacht und erwartet `X-Olympus-Intake-Token`. Der Token wird ueber `OLYMPUS_REPAIR_INTAKE_TOKEN` gesetzt und darf nicht im Browser verwendet werden.
|
||||
|
||||
Vorbereitete Kundenportal-Routen fuer spaeter:
|
||||
|
||||
- `/status`
|
||||
- `/reparatur/status`
|
||||
- `/portal/login`
|
||||
|
||||
## Typische Fehler
|
||||
|
||||
Hermes restartet wegen fehlender Migration:
|
||||
|
|
|
|||
22
ROADMAP.md
22
ROADMAP.md
|
|
@ -74,13 +74,23 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
|||
- Idempotentes Standard-Hersteller-Seeding
|
||||
- Klarere Hermes-Fehler fuer unvollstaendige Dokumentzuordnungen
|
||||
|
||||
## v0.8.0 - Projektmodul, geplant
|
||||
## v0.8.0 - Reparaturmanagement
|
||||
|
||||
- Projektstammdaten
|
||||
- Projektstatus und Verantwortliche
|
||||
- Verknuepfung mit Kunden
|
||||
- RBAC-Permissions fuer Projekte
|
||||
- Audit Logs fuer Projektaktionen
|
||||
- Reparaturstammdaten und Werkstattvorgaenge
|
||||
- Reparaturnummern im Format `R<jahr>-000001`
|
||||
- Statusmodell und Statushistorie
|
||||
- Website Repair Intake API vorbereitet
|
||||
- RBAC-Permissions fuer Reparaturen
|
||||
- Audit Logs und Activity Feed fuer Reparaturaktionen
|
||||
- Dashboard-Kennzahlen fuer Reparaturen
|
||||
- Athena-BFF-Routen und Reparatur-UI
|
||||
|
||||
## v0.8.1 - Kundenportal-Status, geplant
|
||||
|
||||
- `/status` als Einstieg fuer Statusabfragen
|
||||
- `/reparatur/status` fuer Reparaturstatus
|
||||
- `/portal/login` fuer spaeteren Kundenlogin
|
||||
- Keine Browser-Secrets und keine direkten Hermes-Zugriffe
|
||||
|
||||
## v0.9.0 - Tickets, geplant
|
||||
|
||||
|
|
|
|||
|
|
@ -18,3 +18,4 @@ STORAGE_BASE_PATH=/data/storage
|
|||
STORAGE_MAX_UPLOAD_MB=50
|
||||
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||
KNOWLEDGE_MAX_UPLOAD_MB=50
|
||||
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ from alembic import context
|
|||
from app.core.config import settings
|
||||
from app.db.database import Base
|
||||
import app.models.user
|
||||
import app.models.customer
|
||||
import app.models.knowledge
|
||||
import app.models.audit
|
||||
import app.models.rbac
|
||||
import app.models.repair
|
||||
|
||||
config = context.config
|
||||
|
||||
|
|
|
|||
204
backend/hermes/alembic/versions/c8e2f1a9b704_create_repairs.py
Normal file
204
backend/hermes/alembic/versions/c8e2f1a9b704_create_repairs.py
Normal 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")
|
||||
|
|
@ -56,6 +56,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
|
|||
return "roles.read" in permissions
|
||||
if action.startswith("knowledge."):
|
||||
return "knowledge.read" in permissions
|
||||
if action.startswith("repairs."):
|
||||
return "repairs.read" in permissions
|
||||
if action.startswith("audit_logs."):
|
||||
return "audit_logs.read" in permissions
|
||||
if action.startswith("auth."):
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from app.db.database import get_db
|
|||
from app.models.rbac import Role
|
||||
from app.models.user import User
|
||||
from app.repositories.customer_repository import CustomerRepository
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ def get_dashboard_summary(
|
|||
latest_customers = []
|
||||
users: list[MetricCard] = []
|
||||
roles: list[MetricCard] = []
|
||||
repairs: list[MetricCard] = []
|
||||
|
||||
if "customers.read" in permissions:
|
||||
customers = [
|
||||
|
|
@ -54,6 +56,14 @@ def get_dashboard_summary(
|
|||
)
|
||||
]
|
||||
|
||||
if "repairs.read" in permissions:
|
||||
repairs = [
|
||||
MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")),
|
||||
MetricCard(label="In Diagnose", value=RepairRepository.count_by_status(db, "diagnosis")),
|
||||
MetricCard(label="Warten auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")),
|
||||
MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")),
|
||||
]
|
||||
|
||||
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
|
||||
|
||||
return DashboardSummary(
|
||||
|
|
@ -61,6 +71,7 @@ def get_dashboard_summary(
|
|||
latest_customers=latest_customers,
|
||||
users=users,
|
||||
roles=roles,
|
||||
repairs=repairs,
|
||||
activities=EmptyWidget(
|
||||
title="Letzte Aktivitäten",
|
||||
message="Noch keine Aktivitäten vorhanden.",
|
||||
|
|
|
|||
142
backend/hermes/app/api/repairs.py
Normal file
142
backend/hermes/app/api/repairs.py
Normal 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",
|
||||
)
|
||||
|
|
@ -23,6 +23,7 @@ class Settings(BaseSettings):
|
|||
storage_max_upload_mb: int = 50
|
||||
knowledge_storage_path: str = "/data/knowledge"
|
||||
knowledge_max_upload_mb: int = 50
|
||||
olympus_repair_intake_token: str | None = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from app.api.customers import router as customers_router
|
|||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.knowledge import router as knowledge_router
|
||||
from app.api.permissions import router as permissions_router
|
||||
from app.api.repairs import router as repairs_router
|
||||
from app.api.roles import router as roles_router
|
||||
from app.api.users import router as users_router
|
||||
from app.db.database import SessionLocal
|
||||
|
|
@ -39,6 +40,7 @@ app.include_router(roles_router)
|
|||
app.include_router(permissions_router)
|
||||
app.include_router(customers_router)
|
||||
app.include_router(knowledge_router)
|
||||
app.include_router(repairs_router)
|
||||
app.include_router(dashboard_router)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
|||
81
backend/hermes/app/models/repair.py
Normal file
81
backend/hermes/app/models/repair.py
Normal 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())
|
||||
|
|
@ -76,6 +76,13 @@ STANDARD_PERMISSIONS = [
|
|||
("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"),
|
||||
("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"),
|
||||
("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"),
|
||||
("repairs.read", "Reparaturen lesen", "Reparaturen anzeigen", "repairs"),
|
||||
("repairs.create", "Reparaturen erstellen", "Reparaturen anlegen", "repairs"),
|
||||
("repairs.update", "Reparaturen bearbeiten", "Reparaturen aktualisieren", "repairs"),
|
||||
("repairs.delete", "Reparaturen löschen", "Reparaturen stornieren", "repairs"),
|
||||
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
|
||||
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
|
||||
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
|
||||
]
|
||||
|
||||
ROLE_PERMISSION_NAMES = {
|
||||
|
|
@ -88,6 +95,10 @@ ROLE_PERMISSION_NAMES = {
|
|||
"knowledge.download",
|
||||
"projects.read",
|
||||
"tickets.read",
|
||||
"repairs.read",
|
||||
"repairs.create",
|
||||
"repairs.update",
|
||||
"repairs.status.update",
|
||||
},
|
||||
"sales": {
|
||||
"dashboard.read",
|
||||
|
|
@ -108,6 +119,9 @@ ROLE_PERMISSION_NAMES = {
|
|||
"knowledge.update",
|
||||
"knowledge.upload",
|
||||
"knowledge.download",
|
||||
"repairs.read",
|
||||
"repairs.update",
|
||||
"repairs.status.update",
|
||||
},
|
||||
"support": {
|
||||
"dashboard.read",
|
||||
|
|
@ -118,6 +132,11 @@ ROLE_PERMISSION_NAMES = {
|
|||
"tickets.read",
|
||||
"tickets.create",
|
||||
"tickets.update",
|
||||
"repairs.read",
|
||||
"repairs.create",
|
||||
"repairs.update",
|
||||
"repairs.status.update",
|
||||
"repairs.intake",
|
||||
},
|
||||
"warehouse": {
|
||||
"dashboard.read",
|
||||
|
|
|
|||
219
backend/hermes/app/repositories/repair_repository.py
Normal file
219
backend/hermes/app/repositories/repair_repository.py
Normal 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)
|
||||
|
|
@ -18,6 +18,7 @@ class DashboardSummary(BaseModel):
|
|||
latest_customers: list[CustomerResponse] = []
|
||||
users: list[MetricCard] = []
|
||||
roles: list[MetricCard] = []
|
||||
repairs: list[MetricCard] = []
|
||||
activities: EmptyWidget
|
||||
tasks: EmptyWidget
|
||||
tickets: EmptyWidget
|
||||
|
|
|
|||
162
backend/hermes/app/schemas/repair.py
Normal file
162
backend/hermes/app/schemas/repair.py
Normal 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
|
||||
|
|
@ -157,6 +157,12 @@ def action_title(action: str) -> str:
|
|||
"knowledge.notes.create": "Wissensnotiz erstellt",
|
||||
"knowledge.notes.update": "Wissensnotiz bearbeitet",
|
||||
"knowledge.notes.delete": "Wissensnotiz gelöscht",
|
||||
"repairs.create": "Reparatur erstellt",
|
||||
"repairs.update": "Reparatur bearbeitet",
|
||||
"repairs.status_update": "Reparaturstatus geändert",
|
||||
"repairs.cancel": "Reparatur storniert",
|
||||
"repairs.intake_processed": "Reparatur-Intake verarbeitet",
|
||||
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
|
||||
}
|
||||
return labels.get(action, action)
|
||||
|
||||
|
|
|
|||
157
backend/hermes/app/services/repair_service.py
Normal file
157
backend/hermes/app/services/repair_service.py
Normal 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")
|
||||
|
|
@ -25,6 +25,7 @@ services:
|
|||
STORAGE_MAX_UPLOAD_MB: ${STORAGE_MAX_UPLOAD_MB:-50}
|
||||
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
|
||||
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
|
||||
OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-}
|
||||
|
||||
volumes:
|
||||
- ${STORAGE_HOST_PATH:-./storage}:/data/storage
|
||||
|
|
|
|||
14
frontend/athena/app/api/repairs/[id]/history/route.ts
Normal file
14
frontend/athena/app/api/repairs/[id]/history/route.ts
Normal 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`);
|
||||
}
|
||||
39
frontend/athena/app/api/repairs/[id]/route.ts
Normal file
39
frontend/athena/app/api/repairs/[id]/route.ts
Normal 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);
|
||||
}
|
||||
21
frontend/athena/app/api/repairs/[id]/status/route.ts
Normal file
21
frontend/athena/app/api/repairs/[id]/status/route.ts
Normal 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`);
|
||||
}
|
||||
18
frontend/athena/app/api/repairs/route.ts
Normal file
18
frontend/athena/app/api/repairs/route.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
const cards = [...summary.customers, ...summary.users, ...summary.roles];
|
||||
const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
234
frontend/athena/app/repairs/[id]/page.tsx
Normal file
234
frontend/athena/app/repairs/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
frontend/athena/app/repairs/page.tsx
Normal file
283
frontend/athena/app/repairs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ const menu = [
|
|||
icon: Wrench,
|
||||
name: "Reparaturen",
|
||||
href: "/repairs",
|
||||
permission: "repairs.read",
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
|
|
|
|||
222
frontend/athena/components/repairs/RepairFormDialog.tsx
Normal file
222
frontend/athena/components/repairs/RepairFormDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/athena/components/repairs/RepairStatusBadge.tsx
Normal file
59
frontend/athena/components/repairs/RepairStatusBadge.tsx
Normal 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 };
|
||||
45
frontend/athena/components/repairs/RepairStatusDialog.tsx
Normal file
45
frontend/athena/components/repairs/RepairStatusDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,5 +22,6 @@ export const config = {
|
|||
"/users/:path*",
|
||||
"/roles/:path*",
|
||||
"/customers/:path*",
|
||||
"/repairs/:path*",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface DashboardSummary {
|
|||
latest_customers: Customer[];
|
||||
users: MetricCard[];
|
||||
roles: MetricCard[];
|
||||
repairs: MetricCard[];
|
||||
activities: EmptyWidget;
|
||||
tasks: EmptyWidget;
|
||||
tickets: EmptyWidget;
|
||||
|
|
|
|||
89
frontend/athena/types/repair.ts
Normal file
89
frontend/athena/types/repair.ts
Normal 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;
|
||||
}
|
||||
1
funktechnik-schubert-website
Submodule
1
funktechnik-schubert-website
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit a284bbc1886b26091bea7ea7a5aacee71b4507fe
|
||||
Loading…
Add table
Add a link
Reference in a new issue