feat(repairs): add repair estimates

This commit is contained in:
Schubert Ferenc 2026-07-05 00:57:02 +02:00
parent 6e7e75f864
commit 4436fe5f73
25 changed files with 1802 additions and 9 deletions

View file

@ -380,6 +380,66 @@ RBAC:
Athena ruft diese Endpunkte ausschliesslich ueber `/api/repairs/[id]/documents...` auf. Mutierende Requests verwenden den Same-Origin-Schutz. 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<jahr>-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 ### Website Repair Intake
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit. Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.

View file

@ -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. - `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. - 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<jahr>-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: Vorbereitete Website-/Portal-Routen fuer spaeter:
- `/status/<token>` - `/status/<token>`

View file

@ -134,7 +134,20 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Bildvorschau mit Lightbox, PDF inline/download - Bildvorschau mit Lightbox, PDF inline/download
- Audit Logs fuer Upload, Aenderung und Loeschung - 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<jahr>-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 - `/portal/login` fuer spaeteren Kundenlogin
- Separates Authentifizierungsmodell fuer Kunden - Separates Authentifizierungsmodell fuer Kunden

View file

@ -13,6 +13,7 @@ import app.models.knowledge
import app.models.audit import app.models.audit
import app.models.rbac import app.models.rbac
import app.models.repair import app.models.repair
import app.models.repair_estimate
import app.models.system_setting import app.models.system_setting
config = context.config config = context.config

View file

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

View file

@ -11,6 +11,7 @@ from app.models.audit import AuditLog
from app.models.user import User from app.models.user import User
from app.repositories.customer_repository import CustomerRepository from app.repositories.customer_repository import CustomerRepository
from app.repositories.repair_repository import RepairRepository from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.user_repository import UserRepository from app.repositories.user_repository import UserRepository
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
from app.services.system_settings_service import SystemSettingsService from app.services.system_settings_service import SystemSettingsService
@ -73,6 +74,14 @@ def get_dashboard_summary(
MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)), 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: if "system_settings.manage" in permissions:
smtp_config = SystemSettingsService.get_smtp_runtime_config(db) smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
public_links = SystemSettingsService.get_public_links_settings(db) public_links = SystemSettingsService.get_public_links_settings(db)

View file

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

View file

@ -26,6 +26,7 @@ import app.models.knowledge
import app.models.audit import app.models.audit
import app.models.user import app.models.user
import app.models.repair import app.models.repair
import app.models.repair_estimate
import app.models.system_setting import app.models.system_setting

View file

@ -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.knowledge import router as knowledge_router
from app.api.permissions import router as permissions_router from app.api.permissions import router as permissions_router
from app.api.repairs import router as repairs_router from app.api.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.roles import router as roles_router
from app.api.system_settings import router as system_settings_router from app.api.system_settings import router as system_settings_router
from app.api.users import router as users_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(customers_router)
app.include_router(knowledge_router) app.include_router(knowledge_router)
app.include_router(repairs_router) app.include_router(repairs_router)
app.include_router(repair_estimates_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
app.include_router(system_settings_router) app.include_router(system_settings_router)

View file

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

View file

@ -85,6 +85,11 @@ STANDARD_PERMISSIONS = [
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"), ("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"), ("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "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 = { ROLE_PERMISSION_NAMES = {
@ -102,6 +107,10 @@ ROLE_PERMISSION_NAMES = {
"repairs.update", "repairs.update",
"repairs.status.update", "repairs.status.update",
"repairs.public_link.manage", "repairs.public_link.manage",
"repair_estimates.read",
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
}, },
"sales": { "sales": {
"dashboard.read", "dashboard.read",
@ -125,6 +134,10 @@ ROLE_PERMISSION_NAMES = {
"repairs.read", "repairs.read",
"repairs.update", "repairs.update",
"repairs.status.update", "repairs.status.update",
"repair_estimates.read",
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
}, },
"support": { "support": {
"dashboard.read", "dashboard.read",
@ -141,6 +154,8 @@ ROLE_PERMISSION_NAMES = {
"repairs.status.update", "repairs.status.update",
"repairs.intake", "repairs.intake",
"repairs.public_link.manage", "repairs.public_link.manage",
"repair_estimates.read",
"repair_estimates.send",
}, },
"warehouse": { "warehouse": {
"dashboard.read", "dashboard.read",

View file

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

View file

@ -3,6 +3,8 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
from app.schemas.repair_estimate import PublicEstimateResponse
RepairStatus = Literal[ RepairStatus = Literal[
"new", "new",
"accepted", "accepted",
@ -202,6 +204,7 @@ class RepairPublicStatusResponse(BaseModel):
device_model: str device_model: str
status_history_public: list[RepairPublicStatusHistoryItem] status_history_public: list[RepairPublicStatusHistoryItem]
updated_at: datetime updated_at: datetime
estimate: PublicEstimateResponse | None = None
class RepairNotificationTemplateResponse(BaseModel): class RepairNotificationTemplateResponse(BaseModel):

View file

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

View file

@ -172,6 +172,14 @@ def action_title(action: str) -> str:
"repairs.documents.upload": "Reparaturdokument hochgeladen", "repairs.documents.upload": "Reparaturdokument hochgeladen",
"repairs.documents.update": "Reparaturdokument geändert", "repairs.documents.update": "Reparaturdokument geändert",
"repairs.documents.delete": "Reparaturdokument gelöscht", "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.update": "SMTP-Konfiguration geändert",
"system_settings.smtp.test_sent": "SMTP-Testmail versendet", "system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen", "system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",

View file

@ -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"""<!doctype html>
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
<tr><td style="padding:28px;">
<p>Hallo {escape(repair.customer_name)},</p>
<p>zu Ihrer Reparatur <strong>{escape(repair.repair_number)}</strong> liegt ein Kostenvoranschlag vor.</p>
<p><strong>Gerät:</strong> {escape((repair.device_manufacturer + " " + repair.device_model).strip())}<br>
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
<strong>Gesamtbetrag:</strong> {escape(_money(estimate.total_cents, estimate.currency))}</p>
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Kostenvoranschlag ansehen</a></p>
</td></tr></table></body></html>"""

View file

@ -11,6 +11,7 @@ from app.core.config import settings
from app.models.repair import Repair, RepairPublicAccessToken from app.models.repair import Repair, RepairPublicAccessToken
from app.models.user import User from app.models.user import User
from app.repositories.repair_repository import RepairRepository from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.schemas.repair import ( from app.schemas.repair import (
RepairPublicLinkCreateResponse, RepairPublicLinkCreateResponse,
RepairPublicLinkResponse, RepairPublicLinkResponse,
@ -131,16 +132,12 @@ class RepairPublicLinkService:
@staticmethod @staticmethod
def public_status(db: Session, token: str) -> RepairPublicStatusResponse: def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
normalized_token = normalize_token(token) public_link = RepairPublicLinkService.get_public_link_or_404(db, 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")
repair = public_link.repair repair = public_link.repair
history = RepairRepository.get_history_public(db, repair.id) 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( response = RepairPublicStatusResponse(
repair_number=repair.repair_number, repair_number=repair.repair_number,
public_status_label=STATUS_LABELS.get(repair.status, repair.status), public_status_label=STATUS_LABELS.get(repair.status, repair.status),
@ -155,6 +152,18 @@ class RepairPublicLinkService:
for item in history for item in history
], ],
updated_at=repair.updated_at, updated_at=repair.updated_at,
estimate=RepairEstimateService.public_response(estimate),
) )
RepairRepository.mark_public_link_used(db, public_link) RepairRepository.mark_public_link_used(db, public_link)
return response 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wr
import DetailSection from "@/components/common/DetailSection"; import DetailSection from "@/components/common/DetailSection";
import { useToast } from "@/components/common/ToastProvider"; import { useToast } from "@/components/common/ToastProvider";
import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection"; import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection";
import RepairEstimatesSection from "@/components/repairs/RepairEstimatesSection";
import RepairFormDialog from "@/components/repairs/RepairFormDialog"; import RepairFormDialog from "@/components/repairs/RepairFormDialog";
import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge"; import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge";
import RepairStatusDialog from "@/components/repairs/RepairStatusDialog"; import RepairStatusDialog from "@/components/repairs/RepairStatusDialog";
@ -169,6 +170,11 @@ export default function RepairDetailPage({ params }: Params) {
const canUpdate = hasPermission(currentUser, "repairs.update"); const canUpdate = hasPermission(currentUser, "repairs.update");
const canUpdateStatus = hasPermission(currentUser, "repairs.status.update"); const canUpdateStatus = hasPermission(currentUser, "repairs.status.update");
const canManagePublicLink = hasPermission(currentUser, "repairs.public_link.manage"); 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() { async function createPublicLink() {
if (!repair || !canManagePublicLink) return; if (!repair || !canManagePublicLink) return;
@ -427,6 +433,18 @@ export default function RepairDetailPage({ params }: Params) {
})()} })()}
</DetailSection> </DetailSection>
<DetailSection title="Kostenvoranschläge">
<RepairEstimatesSection
repairId={repair.id}
customerEmail={repair.customer_email}
canRead={canReadEstimates}
canCreate={canCreateEstimates}
canUpdate={canUpdateEstimates}
canDelete={canDeleteEstimates}
canSend={canSendEstimates}
/>
</DetailSection>
<DetailSection title="Dokumente & Bilder"> <DetailSection title="Dokumente & Bilder">
<RepairDocumentsSection repairId={repair.id} canUpdate={canUpdate} /> <RepairDocumentsSection repairId={repair.id} canUpdate={canUpdate} />
</DetailSection> </DetailSection>

View file

@ -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<RepairEstimateItemType, string> = {
labor: "Arbeitszeit",
part: "Ersatzteil",
flat_rate: "Pauschale",
shipping: "Versand",
other: "Sonstiges",
};
const statusLabels: Record<string, string> = {
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<RepairEstimate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<RepairEstimate | null>(null);
const [payload, setPayload] = useState<RepairEstimatePayload>(emptyPayload());
const [saving, setSaving] = useState(false);
const [pendingId, setPendingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
const loadEstimates = useCallback(async () => {
if (!canRead) {
setLoading(false);
return;
}
setLoading(true);
setError("");
try {
const response = await api.get<RepairEstimate[]>(`/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<RepairEstimateItemPayload>) {
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<RepairEstimate>(`/repairs/${repairId}/estimates/${editing.id}`, normalizedPayload);
} else {
await api.post<RepairEstimate>(`/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<RepairEstimate>(`/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<RepairEstimate>(`/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 <p className="text-sm text-slate-500">Keine Berechtigung für Kostenvoranschläge.</p>;
}
if (loading) {
return <div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-500">Kostenvoranschläge werden geladen...</div>;
}
if (error) {
return <div className="rounded-lg border bg-red-50 p-5 text-sm text-red-700">{error}</div>;
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-slate-500">{estimates.length} Kostenvoranschlag{estimates.length === 1 ? "" : "e"}</p>
{canCreate && <Button type="button" onClick={openCreateDialog}><Plus />KV anlegen</Button>}
</div>
{estimates.length === 0 ? (
<div className="rounded-lg border border-dashed bg-slate-50 p-8 text-center">
<h3 className="text-lg font-semibold text-slate-950">Noch kein Kostenvoranschlag vorhanden.</h3>
<p className="mx-auto mt-2 max-w-xl text-sm text-slate-500">Erstelle Positionen, lasse Olympus serverseitig summieren und sende den KV per Statuslink an den Kunden.</p>
{canCreate && <div className="mt-5"><Button type="button" onClick={openCreateDialog}><Plus />KV anlegen</Button></div>}
</div>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{estimates.map((estimate) => (
<article key={estimate.id} className="rounded-lg border bg-white p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<FileCheck2 className="h-5 w-5 text-slate-500" />
<h3 className="font-semibold text-slate-950">{estimate.estimate_number}</h3>
</div>
<p className="mt-1 text-sm text-slate-600">{estimate.title}</p>
</div>
<span className="w-fit rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">{statusLabels[estimate.status]}</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
<Meta label="Summe netto" value={money(estimate.subtotal_cents, estimate.currency)} />
<Meta label="MwSt." value={money(estimate.tax_cents, estimate.currency)} />
<Meta label="Gesamt" value={money(estimate.total_cents, estimate.currency)} />
<Meta label="Gültig bis" value={estimate.valid_until ?? "-"} />
<Meta label="Gesendet" value={dateTime(estimate.sent_at)} />
<Meta label="Antwort" value={estimate.customer_response_message || "-"} />
</dl>
<div className="mt-4 divide-y rounded-lg border">
{estimate.items.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
<div>
<p className="font-medium text-slate-950">{item.position}. {item.title}</p>
<p className="text-xs text-slate-500">{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}</p>
</div>
<p className="font-medium text-slate-950">{money(item.total_cents, estimate.currency)}</p>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{canUpdate && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => openEditDialog(estimate)}>Bearbeiten</Button>}
{canSend && ["draft", "sent"].includes(estimate.status) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>}
{canUpdate && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => void cancelEstimate(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
{canDelete && ["draft", "cancelled"].includes(estimate.status) && <Button type="button" variant="destructive" size="sm" onClick={() => setDeleteTarget(estimate)} disabled={pendingId === estimate.id}><Trash2 />Löschen</Button>}
</div>
</article>
))}
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{editing ? "Kostenvoranschlag bearbeiten" : "Kostenvoranschlag anlegen"}</DialogTitle>
<DialogDescription>Summen werden nach dem Speichern serverseitig berechnet.</DialogDescription>
</DialogHeader>
<div className="grid max-h-[70vh] gap-5 overflow-y-auto pr-1">
<div className="grid gap-4 md:grid-cols-2">
<Field label="Titel"><Input value={payload.title} onChange={(event) => setPayload((current) => ({ ...current, title: event.target.value }))} /></Field>
<Field label="Gültig bis"><Input type="date" value={payload.valid_until ?? ""} onChange={(event) => setPayload((current) => ({ ...current, valid_until: event.target.value || null }))} /></Field>
<Field label="MwSt. %"><Input value={payload.tax_rate_percent} onChange={(event) => setPayload((current) => ({ ...current, tax_rate_percent: event.target.value }))} /></Field>
<Field label="Währung"><Input value={payload.currency} onChange={(event) => setPayload((current) => ({ ...current, currency: event.target.value.toUpperCase() }))} /></Field>
</div>
<Field label="Kundennachricht"><textarea className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none" value={payload.customer_message} onChange={(event) => setPayload((current) => ({ ...current, customer_message: event.target.value }))} /></Field>
<Field label="Interne Notiz"><textarea className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none" value={payload.internal_note ?? ""} onChange={(event) => setPayload((current) => ({ ...current, internal_note: event.target.value }))} /></Field>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-slate-950">Positionen</h4>
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
</div>
{payload.items.map((item, index) => (
<div key={index} className="rounded-lg border bg-slate-50 p-3">
<div className="grid gap-3 md:grid-cols-[1fr_1fr_0.7fr_0.7fr_0.8fr_auto]">
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={item.item_type} onChange={(event) => updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}>
{Object.entries(itemTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<Input placeholder="Titel" value={item.title} onChange={(event) => updateItem(index, { title: event.target.value })} />
<Input placeholder="Menge" value={item.quantity} onChange={(event) => updateItem(index, { quantity: event.target.value })} />
<Input placeholder="Einheit" value={item.unit} onChange={(event) => updateItem(index, { unit: event.target.value })} />
<Input type="number" min={0} step={1} placeholder="Cent" value={item.unit_price_cents} onChange={(event) => updateItem(index, { unit_price_cents: Number(event.target.value) || 0 })} />
<Button type="button" variant="ghost" size="icon" disabled={payload.items.length === 1} onClick={() => setPayload((current) => ({ ...current, items: current.items.filter((_, itemIndex) => itemIndex !== index) }))} aria-label="Position entfernen"><Trash2 /></Button>
</div>
<Input className="mt-3" placeholder="Beschreibung optional" value={item.description ?? ""} onChange={(event) => updateItem(index, { description: event.target.value })} />
</div>
))}
</div>
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 text-sm sm:grid-cols-3">
<Meta label="Netto Vorschau" value={money(clientSubtotal, payload.currency || "EUR")} />
<Meta label="MwSt. Vorschau" value={money(clientTax, payload.currency || "EUR")} />
<Meta label="Gesamt Vorschau" value={money(clientSubtotal + clientTax, payload.currency || "EUR")} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>Abbrechen</Button>
<Button type="button" onClick={() => void saveEstimate()} disabled={saving}>{saving ? "Speichert..." : "Speichern"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(deleteTarget)}
title="Kostenvoranschlag löschen?"
description="Der Entwurf wird dauerhaft entfernt."
pending={pendingId === deleteTarget?.id}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={() => void deleteEstimate()}
>
{deleteTarget && <p className="text-sm text-slate-600">{deleteTarget.estimate_number} · {deleteTarget.title}</p>}
</ConfirmDialog>
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</dt>
<dd className="mt-1 break-words font-medium text-slate-950">{value}</dd>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
{children}
</label>
);
}

View file

@ -23,6 +23,8 @@ export type RepairDocumentType =
| "shipping" | "shipping"
| "other"; | "other";
export type RepairDocumentVisibility = "internal" | "customer"; export type RepairDocumentVisibility = "internal" | "customer";
export type RepairEstimateStatus = "draft" | "sent" | "approved" | "declined" | "expired" | "cancelled";
export type RepairEstimateItemType = "labor" | "part" | "flat_rate" | "shipping" | "other";
export interface Repair { export interface Repair {
id: number; id: number;
@ -161,3 +163,55 @@ export interface RepairDocument {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface RepairEstimateItemPayload {
item_type: RepairEstimateItemType;
title: string;
description: string | null;
quantity: string;
unit: string;
unit_price_cents: number;
}
export interface RepairEstimatePayload {
title: string;
customer_message: string;
internal_note: string | null;
tax_rate_percent: string;
currency: string;
valid_until: string | null;
items: RepairEstimateItemPayload[];
}
export interface RepairEstimateItem extends RepairEstimateItemPayload {
id: number;
estimate_id: number;
position: number;
total_cents: number;
created_at: string;
updated_at: string;
}
export interface RepairEstimate {
id: number;
repair_id: number;
estimate_number: string;
status: RepairEstimateStatus;
title: string;
customer_message: string;
internal_note: string | null;
subtotal_cents: number;
tax_rate_percent: string;
tax_cents: number;
total_cents: number;
currency: string;
valid_until: string | null;
sent_at: string | null;
approved_at: string | null;
declined_at: string | null;
customer_response_message: string | null;
created_by_user_id: number | null;
created_at: string;
updated_at: string;
items: RepairEstimateItem[];
}