feat(repairs): add repair estimates
This commit is contained in:
parent
6e7e75f864
commit
4436fe5f73
25 changed files with 1802 additions and 9 deletions
|
|
@ -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<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
|
||||
|
||||
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
|
||||
|
|
|
|||
|
|
@ -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<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:
|
||||
|
||||
- `/status/<token>`
|
||||
|
|
|
|||
15
ROADMAP.md
15
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<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
|
||||
- Separates Authentifizierungsmodell fuer Kunden
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
181
backend/hermes/app/api/repair_estimates.py
Normal file
181
backend/hermes/app/api/repair_estimates.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
81
backend/hermes/app/models/repair_estimate.py
Normal file
81
backend/hermes/app/models/repair_estimate.py
Normal 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")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
125
backend/hermes/app/repositories/repair_estimate_repository.py
Normal file
125
backend/hermes/app/repositories/repair_estimate_repository.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
168
backend/hermes/app/schemas/repair_estimate.py
Normal file
168
backend/hermes/app/schemas/repair_estimate.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
357
backend/hermes/app/services/repair_estimate_service.py
Normal file
357
backend/hermes/app/services/repair_estimate_service.py
Normal 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>"""
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
24
frontend/athena/app/api/repairs/[id]/estimates/route.ts
Normal file
24
frontend/athena/app/api/repairs/[id]/estimates/route.ts
Normal 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`);
|
||||
}
|
||||
|
|
@ -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) {
|
|||
})()}
|
||||
</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">
|
||||
<RepairDocumentsSection repairId={repair.id} canUpdate={canUpdate} />
|
||||
</DetailSection>
|
||||
|
|
|
|||
401
frontend/athena/components/repairs/RepairEstimatesSection.tsx
Normal file
401
frontend/athena/components/repairs/RepairEstimatesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ export type RepairDocumentType =
|
|||
| "shipping"
|
||||
| "other";
|
||||
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 {
|
||||
id: number;
|
||||
|
|
@ -161,3 +163,55 @@ export interface RepairDocument {
|
|||
created_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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue