From 4436fe5f7306f673b6d9174ed586a085c00f9c34 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 00:57:02 +0200 Subject: [PATCH] feat(repairs): add repair estimates --- ARCHITECTURE.md | 60 +++ README-DEV.md | 16 + ROADMAP.md | 15 +- backend/hermes/alembic/env.py | 1 + .../b4e8f1c7a205_create_repair_estimates.py | 160 +++++++ backend/hermes/app/api/dashboard.py | 9 + backend/hermes/app/api/repair_estimates.py | 181 ++++++++ backend/hermes/app/db/database.py | 1 + backend/hermes/app/main.py | 2 + backend/hermes/app/models/repair_estimate.py | 81 ++++ backend/hermes/app/rbac/defaults.py | 15 + .../repair_estimate_repository.py | 125 ++++++ backend/hermes/app/schemas/repair.py | 3 + backend/hermes/app/schemas/repair_estimate.py | 168 ++++++++ backend/hermes/app/services/audit_service.py | 8 + .../app/services/repair_estimate_service.py | 357 ++++++++++++++++ .../services/repair_public_link_service.py | 25 +- .../estimates/[estimateId]/cancel/route.ts | 19 + .../estimates/[estimateId]/events/route.ts | 12 + .../[id]/estimates/[estimateId]/route.ts | 37 ++ .../[id]/estimates/[estimateId]/send/route.ts | 19 + .../app/api/repairs/[id]/estimates/route.ts | 24 ++ frontend/athena/app/repairs/[id]/page.tsx | 18 + .../repairs/RepairEstimatesSection.tsx | 401 ++++++++++++++++++ frontend/athena/types/repair.ts | 54 +++ 25 files changed, 1802 insertions(+), 9 deletions(-) create mode 100644 backend/hermes/alembic/versions/b4e8f1c7a205_create_repair_estimates.py create mode 100644 backend/hermes/app/api/repair_estimates.py create mode 100644 backend/hermes/app/models/repair_estimate.py create mode 100644 backend/hermes/app/repositories/repair_estimate_repository.py create mode 100644 backend/hermes/app/schemas/repair_estimate.py create mode 100644 backend/hermes/app/services/repair_estimate_service.py create mode 100644 frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/cancel/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/events/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/send/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/estimates/route.ts create mode 100644 frontend/athena/components/repairs/RepairEstimatesSection.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0e0cb43..fdac253 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -380,6 +380,66 @@ RBAC: Athena ruft diese Endpunkte ausschliesslich ueber `/api/repairs/[id]/documents...` auf. Mutierende Requests verwenden den Same-Origin-Schutz. +### Kostenvoranschläge + +Ab v0.8.6 verwaltet Olympus Kostenvoranschlaege direkt im Repair-Modul. Es gibt keinen Kundenlogin und keine neue Website-App. Kundenentscheidungen laufen ueber den bestehenden sicheren Public-Statuslink. + +Datenmodell: + +- `repair_estimates` +- `repair_estimate_items` +- `repair_estimate_events` + +Kostenvoranschlagsnummern folgen `KV-000001`. Positionstypen sind `labor`, `part`, `flat_rate`, `shipping` und `other`. Statuswerte sind `draft`, `sent`, `approved`, `declined`, `expired` und `cancelled`. + +Hermes berechnet alle Summen serverseitig: + +- `subtotal_cents` aus Positionen +- `tax_cents` aus `tax_rate_percent` +- `total_cents` als Summe aus netto und Steuer + +Interne Hermes-Endpunkte: + +- `GET /repairs/{repair_id}/estimates` +- `POST /repairs/{repair_id}/estimates` +- `GET /repairs/{repair_id}/estimates/{estimate_id}` +- `PUT /repairs/{repair_id}/estimates/{estimate_id}` +- `DELETE /repairs/{repair_id}/estimates/{estimate_id}` +- `POST /repairs/{repair_id}/estimates/{estimate_id}/send` +- `POST /repairs/{repair_id}/estimates/{estimate_id}/cancel` +- `GET /repairs/{repair_id}/estimates/{estimate_id}/events` + +RBAC: + +- `repair_estimates.read` +- `repair_estimates.create` +- `repair_estimates.update` +- `repair_estimates.delete` +- `repair_estimates.send` + +Administrator bekommt alle Rechte. Management und Technik bekommen Lesen/Erstellen/Bearbeiten/Senden. Support bekommt Lesen/Senden. + +Public API: + +- `GET /public/repairs/status/{token}` liefert bei aktivem gesendetem KV ein Feld `estimate`. +- `POST /public/repairs/status/{token}/estimate/approve` +- `POST /public/repairs/status/{token}/estimate/decline` +- `POST /public/repairs/status/{token}/estimate/question` + +Die Public-Antwort enthaelt keine internen Notizen, keine internen Events und keine Tokens. Bei Freigabe setzt Olympus den KV auf `approved` und den Reparaturstatus auf `approved`. Bei Ablehnung oder Rueckfrage bleibt der Prozess bei `waiting_for_customer`. + +Der KV-Versand nutzt die bestehende SMTP-Konfiguration aus `system_settings` mit Env-Fallback. Fehler beim Mailversand zerstoeren den KV nicht; der Versandversuch wird als Notification Event dokumentiert. + +Nicht enthalten in v0.8.6: + +- PDF-Erzeugung +- Lexoffice +- Rechnungserstellung +- Kundenlogin +- Anzeige/Freigabe in der oeffentlichen Website + +Die Website kann spaeter, z. B. in `funktechnik-schubert-website` v0.4.3, die bereits vorhandenen Public-API-Daten anzeigen und die Entscheidungsendpunkte aufrufen. + ### Website Repair Intake Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit. diff --git a/README-DEV.md b/README-DEV.md index 165a88c..896ad69 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -198,6 +198,22 @@ Reparaturdokumente: - `visibility` ist mit `internal` und `customer` vorbereitet. In v0.8.5 gibt es noch keine oeffentliche Kundenanzeige fuer diese Dateien. - PDF-Dateien koennen inline oder als Download ueber Athena-BFF geoeffnet werden; ein vollstaendiger PDF-Viewer ist ein Folgefeature. +Kostenvoranschlaege: + +- Ab v0.8.6 koennen KVs direkt auf der Reparaturdetailseite erstellt, bearbeitet, gesendet, storniert und geloescht werden. +- Athena nutzt ausschliesslich BFF-Routen unter `/api/repairs/[id]/estimates`. +- Hermes berechnet alle Summen serverseitig. Client-Summen sind nur Vorschau. +- Nummernformat: `KV-000001`. +- Der Versand nutzt die bestehende SMTP-Konfiguration aus `/settings` mit Env-Fallback. +- Der Versand erzeugt/erneuert einen sicheren Public-Statuslink und sendet ihn an `customer_email`. +- `GET /public/repairs/status/{token}` liefert aktive/gesendete KV-Daten ohne interne Notizen. +- Public-Entscheidungen laufen ohne Kundenlogin ueber: + - `POST /public/repairs/status/{token}/estimate/approve` + - `POST /public/repairs/status/{token}/estimate/decline` + - `POST /public/repairs/status/{token}/estimate/question` +- Die oeffentliche Website zeigt diese Daten erst nach einer spaeteren Website-Version an. Olympus stellt die API dafuer bereit. +- PDF-Erzeugung, Lexoffice und Rechnungen sind Folgefeatures. + Vorbereitete Website-/Portal-Routen fuer spaeter: - `/status/` diff --git a/ROADMAP.md b/ROADMAP.md index de6753b..8248ce1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,7 +134,20 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Bildvorschau mit Lightbox, PDF inline/download - Audit Logs fuer Upload, Aenderung und Loeschung -## v0.8.6 - Kundenportal, geplant +## v0.8.6 - Kostenvoranschläge und Kundenfreigabe + +- Kostenvoranschläge direkt an Reparaturen +- Positionen fuer Arbeitszeit, Ersatzteile, Pauschalen, Versand und Sonstiges +- Serverseitige Netto-, MwSt.- und Gesamtsummen +- KV-Nummern im Format `KV-000001` +- Versand per bestehendem Public-Statuslink und SMTP-Systemsettings +- Public API liefert Estimate-Daten am Statuslink +- Public Approve/Decline/Question ohne Kundenlogin +- Kundenentscheidung aktualisiert Reparaturstatus und KV-Historie +- Keine PDF-Erzeugung, keine Lexoffice-Anbindung und keine Rechnungserstellung in dieser Version +- Website-Anzeige/Freigabe wird spaeter in `funktechnik-schubert-website` nachgezogen + +## v0.8.7 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden diff --git a/backend/hermes/alembic/env.py b/backend/hermes/alembic/env.py index 1f62d94..474acef 100644 --- a/backend/hermes/alembic/env.py +++ b/backend/hermes/alembic/env.py @@ -13,6 +13,7 @@ import app.models.knowledge import app.models.audit import app.models.rbac import app.models.repair +import app.models.repair_estimate import app.models.system_setting config = context.config diff --git a/backend/hermes/alembic/versions/b4e8f1c7a205_create_repair_estimates.py b/backend/hermes/alembic/versions/b4e8f1c7a205_create_repair_estimates.py new file mode 100644 index 0000000..bf6e3cf --- /dev/null +++ b/backend/hermes/alembic/versions/b4e8f1c7a205_create_repair_estimates.py @@ -0,0 +1,160 @@ +"""create repair estimates + +Revision ID: b4e8f1c7a205 +Revises: a1c5f9e2d430 +Create Date: 2026-07-05 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "b4e8f1c7a205" +down_revision: Union[str, Sequence[str], None] = "a1c5f9e2d430" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +ESTIMATE_PERMISSIONS = [ + ("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"), + ("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"), + ("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"), + ("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"), + ("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"), +] + + +def upgrade() -> None: + op.create_table( + "repair_estimates", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("repair_id", sa.Integer(), nullable=False), + sa.Column("estimate_number", sa.String(length=20), nullable=False), + sa.Column("status", sa.String(length=40), server_default="draft", nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("customer_message", sa.Text(), server_default="", nullable=False), + sa.Column("internal_note", sa.Text(), nullable=True), + sa.Column("subtotal_cents", sa.Integer(), server_default="0", nullable=False), + sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False), + sa.Column("tax_cents", sa.Integer(), server_default="0", nullable=False), + sa.Column("total_cents", sa.Integer(), server_default="0", nullable=False), + sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False), + sa.Column("valid_until", sa.Date(), nullable=True), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("customer_response_message", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), 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.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("estimate_number"), + ) + op.create_index(op.f("ix_repair_estimates_created_by_user_id"), "repair_estimates", ["created_by_user_id"], unique=False) + op.create_index(op.f("ix_repair_estimates_estimate_number"), "repair_estimates", ["estimate_number"], unique=True) + op.create_index(op.f("ix_repair_estimates_repair_id"), "repair_estimates", ["repair_id"], unique=False) + op.create_index(op.f("ix_repair_estimates_status"), "repair_estimates", ["status"], unique=False) + + op.create_table( + "repair_estimate_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("estimate_id", sa.Integer(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("item_type", sa.String(length=40), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("quantity", sa.Numeric(10, 2), server_default="1.00", nullable=False), + sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False), + sa.Column("unit_price_cents", sa.Integer(), server_default="0", nullable=False), + sa.Column("total_cents", sa.Integer(), server_default="0", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_repair_estimate_items_estimate_id"), "repair_estimate_items", ["estimate_id"], unique=False) + op.create_index(op.f("ix_repair_estimate_items_item_type"), "repair_estimate_items", ["item_type"], unique=False) + + op.create_table( + "repair_estimate_events", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("estimate_id", sa.Integer(), nullable=False), + sa.Column("event_type", sa.String(length=40), nullable=False), + sa.Column("actor_type", sa.String(length=40), nullable=False), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("note", sa.Text(), 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(["estimate_id"], ["repair_estimates.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_repair_estimate_events_actor_type"), "repair_estimate_events", ["actor_type"], unique=False) + op.create_index(op.f("ix_repair_estimate_events_actor_user_id"), "repair_estimate_events", ["actor_user_id"], unique=False) + op.create_index(op.f("ix_repair_estimate_events_estimate_id"), "repair_estimate_events", ["estimate_id"], unique=False) + op.create_index(op.f("ix_repair_estimate_events_event_type"), "repair_estimate_events", ["event_type"], unique=False) + + for name, display_name, description, module in ESTIMATE_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) + ) + + role_permissions = { + "administrator": [item[0] for item in ESTIMATE_PERMISSIONS], + "management": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"], + "technician": ["repair_estimates.read", "repair_estimates.create", "repair_estimates.update", "repair_estimates.send"], + "support": ["repair_estimates.read", "repair_estimates.send"], + } + for role_name, permission_names in role_permissions.items(): + for permission_name in permission_names: + 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: + op.execute( + sa.text( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE module = 'repair_estimates' + ) + """ + ) + ) + op.execute("DELETE FROM permissions WHERE module = 'repair_estimates'") + op.drop_index(op.f("ix_repair_estimate_events_event_type"), table_name="repair_estimate_events") + op.drop_index(op.f("ix_repair_estimate_events_estimate_id"), table_name="repair_estimate_events") + op.drop_index(op.f("ix_repair_estimate_events_actor_user_id"), table_name="repair_estimate_events") + op.drop_index(op.f("ix_repair_estimate_events_actor_type"), table_name="repair_estimate_events") + op.drop_table("repair_estimate_events") + op.drop_index(op.f("ix_repair_estimate_items_item_type"), table_name="repair_estimate_items") + op.drop_index(op.f("ix_repair_estimate_items_estimate_id"), table_name="repair_estimate_items") + op.drop_table("repair_estimate_items") + op.drop_index(op.f("ix_repair_estimates_status"), table_name="repair_estimates") + op.drop_index(op.f("ix_repair_estimates_repair_id"), table_name="repair_estimates") + op.drop_index(op.f("ix_repair_estimates_estimate_number"), table_name="repair_estimates") + op.drop_index(op.f("ix_repair_estimates_created_by_user_id"), table_name="repair_estimates") + op.drop_table("repair_estimates") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 9f9545d..2ef835a 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -11,6 +11,7 @@ from app.models.audit import AuditLog from app.models.user import User from app.repositories.customer_repository import CustomerRepository from app.repositories.repair_repository import RepairRepository +from app.repositories.repair_estimate_repository import RepairEstimateRepository from app.repositories.user_repository import UserRepository from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem from app.services.system_settings_service import SystemSettingsService @@ -73,6 +74,14 @@ def get_dashboard_summary( MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)), ] + if "repair_estimates.read" in permissions: + repairs.extend([ + MetricCard(label="Offene KVs", value=RepairEstimateRepository.count_open(db)), + MetricCard(label="Warten auf Freigabe", value=RepairEstimateRepository.count_waiting(db)), + MetricCard(label="KVs freigegeben heute", value=RepairEstimateRepository.count_approved_today(db)), + MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)), + ]) + if "system_settings.manage" in permissions: smtp_config = SystemSettingsService.get_smtp_runtime_config(db) public_links = SystemSettingsService.get_public_links_settings(db) diff --git a/backend/hermes/app/api/repair_estimates.py b/backend/hermes/app/api/repair_estimates.py new file mode 100644 index 0000000..56ab0fb --- /dev/null +++ b/backend/hermes/app/api/repair_estimates.py @@ -0,0 +1,181 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.repair import Repair +from app.models.repair_estimate import RepairEstimate +from app.models.user import User +from app.repositories.repair_estimate_repository import RepairEstimateRepository +from app.repositories.repair_repository import RepairRepository +from app.schemas.repair_estimate import ( + PublicEstimateDecisionRequest, + PublicEstimateResponse, + RepairEstimateCreate, + RepairEstimateEventResponse, + RepairEstimateResponse, + RepairEstimateUpdate, +) +from app.services.repair_estimate_service import RepairEstimateService +from app.services.repair_public_link_service import RepairPublicLinkService + +router = APIRouter(tags=["Repair Estimates"]) + + +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 + + +def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate: + estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id) + if estimate is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden") + return estimate + + +@router.get("/repairs/{repair_id}/estimates", response_model=list[RepairEstimateResponse]) +def list_estimates( + repair_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repair_estimates.read")), +): + get_repair_or_404(db, repair_id) + return RepairEstimateRepository.list_by_repair(db, repair_id) + + +@router.post("/repairs/{repair_id}/estimates", response_model=RepairEstimateResponse, status_code=status.HTTP_201_CREATED) +def create_estimate( + repair_id: int, + payload: RepairEstimateCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repair_estimates.create")), +): + repair = get_repair_or_404(db, repair_id) + return RepairEstimateService.create(db, repair, payload, actor=current_user, request=request) + + +@router.get("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse) +def get_estimate( + repair_id: int, + estimate_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repair_estimates.read")), +): + get_repair_or_404(db, repair_id) + return get_estimate_or_404(db, repair_id, estimate_id) + + +@router.put("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse) +def update_estimate( + repair_id: int, + estimate_id: int, + payload: RepairEstimateUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repair_estimates.update")), +): + repair = get_repair_or_404(db, repair_id) + estimate = get_estimate_or_404(db, repair_id, estimate_id) + return RepairEstimateService.update(db, repair, estimate, payload, actor=current_user, request=request) + + +@router.delete("/repairs/{repair_id}/estimates/{estimate_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_estimate( + repair_id: int, + estimate_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repair_estimates.delete")), +): + repair = get_repair_or_404(db, repair_id) + estimate = get_estimate_or_404(db, repair_id, estimate_id) + RepairEstimateService.delete(db, repair, estimate, actor=current_user, request=request) + return None + + +@router.post("/repairs/{repair_id}/estimates/{estimate_id}/send", response_model=RepairEstimateResponse) +def send_estimate( + repair_id: int, + estimate_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repair_estimates.send")), +): + repair = get_repair_or_404(db, repair_id) + estimate = get_estimate_or_404(db, repair_id, estimate_id) + return RepairEstimateService.send(db, repair, estimate, actor=current_user, request=request) + + +@router.post("/repairs/{repair_id}/estimates/{estimate_id}/cancel", response_model=RepairEstimateResponse) +def cancel_estimate( + repair_id: int, + estimate_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repair_estimates.update")), +): + repair = get_repair_or_404(db, repair_id) + estimate = get_estimate_or_404(db, repair_id, estimate_id) + return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request) + + +@router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse]) +def list_estimate_events( + repair_id: int, + estimate_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repair_estimates.read")), +): + estimate = get_estimate_or_404(db, repair_id, estimate_id) + return estimate.events + + +def get_public_repair_and_estimate(db: Session, token: str) -> tuple[Repair, RepairEstimate]: + public_link = RepairPublicLinkService.get_public_link_or_404(db, token) + repair = public_link.repair + estimate = RepairEstimateRepository.get_latest_public(db, repair.id) + if estimate is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden") + RepairRepository.mark_public_link_used(db, public_link) + return repair, estimate + + +@router.post("/public/repairs/status/{token}/estimate/approve", response_model=PublicEstimateResponse) +def approve_public_estimate( + token: str, + payload: PublicEstimateDecisionRequest, + request: Request, + db: Session = Depends(get_db), +): + repair, estimate = get_public_repair_and_estimate(db, token) + updated = RepairEstimateService.customer_decision(db, repair, estimate, "approve", payload, request=request) + return RepairEstimateService.public_response(updated) + + +@router.post("/public/repairs/status/{token}/estimate/decline", response_model=PublicEstimateResponse) +def decline_public_estimate( + token: str, + payload: PublicEstimateDecisionRequest, + request: Request, + db: Session = Depends(get_db), +): + repair, estimate = get_public_repair_and_estimate(db, token) + updated = RepairEstimateService.customer_decision(db, repair, estimate, "decline", payload, request=request) + return RepairEstimateService.public_response(updated) + + +@router.post("/public/repairs/status/{token}/estimate/question", response_model=PublicEstimateResponse) +def question_public_estimate( + token: str, + payload: PublicEstimateDecisionRequest, + request: Request, + db: Session = Depends(get_db), +): + repair, estimate = get_public_repair_and_estimate(db, token) + updated = RepairEstimateService.customer_decision(db, repair, estimate, "question", payload, request=request) + return RepairEstimateService.public_response(updated) diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index 6a559c7..dcc29f4 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -26,6 +26,7 @@ import app.models.knowledge import app.models.audit import app.models.user import app.models.repair +import app.models.repair_estimate import app.models.system_setting diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index d66787b..9443c1e 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -16,6 +16,7 @@ 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.repair_estimates import router as repair_estimates_router from app.api.roles import router as roles_router from app.api.system_settings import router as system_settings_router from app.api.users import router as users_router @@ -42,6 +43,7 @@ app.include_router(permissions_router) app.include_router(customers_router) app.include_router(knowledge_router) app.include_router(repairs_router) +app.include_router(repair_estimates_router) app.include_router(dashboard_router) app.include_router(system_settings_router) diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py new file mode 100644 index 0000000..81ec866 --- /dev/null +++ b/backend/hermes/app/models/repair_estimate.py @@ -0,0 +1,81 @@ +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.database import Base + + +class RepairEstimate(Base): + __tablename__ = "repair_estimates" + + id: Mapped[int] = mapped_column(primary_key=True) + repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True) + estimate_number: Mapped[str] = mapped_column(String(20), unique=True, index=True) + status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True) + title: Mapped[str] = mapped_column(String(255)) + customer_message: Mapped[str] = mapped_column(Text, default="", server_default="") + internal_note: Mapped[str | None] = mapped_column(Text, nullable=True) + subtotal_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00") + tax_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR") + valid_until: Mapped[date | None] = mapped_column(Date, nullable=True) + sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + customer_response_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by_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()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + repair = relationship("Repair", lazy="joined") + created_by = relationship("User", lazy="joined") + items: Mapped[list["RepairEstimateItem"]] = relationship( + back_populates="estimate", + cascade="all, delete-orphan", + order_by="RepairEstimateItem.position", + lazy="selectin", + ) + events: Mapped[list["RepairEstimateEvent"]] = relationship( + back_populates="estimate", + cascade="all, delete-orphan", + order_by="RepairEstimateEvent.created_at", + lazy="selectin", + ) + + +class RepairEstimateItem(Base): + __tablename__ = "repair_estimate_items" + + id: Mapped[int] = mapped_column(primary_key=True) + estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True) + position: Mapped[int] = mapped_column(Integer) + item_type: Mapped[str] = mapped_column(String(40), index=True) + title: Mapped[str] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + quantity: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=Decimal("1.00"), server_default="1.00") + unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.") + unit_price_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + 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()) + + estimate: Mapped[RepairEstimate] = relationship(back_populates="items") + + +class RepairEstimateEvent(Base): + __tablename__ = "repair_estimate_events" + + id: Mapped[int] = mapped_column(primary_key=True) + estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True) + event_type: Mapped[str] = mapped_column(String(40), index=True) + actor_type: Mapped[str] = mapped_column(String(40), index=True) + actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + estimate: Mapped[RepairEstimate] = relationship(back_populates="events") + actor = relationship("User", lazy="joined") diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 96c1699..12490a9 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -85,6 +85,11 @@ STANDARD_PERMISSIONS = [ ("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"), ("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"), ("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "repairs"), + ("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"), + ("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"), + ("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"), + ("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"), + ("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"), ] ROLE_PERMISSION_NAMES = { @@ -102,6 +107,10 @@ ROLE_PERMISSION_NAMES = { "repairs.update", "repairs.status.update", "repairs.public_link.manage", + "repair_estimates.read", + "repair_estimates.create", + "repair_estimates.update", + "repair_estimates.send", }, "sales": { "dashboard.read", @@ -125,6 +134,10 @@ ROLE_PERMISSION_NAMES = { "repairs.read", "repairs.update", "repairs.status.update", + "repair_estimates.read", + "repair_estimates.create", + "repair_estimates.update", + "repair_estimates.send", }, "support": { "dashboard.read", @@ -141,6 +154,8 @@ ROLE_PERMISSION_NAMES = { "repairs.status.update", "repairs.intake", "repairs.public_link.manage", + "repair_estimates.read", + "repair_estimates.send", }, "warehouse": { "dashboard.read", diff --git a/backend/hermes/app/repositories/repair_estimate_repository.py b/backend/hermes/app/repositories/repair_estimate_repository.py new file mode 100644 index 0000000..7519a94 --- /dev/null +++ b/backend/hermes/app/repositories/repair_estimate_repository.py @@ -0,0 +1,125 @@ +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from app.models.repair_estimate import RepairEstimate, RepairEstimateEvent, RepairEstimateItem + + +class RepairEstimateRepository: + @staticmethod + def list_by_repair(db: Session, repair_id: int) -> list[RepairEstimate]: + return list( + db.scalars( + select(RepairEstimate) + .options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events)) + .where(RepairEstimate.repair_id == repair_id) + .order_by(RepairEstimate.created_at.desc(), RepairEstimate.id.desc()) + ) + ) + + @staticmethod + def get(db: Session, *, repair_id: int, estimate_id: int) -> RepairEstimate | None: + return db.scalar( + select(RepairEstimate) + .options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events)) + .where(RepairEstimate.repair_id == repair_id) + .where(RepairEstimate.id == estimate_id) + ) + + @staticmethod + def get_latest_public(db: Session, repair_id: int) -> RepairEstimate | None: + return db.scalar( + select(RepairEstimate) + .options(selectinload(RepairEstimate.items)) + .where(RepairEstimate.repair_id == repair_id) + .where(RepairEstimate.status.in_(["sent", "approved", "declined"])) + .order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc()) + .limit(1) + ) + + @staticmethod + def get_next_number(db: Session, year: int) -> str: + prefix = f"KV{year}-" + latest = db.scalar( + select(RepairEstimate.estimate_number) + .where(RepairEstimate.estimate_number.like(f"{prefix}%")) + .order_by(RepairEstimate.estimate_number.desc()) + .limit(1) + ) + next_number = 1 + if latest: + next_number = int(latest.split("-")[-1]) + 1 + return f"{prefix}{next_number:06d}" + + @staticmethod + def save(db: Session, estimate: RepairEstimate) -> RepairEstimate: + db.add(estimate) + db.commit() + db.refresh(estimate) + return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate + + @staticmethod + def replace_items(db: Session, estimate: RepairEstimate, items: list[RepairEstimateItem]) -> None: + estimate.items.clear() + db.flush() + for item in items: + estimate.items.append(item) + + @staticmethod + def delete(db: Session, estimate: RepairEstimate) -> None: + db.delete(estimate) + db.commit() + + @staticmethod + def add_event( + db: Session, + *, + estimate_id: int, + event_type: str, + actor_type: str, + actor_user_id: int | None = None, + note: str | None = None, + commit: bool = True, + ) -> RepairEstimateEvent: + event = RepairEstimateEvent( + estimate_id=estimate_id, + event_type=event_type, + actor_type=actor_type, + actor_user_id=actor_user_id, + note=note, + ) + db.add(event) + if commit: + db.commit() + db.refresh(event) + return event + + @staticmethod + def mark_sent(db: Session, estimate: RepairEstimate) -> RepairEstimate: + estimate.status = "sent" + estimate.sent_at = datetime.now(UTC) + db.commit() + db.refresh(estimate) + return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate + + @staticmethod + def count_open(db: Session) -> int: + return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status.in_(["draft", "sent"]))) or 0 + + @staticmethod + def count_waiting(db: Session) -> int: + return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "sent")) or 0 + + @staticmethod + def count_approved_today(db: Session) -> int: + today = datetime.now(UTC).date() + return db.scalar( + select(func.count(RepairEstimate.id)) + .where(RepairEstimate.status == "approved") + .where(func.date(RepairEstimate.approved_at) == today) + ) or 0 + + @staticmethod + def count_declined(db: Session) -> int: + return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "declined")) or 0 diff --git a/backend/hermes/app/schemas/repair.py b/backend/hermes/app/schemas/repair.py index cb55b81..90e0653 100644 --- a/backend/hermes/app/schemas/repair.py +++ b/backend/hermes/app/schemas/repair.py @@ -3,6 +3,8 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator +from app.schemas.repair_estimate import PublicEstimateResponse + RepairStatus = Literal[ "new", "accepted", @@ -202,6 +204,7 @@ class RepairPublicStatusResponse(BaseModel): device_model: str status_history_public: list[RepairPublicStatusHistoryItem] updated_at: datetime + estimate: PublicEstimateResponse | None = None class RepairNotificationTemplateResponse(BaseModel): diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py new file mode 100644 index 0000000..f2c6f44 --- /dev/null +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -0,0 +1,168 @@ +from datetime import date, datetime +from decimal import Decimal, ROUND_HALF_UP +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled"] +EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"] +EstimateActorType = Literal["user", "customer", "system"] +EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question"] + + +def normalize_text(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +class RepairEstimateItemPayload(BaseModel): + item_type: EstimateItemType = "other" + title: str = Field(min_length=1, max_length=255) + description: str | None = None + quantity: Decimal = Field(gt=Decimal("0")) + unit: str = Field(default="Stk.", max_length=40) + unit_price_cents: int = Field(ge=0) + + @field_validator("title", "description", "unit", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class RepairEstimatePayload(BaseModel): + title: str = Field(min_length=1, max_length=255) + customer_message: str = "" + internal_note: str | None = None + tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0")) + currency: str = Field(default="EUR", max_length=3) + valid_until: date | None = None + items: list[RepairEstimateItemPayload] + + @field_validator("title", "customer_message", "internal_note", "currency", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + return value.upper() or "EUR" + + @model_validator(mode="after") + def validate_items(self): + if not self.items: + raise ValueError("Mindestens eine Position ist erforderlich") + return self + + +class RepairEstimateCreate(RepairEstimatePayload): + pass + + +class RepairEstimateUpdate(RepairEstimatePayload): + pass + + +class RepairEstimateItemResponse(BaseModel): + id: int + estimate_id: int + position: int + item_type: EstimateItemType + title: str + description: str | None + quantity: Decimal + unit: str + unit_price_cents: int + total_cents: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class RepairEstimateEventResponse(BaseModel): + id: int + estimate_id: int + event_type: str + actor_type: str + actor_user_id: int | None + note: str | None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class RepairEstimateResponse(BaseModel): + id: int + repair_id: int + estimate_number: str + status: EstimateStatus + title: str + customer_message: str + internal_note: str | None + subtotal_cents: int + tax_rate_percent: Decimal + tax_cents: int + total_cents: int + currency: str + valid_until: date | None + sent_at: datetime | None + approved_at: datetime | None + declined_at: datetime | None + customer_response_message: str | None + created_by_user_id: int | None + created_at: datetime + updated_at: datetime + items: list[RepairEstimateItemResponse] + + model_config = ConfigDict(from_attributes=True) + + +class PublicEstimateItemResponse(BaseModel): + item_type: EstimateItemType + title: str + description: str | None + quantity: Decimal + unit: str + unit_price_cents: int + total_cents: int + + +class PublicEstimateResponse(BaseModel): + estimate_number: str + status: EstimateStatus + title: str + customer_message: str + subtotal_cents: int + tax_cents: int + total_cents: int + currency: str + valid_until: date | None + items: list[PublicEstimateItemResponse] + + +class PublicEstimateDecisionRequest(BaseModel): + message: str | None = Field(default=None, max_length=2000) + + @field_validator("message", mode="before") + @classmethod + def normalize_message(cls, value: object) -> str | None: + if value is None: + return None + text = normalize_text(value) + return text or None + + +def calculate_item_total(quantity: Decimal, unit_price_cents: int) -> int: + total = (quantity * Decimal(unit_price_cents)).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + return int(total) + + +def calculate_tax(subtotal_cents: int, tax_rate_percent: Decimal) -> int: + tax = (Decimal(subtotal_cents) * tax_rate_percent / Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + return int(tax) diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 0ff681a..204e17c 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -172,6 +172,14 @@ def action_title(action: str) -> str: "repairs.documents.upload": "Reparaturdokument hochgeladen", "repairs.documents.update": "Reparaturdokument geändert", "repairs.documents.delete": "Reparaturdokument gelöscht", + "repair_estimates.create": "Kostenvoranschlag erstellt", + "repair_estimates.update": "Kostenvoranschlag geändert", + "repair_estimates.send": "Kostenvoranschlag gesendet", + "repair_estimates.approve": "Kostenvoranschlag freigegeben", + "repair_estimates.decline": "Kostenvoranschlag abgelehnt", + "repair_estimates.question": "Rückfrage zum Kostenvoranschlag", + "repair_estimates.cancel": "Kostenvoranschlag storniert", + "repair_estimates.delete": "Kostenvoranschlag gelöscht", "system_settings.smtp.update": "SMTP-Konfiguration geändert", "system_settings.smtp.test_sent": "SMTP-Testmail versendet", "system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen", diff --git a/backend/hermes/app/services/repair_estimate_service.py b/backend/hermes/app/services/repair_estimate_service.py new file mode 100644 index 0000000..e933587 --- /dev/null +++ b/backend/hermes/app/services/repair_estimate_service.py @@ -0,0 +1,357 @@ +from datetime import UTC, datetime +from decimal import Decimal +from html import escape + +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.repair_estimate import RepairEstimate, RepairEstimateItem +from app.models.user import User +from app.repositories.repair_estimate_repository import RepairEstimateRepository +from app.repositories.repair_repository import RepairRepository +from app.schemas.repair import RepairStatusUpdate +from app.schemas.repair_estimate import ( + PublicEstimateDecisionRequest, + PublicEstimateItemResponse, + PublicEstimateResponse, + RepairEstimateCreate, + RepairEstimateItemPayload, + RepairEstimateUpdate, + calculate_item_total, + calculate_tax, +) +from app.services.audit_service import write_audit_log +from app.services.repair_public_link_service import RepairPublicLinkService +from app.services.system_settings_service import SystemSettingsService + + +def _repair_label(repair: Repair) -> str: + return f"{repair.repair_number} · {repair.customer_name}" + + +def _estimate_label(estimate: RepairEstimate) -> str: + return f"{estimate.estimate_number} · {estimate.title}" + + +def _money(cents: int, currency: str = "EUR") -> str: + return f"{cents / 100:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".") + + +def _audit_estimate_data(estimate: RepairEstimate) -> dict: + return { + "id": estimate.id, + "repair_id": estimate.repair_id, + "estimate_number": estimate.estimate_number, + "status": estimate.status, + "title": estimate.title, + "currency": estimate.currency, + "valid_until": estimate.valid_until, + "sent_at": estimate.sent_at, + "approved_at": estimate.approved_at, + "declined_at": estimate.declined_at, + "created_by_user_id": estimate.created_by_user_id, + } + + +class RepairEstimateService: + @staticmethod + def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate: + estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year) + estimate = RepairEstimate( + repair_id=repair.id, + estimate_number=estimate_number, + status="draft", + created_by_user_id=actor.id, + ) + RepairEstimateService._apply_payload(estimate, payload) + try: + db.add(estimate) + db.flush() + RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False) + db.commit() + except IntegrityError: + db.rollback() + estimate.estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year) + db.add(estimate) + db.flush() + RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False) + db.commit() + db.refresh(estimate) + estimate = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate + write_audit_log( + db, + action="repair_estimates.create", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + actor=actor, + request=request, + after_data=_audit_estimate_data(estimate), + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + return estimate + + @staticmethod + def update(db: Session, repair: Repair, estimate: RepairEstimate, payload: RepairEstimateUpdate, *, actor: User, request: Request) -> RepairEstimate: + if estimate.status not in {"draft", "sent"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden") + before_data = _audit_estimate_data(estimate) + RepairEstimateService._apply_payload(estimate, payload) + RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False) + db.commit() + db.refresh(estimate) + updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate + write_audit_log( + db, + action="repair_estimates.update", + entity_type="repair_estimates", + entity_id=updated.id, + entity_label=_estimate_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_estimate_data(updated), + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + return updated + + @staticmethod + def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None: + if estimate.status not in {"draft", "cancelled"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe oder stornierte Kostenvoranschläge können gelöscht werden") + before_data = _audit_estimate_data(estimate) + label = _estimate_label(estimate) + RepairEstimateRepository.delete(db, estimate) + write_audit_log( + db, + action="repair_estimates.delete", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + + @staticmethod + def send(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate: + if estimate.status not in {"draft", "sent"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht gesendet werden") + + if not repair.customer_email: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt") + + link = RepairPublicLinkService.create_with_audit( + db, + repair, + actor=actor, + request=request, + audit_action="repairs.public_link.regenerate", + ) + public_status_url = link.public_status_path + estimate = RepairEstimateRepository.mark_sent(db, estimate) + RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id) + smtp_config = SystemSettingsService.get_smtp_runtime_config(db) + mail_sent = False + if smtp_config.is_configured: + try: + SystemSettingsService.send_email( + smtp_config, + recipient=repair.customer_email, + subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}", + text=RepairEstimateService._estimate_mail_text(repair, estimate, public_status_url), + html=RepairEstimateService._estimate_mail_html(repair, estimate, public_status_url), + ) + mail_sent = True + except Exception: + mail_sent = False + + RepairRepository.create_notification_event( + db, + repair_id=repair.id, + event_type="repair_estimate_mail", + channel="email", + recipient=repair.customer_email, + subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}", + template="repair_estimate", + status="sent" if mail_sent else "failed", + success=mail_sent, + error_message=None if mail_sent else "Kostenvoranschlag-Mail konnte nicht versendet werden", + sent_at=datetime.now(UTC) if mail_sent else None, + ) + RepairRepository.update_status(db, repair, RepairStatusUpdate(status="waiting_for_customer", note="Kostenvoranschlag gesendet"), actor_user_id=actor.id) + write_audit_log( + db, + action="repair_estimates.send", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + actor=actor, + request=request, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent}, + ) + return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate + + @staticmethod + def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate: + if estimate.status in {"approved", "declined", "cancelled"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden") + estimate.status = "cancelled" + RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False) + db.commit() + db.refresh(estimate) + write_audit_log( + db, + action="repair_estimates.cancel", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + actor=actor, + request=request, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate + + @staticmethod + def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None: + if estimate is None: + return None + return PublicEstimateResponse( + estimate_number=estimate.estimate_number, + status=estimate.status, + title=estimate.title, + customer_message=estimate.customer_message, + subtotal_cents=estimate.subtotal_cents, + tax_cents=estimate.tax_cents, + total_cents=estimate.total_cents, + currency=estimate.currency, + valid_until=estimate.valid_until, + items=[ + PublicEstimateItemResponse( + item_type=item.item_type, + title=item.title, + description=item.description, + quantity=item.quantity, + unit=item.unit, + unit_price_cents=item.unit_price_cents, + total_cents=item.total_cents, + ) + for item in estimate.items + ], + ) + + @staticmethod + def customer_decision( + db: Session, + repair: Repair, + estimate: RepairEstimate, + decision: str, + payload: PublicEstimateDecisionRequest, + *, + request: Request | None, + ) -> RepairEstimate: + if estimate.status != "sent": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag ist nicht zur Entscheidung offen") + now = datetime.now(UTC) + estimate.customer_response_message = payload.message + if decision == "approve": + estimate.status = "approved" + estimate.approved_at = now + event_type = "approved" + repair_status = "approved" + note = "Kunde hat den Kostenvoranschlag freigegeben" + audit_action = "repair_estimates.approve" + elif decision == "decline": + estimate.status = "declined" + estimate.declined_at = now + event_type = "declined" + repair_status = "waiting_for_customer" + note = "Kunde hat den Kostenvoranschlag abgelehnt" + audit_action = "repair_estimates.decline" + else: + event_type = "question" + repair_status = "waiting_for_customer" + note = "Kunde hat eine Rückfrage zum Kostenvoranschlag gestellt" + audit_action = "repair_estimates.question" + + RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type=event_type, actor_type="customer", note=payload.message, commit=False) + RepairRepository.update_status(db, repair, RepairStatusUpdate(status=repair_status, note=note), actor_user_id=None) + db.commit() + db.refresh(estimate) + write_audit_log( + db, + action=audit_action, + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + request=request, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"}, + ) + return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate + + @staticmethod + def _apply_payload(estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None: + estimate.title = payload.title + estimate.customer_message = payload.customer_message + estimate.internal_note = payload.internal_note + estimate.tax_rate_percent = payload.tax_rate_percent + estimate.currency = payload.currency + estimate.valid_until = payload.valid_until + items, subtotal = RepairEstimateService._build_items(payload.items) + tax_cents = calculate_tax(subtotal, payload.tax_rate_percent) + estimate.subtotal_cents = subtotal + estimate.tax_cents = tax_cents + estimate.total_cents = subtotal + tax_cents + estimate.items = items + + @staticmethod + def _build_items(payload_items: list[RepairEstimateItemPayload]) -> tuple[list[RepairEstimateItem], int]: + items: list[RepairEstimateItem] = [] + subtotal = 0 + for index, payload in enumerate(payload_items, start=1): + total = calculate_item_total(payload.quantity, payload.unit_price_cents) + subtotal += total + items.append( + RepairEstimateItem( + position=index, + item_type=payload.item_type, + title=payload.title, + description=payload.description, + quantity=payload.quantity, + unit=payload.unit, + unit_price_cents=payload.unit_price_cents, + total_cents=total, + ) + ) + return items, subtotal + + @staticmethod + def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str: + return ( + f"Hallo {repair.customer_name},\n\n" + f"zu Ihrer Reparatur {repair.repair_number} liegt ein Kostenvoranschlag vor.\n\n" + f"Gerät: {repair.device_manufacturer} {repair.device_model}\n" + f"Kostenvoranschlag: {estimate.estimate_number}\n" + f"Gesamtbetrag: {_money(estimate.total_cents, estimate.currency)}\n\n" + f"Kostenvoranschlag ansehen und entscheiden:\n{public_status_url}\n\n" + "Funktechnik Schubert" + ) + + @staticmethod + def _estimate_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str: + return f""" + + + +
Funktechnik Schubert
+

Hallo {escape(repair.customer_name)},

+

zu Ihrer Reparatur {escape(repair.repair_number)} liegt ein Kostenvoranschlag vor.

+

Gerät: {escape((repair.device_manufacturer + " " + repair.device_model).strip())}
+Kostenvoranschlag: {escape(estimate.estimate_number)}
+Gesamtbetrag: {escape(_money(estimate.total_cents, estimate.currency))}

+

Kostenvoranschlag ansehen

+
""" diff --git a/backend/hermes/app/services/repair_public_link_service.py b/backend/hermes/app/services/repair_public_link_service.py index f35c231..c75e86c 100644 --- a/backend/hermes/app/services/repair_public_link_service.py +++ b/backend/hermes/app/services/repair_public_link_service.py @@ -11,6 +11,7 @@ from app.core.config import settings from app.models.repair import Repair, RepairPublicAccessToken from app.models.user import User from app.repositories.repair_repository import RepairRepository +from app.repositories.repair_estimate_repository import RepairEstimateRepository from app.schemas.repair import ( RepairPublicLinkCreateResponse, RepairPublicLinkResponse, @@ -131,16 +132,12 @@ class RepairPublicLinkService: @staticmethod def public_status(db: Session, token: str) -> RepairPublicStatusResponse: - normalized_token = normalize_token(token) - if not normalized_token: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") - - public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token)) - if public_link is None or public_link.repair is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") - + public_link = RepairPublicLinkService.get_public_link_or_404(db, token) repair = public_link.repair history = RepairRepository.get_history_public(db, repair.id) + estimate = RepairEstimateRepository.get_latest_public(db, repair.id) + from app.services.repair_estimate_service import RepairEstimateService + response = RepairPublicStatusResponse( repair_number=repair.repair_number, public_status_label=STATUS_LABELS.get(repair.status, repair.status), @@ -155,6 +152,18 @@ class RepairPublicLinkService: for item in history ], updated_at=repair.updated_at, + estimate=RepairEstimateService.public_response(estimate), ) RepairRepository.mark_public_link_used(db, public_link) return response + + @staticmethod + def get_public_link_or_404(db: Session, token: str) -> RepairPublicAccessToken: + normalized_token = normalize_token(token) + if not normalized_token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") + + public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token)) + if public_link is None or public_link.repair is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") + return public_link diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/cancel/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/cancel/route.ts new file mode 100644 index 0000000..97a785f --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/cancel/route.ts @@ -0,0 +1,19 @@ +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; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/cancel`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/events/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/events/route.ts new file mode 100644 index 0000000..6dd1af5 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/events/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ id: string; estimateId: string }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/events`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts new file mode 100644 index 0000000..bdb0937 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/route.ts @@ -0,0 +1,37 @@ +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; estimateId: string }>; +}; + +async function proxyEstimateRequest(request: NextRequest, { params }: Params) { + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyEstimateRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyEstimateRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyEstimateRequest(request, context); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/send/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/send/route.ts new file mode 100644 index 0000000..f7f13a6 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/send/route.ts @@ -0,0 +1,19 @@ +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; estimateId: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id, estimateId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/send`); +} diff --git a/frontend/athena/app/api/repairs/[id]/estimates/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/route.ts new file mode 100644 index 0000000..7469ee5 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/route.ts @@ -0,0 +1,24 @@ +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 GET(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates`); +} + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/estimates`); +} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index 9bb4f9a..78a1394 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -7,6 +7,7 @@ import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wr import DetailSection from "@/components/common/DetailSection"; import { useToast } from "@/components/common/ToastProvider"; import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection"; +import RepairEstimatesSection from "@/components/repairs/RepairEstimatesSection"; import RepairFormDialog from "@/components/repairs/RepairFormDialog"; import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge"; import RepairStatusDialog from "@/components/repairs/RepairStatusDialog"; @@ -169,6 +170,11 @@ export default function RepairDetailPage({ params }: Params) { const canUpdate = hasPermission(currentUser, "repairs.update"); const canUpdateStatus = hasPermission(currentUser, "repairs.status.update"); const canManagePublicLink = hasPermission(currentUser, "repairs.public_link.manage"); + const canReadEstimates = hasPermission(currentUser, "repair_estimates.read"); + const canCreateEstimates = hasPermission(currentUser, "repair_estimates.create"); + const canUpdateEstimates = hasPermission(currentUser, "repair_estimates.update"); + const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete"); + const canSendEstimates = hasPermission(currentUser, "repair_estimates.send"); async function createPublicLink() { if (!repair || !canManagePublicLink) return; @@ -427,6 +433,18 @@ export default function RepairDetailPage({ params }: Params) { })()} + + + + diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx new file mode 100644 index 0000000..6e00342 --- /dev/null +++ b/frontend/athena/components/repairs/RepairEstimatesSection.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { FileCheck2, Plus, Send, Trash2, XCircle } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import { useToast } from "@/components/common/ToastProvider"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/api"; +import type { + RepairEstimate, + RepairEstimateItemPayload, + RepairEstimateItemType, + RepairEstimatePayload, +} from "@/types/repair"; + +const itemTypeLabels: Record = { + labor: "Arbeitszeit", + part: "Ersatzteil", + flat_rate: "Pauschale", + shipping: "Versand", + other: "Sonstiges", +}; + +const statusLabels: Record = { + draft: "Entwurf", + sent: "Gesendet", + approved: "Freigegeben", + declined: "Abgelehnt", + expired: "Abgelaufen", + cancelled: "Storniert", +}; + +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 money(cents: number, currency = "EUR") { + return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(cents / 100); +} + +function dateTime(value: string | null) { + if (!value) return "-"; + return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); +} + +function emptyItem(): RepairEstimateItemPayload { + return { + item_type: "labor", + title: "", + description: "", + quantity: "1.00", + unit: "Std.", + unit_price_cents: 0, + }; +} + +function emptyPayload(): RepairEstimatePayload { + return { + title: "Kostenvoranschlag", + customer_message: "Bitte prüfen Sie den Kostenvoranschlag und geben Sie uns über den Statuslink Rückmeldung.", + internal_note: "", + tax_rate_percent: "19.00", + currency: "EUR", + valid_until: null, + items: [emptyItem()], + }; +} + +type Props = { + repairId: number; + customerEmail: string; + canRead: boolean; + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; + canSend: boolean; +}; + +export default function RepairEstimatesSection({ + repairId, + customerEmail, + canRead, + canCreate, + canUpdate, + canDelete, + canSend, +}: Props) { + const { showToast } = useToast(); + const [estimates, setEstimates] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [payload, setPayload] = useState(emptyPayload()); + const [saving, setSaving] = useState(false); + const [pendingId, setPendingId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const loadEstimates = useCallback(async () => { + if (!canRead) { + setLoading(false); + return; + } + setLoading(true); + setError(""); + try { + const response = await api.get(`/repairs/${repairId}/estimates`); + setEstimates(response.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [canRead, repairId]); + + useEffect(() => { + queueMicrotask(() => { + void loadEstimates(); + }); + }, [loadEstimates]); + + const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => { + const quantity = Number(item.quantity.replace(",", ".")) || 0; + return sum + Math.round(quantity * item.unit_price_cents); + }, 0), [payload.items]); + const clientTax = Math.round(clientSubtotal * (Number(payload.tax_rate_percent.replace(",", ".")) || 0) / 100); + + function openCreateDialog() { + setEditing(null); + setPayload(emptyPayload()); + setDialogOpen(true); + } + + function openEditDialog(estimate: RepairEstimate) { + setEditing(estimate); + setPayload({ + title: estimate.title, + customer_message: estimate.customer_message, + internal_note: estimate.internal_note ?? "", + tax_rate_percent: String(estimate.tax_rate_percent), + currency: estimate.currency, + valid_until: estimate.valid_until, + items: estimate.items.map((item) => ({ + item_type: item.item_type, + title: item.title, + description: item.description ?? "", + quantity: String(item.quantity), + unit: item.unit, + unit_price_cents: item.unit_price_cents, + })), + }); + setDialogOpen(true); + } + + function updateItem(index: number, update: Partial) { + setPayload((current) => ({ + ...current, + items: current.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...update } : item), + })); + } + + async function saveEstimate() { + setSaving(true); + try { + const normalizedPayload = { + ...payload, + internal_note: payload.internal_note || null, + valid_until: payload.valid_until || null, + items: payload.items.map((item) => ({ + ...item, + description: item.description || null, + quantity: item.quantity.replace(",", "."), + })), + }; + if (editing) { + await api.put(`/repairs/${repairId}/estimates/${editing.id}`, normalizedPayload); + } else { + await api.post(`/repairs/${repairId}/estimates`, normalizedPayload); + } + await loadEstimates(); + setDialogOpen(false); + showToast({ type: "success", title: editing ? "Kostenvoranschlag gespeichert" : "Kostenvoranschlag erstellt" }); + } catch (err) { + showToast({ type: "error", title: "Kostenvoranschlag konnte nicht gespeichert werden", description: getErrorMessage(err) }); + } finally { + setSaving(false); + } + } + + async function sendEstimate(estimate: RepairEstimate) { + setPendingId(estimate.id); + try { + await api.post(`/repairs/${repairId}/estimates/${estimate.id}/send`); + await loadEstimates(); + showToast({ type: "success", title: "Kostenvoranschlag gesendet", description: "Der Versandversuch wurde dokumentiert." }); + } catch (err) { + showToast({ type: "error", title: "Kostenvoranschlag konnte nicht gesendet werden", description: getErrorMessage(err) }); + } finally { + setPendingId(null); + } + } + + async function cancelEstimate(estimate: RepairEstimate) { + setPendingId(estimate.id); + try { + await api.post(`/repairs/${repairId}/estimates/${estimate.id}/cancel`); + await loadEstimates(); + showToast({ type: "success", title: "Kostenvoranschlag storniert" }); + } catch (err) { + showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) }); + } finally { + setPendingId(null); + } + } + + async function deleteEstimate() { + if (!deleteTarget) return; + setPendingId(deleteTarget.id); + try { + await api.delete(`/repairs/${repairId}/estimates/${deleteTarget.id}`); + await loadEstimates(); + setDeleteTarget(null); + showToast({ type: "success", title: "Kostenvoranschlag gelöscht" }); + } catch (err) { + showToast({ type: "error", title: "Löschen fehlgeschlagen", description: getErrorMessage(err) }); + } finally { + setPendingId(null); + } + } + + if (!canRead) { + return

Keine Berechtigung für Kostenvoranschläge.

; + } + + if (loading) { + return
Kostenvoranschläge werden geladen...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+
+

{estimates.length} Kostenvoranschlag{estimates.length === 1 ? "" : "e"}

+ {canCreate && } +
+ + {estimates.length === 0 ? ( +
+

Noch kein Kostenvoranschlag vorhanden.

+

Erstelle Positionen, lasse Olympus serverseitig summieren und sende den KV per Statuslink an den Kunden.

+ {canCreate &&
} +
+ ) : ( +
+ {estimates.map((estimate) => ( +
+
+
+
+ +

{estimate.estimate_number}

+
+

{estimate.title}

+
+ {statusLabels[estimate.status]} +
+ +
+ + + + + + +
+ +
+ {estimate.items.map((item) => ( +
+
+

{item.position}. {item.title}

+

{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}

+
+

{money(item.total_cents, estimate.currency)}

+
+ ))} +
+ +
+ {canUpdate && ["draft", "sent"].includes(estimate.status) && } + {canSend && ["draft", "sent"].includes(estimate.status) && } + {canUpdate && ["draft", "sent"].includes(estimate.status) && } + {canDelete && ["draft", "cancelled"].includes(estimate.status) && } +
+
+ ))} +
+ )} + + + + + {editing ? "Kostenvoranschlag bearbeiten" : "Kostenvoranschlag anlegen"} + Summen werden nach dem Speichern serverseitig berechnet. + + +
+
+ setPayload((current) => ({ ...current, title: event.target.value }))} /> + setPayload((current) => ({ ...current, valid_until: event.target.value || null }))} /> + setPayload((current) => ({ ...current, tax_rate_percent: event.target.value }))} /> + setPayload((current) => ({ ...current, currency: event.target.value.toUpperCase() }))} /> +
+