fix(estimates): allow approved estimate revocation
This commit is contained in:
parent
31bdc1047d
commit
8d37eb29b3
14 changed files with 295 additions and 8 deletions
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""add repair estimate revoke permission
|
||||||
|
|
||||||
|
Revision ID: a7c3e9d4b821
|
||||||
|
Revises: f4a9c2d7e118
|
||||||
|
Create Date: 2026-07-05 15:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "a7c3e9d4b821"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f4a9c2d7e118"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
PERMISSION_NAME = "repair_estimates.revoke"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO permissions (name, display_name, description, module)
|
||||||
|
VALUES (
|
||||||
|
:name,
|
||||||
|
'Kostenvoranschläge zurücknehmen',
|
||||||
|
'Freigegebene Kostenvoranschläge administrativ zurücknehmen',
|
||||||
|
'repair_estimates'
|
||||||
|
)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
description = excluded.description,
|
||||||
|
module = excluded.module
|
||||||
|
"""
|
||||||
|
).bindparams(name=PERMISSION_NAME)
|
||||||
|
)
|
||||||
|
for role_name in ("administrator", "management"):
|
||||||
|
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 name = :permission_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
).bindparams(permission_name=PERMISSION_NAME)
|
||||||
|
)
|
||||||
|
op.execute(sa.text("DELETE FROM permissions WHERE name = :permission_name").bindparams(permission_name=PERMISSION_NAME))
|
||||||
|
|
@ -58,6 +58,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
|
||||||
return "knowledge.read" in permissions
|
return "knowledge.read" in permissions
|
||||||
if action.startswith("repairs."):
|
if action.startswith("repairs."):
|
||||||
return "repairs.read" in permissions
|
return "repairs.read" in permissions
|
||||||
|
if action.startswith("repair_estimates."):
|
||||||
|
return "repair_estimates.read" in permissions
|
||||||
if action.startswith("inventory."):
|
if action.startswith("inventory."):
|
||||||
return "inventory.read" in permissions
|
return "inventory.read" in permissions
|
||||||
if action.startswith("audit_logs."):
|
if action.startswith("audit_logs."):
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ def get_dashboard_summary(
|
||||||
MetricCard(label="Warten auf Freigabe", value=RepairEstimateRepository.count_waiting(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 freigegeben heute", value=RepairEstimateRepository.count_approved_today(db)),
|
||||||
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)),
|
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)),
|
||||||
|
MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)),
|
||||||
])
|
])
|
||||||
|
|
||||||
if "inventory.read" in permissions:
|
if "inventory.read" in permissions:
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,19 @@ def cancel_estimate(
|
||||||
return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request)
|
return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/revoke", response_model=RepairEstimateResponse)
|
||||||
|
def revoke_estimate(
|
||||||
|
repair_id: int,
|
||||||
|
estimate_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("repair_estimates.revoke")),
|
||||||
|
):
|
||||||
|
repair = get_repair_or_404(db, repair_id)
|
||||||
|
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||||
|
return RepairEstimateService.revoke(db, repair, estimate, actor=current_user, request=request)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse])
|
@router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse])
|
||||||
def list_estimate_events(
|
def list_estimate_events(
|
||||||
repair_id: int,
|
repair_id: int,
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ STANDARD_PERMISSIONS = [
|
||||||
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "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.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
|
||||||
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
|
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
|
||||||
|
("repair_estimates.revoke", "Kostenvoranschläge zurücknehmen", "Freigegebene Kostenvoranschläge administrativ zurücknehmen", "repair_estimates"),
|
||||||
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
|
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
|
||||||
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
|
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
|
||||||
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
|
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
|
||||||
|
|
@ -119,6 +120,7 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"repair_estimates.create",
|
"repair_estimates.create",
|
||||||
"repair_estimates.update",
|
"repair_estimates.update",
|
||||||
"repair_estimates.send",
|
"repair_estimates.send",
|
||||||
|
"repair_estimates.revoke",
|
||||||
"inventory.read",
|
"inventory.read",
|
||||||
"inventory.create",
|
"inventory.create",
|
||||||
"inventory.update",
|
"inventory.update",
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class RepairEstimateRepository:
|
||||||
select(RepairEstimate)
|
select(RepairEstimate)
|
||||||
.options(selectinload(RepairEstimate.items))
|
.options(selectinload(RepairEstimate.items))
|
||||||
.where(RepairEstimate.repair_id == repair_id)
|
.where(RepairEstimate.repair_id == repair_id)
|
||||||
.where(RepairEstimate.status.in_(["sent", "approved", "declined"]))
|
.where(RepairEstimate.status.in_(["sent", "approved", "declined", "revoked"]))
|
||||||
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
|
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|
@ -123,3 +123,14 @@ class RepairEstimateRepository:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def count_declined(db: Session) -> int:
|
def count_declined(db: Session) -> int:
|
||||||
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "declined")) or 0
|
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "declined")) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_revoked_today(db: Session) -> int:
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
return db.scalar(
|
||||||
|
select(func.count(RepairEstimate.id))
|
||||||
|
.join(RepairEstimateEvent, RepairEstimateEvent.estimate_id == RepairEstimate.id)
|
||||||
|
.where(RepairEstimate.status == "revoked")
|
||||||
|
.where(RepairEstimateEvent.event_type == "revoked")
|
||||||
|
.where(func.date(RepairEstimateEvent.created_at) == today)
|
||||||
|
) or 0
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled"]
|
EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled", "revoked"]
|
||||||
EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"]
|
EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"]
|
||||||
EstimateActorType = Literal["user", "customer", "system"]
|
EstimateActorType = Literal["user", "customer", "system"]
|
||||||
EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question"]
|
EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question", "revoked"]
|
||||||
|
|
||||||
|
|
||||||
def normalize_text(value: object) -> str:
|
def normalize_text(value: object) -> str:
|
||||||
|
|
@ -167,6 +167,7 @@ class PublicEstimateItemResponse(BaseModel):
|
||||||
class PublicEstimateResponse(BaseModel):
|
class PublicEstimateResponse(BaseModel):
|
||||||
estimate_number: str
|
estimate_number: str
|
||||||
status: EstimateStatus
|
status: EstimateStatus
|
||||||
|
status_label: str
|
||||||
title: str
|
title: str
|
||||||
customer_message: str
|
customer_message: str
|
||||||
subtotal_cents: int
|
subtotal_cents: int
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ def action_title(action: str) -> str:
|
||||||
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
|
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
|
||||||
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
|
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
|
||||||
"repair_estimates.cancel": "Kostenvoranschlag storniert",
|
"repair_estimates.cancel": "Kostenvoranschlag storniert",
|
||||||
|
"repair_estimates.revoke": "Kostenvoranschlag zurückgenommen",
|
||||||
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
|
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
|
||||||
"inventory.items.create": "Lagerartikel erstellt",
|
"inventory.items.create": "Lagerartikel erstellt",
|
||||||
"inventory.items.update": "Lagerartikel geändert",
|
"inventory.items.update": "Lagerartikel geändert",
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,13 @@ class InventoryService:
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def release_estimate_reservation(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
def release_estimate_reservation(
|
||||||
|
db: Session,
|
||||||
|
estimate: RepairEstimate,
|
||||||
|
*,
|
||||||
|
actor_user_id: int | None,
|
||||||
|
reason: str = "estimate_released",
|
||||||
|
) -> None:
|
||||||
for estimate_item in estimate.items:
|
for estimate_item in estimate.items:
|
||||||
if estimate_item.inventory_item_id is None:
|
if estimate_item.inventory_item_id is None:
|
||||||
continue
|
continue
|
||||||
|
|
@ -240,7 +246,7 @@ class InventoryService:
|
||||||
item_id=item.id,
|
item_id=item.id,
|
||||||
movement_type="release",
|
movement_type="release",
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
reason="estimate_released",
|
reason=reason,
|
||||||
reference_type="repair_estimate",
|
reference_type="repair_estimate",
|
||||||
reference_id=estimate.id,
|
reference_id=estimate.id,
|
||||||
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,17 @@ def _audit_estimate_data(estimate: RepairEstimate) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ESTIMATE_STATUS_LABELS = {
|
||||||
|
"draft": "Entwurf",
|
||||||
|
"sent": "Wartet auf Freigabe",
|
||||||
|
"approved": "Freigegeben",
|
||||||
|
"declined": "Abgelehnt",
|
||||||
|
"expired": "Abgelaufen",
|
||||||
|
"cancelled": "Storniert",
|
||||||
|
"revoked": "Kostenvoranschlag wird überarbeitet",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RepairEstimateService:
|
class RepairEstimateService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
|
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
|
|
@ -249,7 +260,7 @@ class RepairEstimateService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
if estimate.status in {"approved", "declined", "cancelled"}:
|
if estimate.status in {"approved", "declined", "cancelled", "expired", "revoked"}:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
|
||||||
was_sent = estimate.status == "sent"
|
was_sent = estimate.status == "sent"
|
||||||
if was_sent:
|
if was_sent:
|
||||||
|
|
@ -281,6 +292,98 @@ class RepairEstimateService:
|
||||||
)
|
)
|
||||||
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def revoke(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
|
if estimate.status != "approved":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können zurückgenommen werden")
|
||||||
|
|
||||||
|
before_data = _audit_estimate_data(estimate)
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id, reason="estimate_revoked")
|
||||||
|
estimate.status = "revoked"
|
||||||
|
RepairEstimateRepository.add_event(
|
||||||
|
db,
|
||||||
|
estimate_id=estimate.id,
|
||||||
|
event_type="revoked",
|
||||||
|
actor_type="user",
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
note="Freigabe zurückgenommen",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(estimate)
|
||||||
|
|
||||||
|
if repair.status in {"approved", "repair", "final_test", "ready_for_pickup"}:
|
||||||
|
RepairRepository.update_status(
|
||||||
|
db,
|
||||||
|
repair,
|
||||||
|
RepairStatusUpdate(
|
||||||
|
status="waiting_for_customer",
|
||||||
|
note="Kostenvoranschlag zurückgenommen. Kunde wartet auf korrigierten Kostenvoranschlag.",
|
||||||
|
),
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
link = RepairPublicLinkService.create_with_audit(
|
||||||
|
db,
|
||||||
|
repair,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
audit_action="repairs.public_link.regenerate",
|
||||||
|
)
|
||||||
|
subject = "Kostenvoranschlag wurde zurückgenommen"
|
||||||
|
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||||
|
mail_sent = False
|
||||||
|
if smtp_config.is_configured and repair.customer_email:
|
||||||
|
try:
|
||||||
|
SystemSettingsService.send_email(
|
||||||
|
smtp_config,
|
||||||
|
recipient=repair.customer_email,
|
||||||
|
subject=subject,
|
||||||
|
text=RepairEstimateService._revoked_mail_text(repair, estimate, link.public_status_path),
|
||||||
|
html=RepairEstimateService._revoked_mail_html(repair, estimate, link.public_status_path),
|
||||||
|
)
|
||||||
|
mail_sent = True
|
||||||
|
except Exception:
|
||||||
|
mail_sent = False
|
||||||
|
|
||||||
|
RepairRepository.create_notification_event(
|
||||||
|
db,
|
||||||
|
repair_id=repair.id,
|
||||||
|
event_type="repair_estimate_revoked_mail",
|
||||||
|
channel="email",
|
||||||
|
recipient=repair.customer_email,
|
||||||
|
subject=subject,
|
||||||
|
template="repair_estimate_revoked",
|
||||||
|
status="sent" if mail_sent else "failed",
|
||||||
|
success=mail_sent,
|
||||||
|
error_message=None if mail_sent else "Kostenvoranschlag-Rücknahme-Mail konnte nicht versendet werden",
|
||||||
|
sent_at=datetime.now(UTC) if mail_sent else None,
|
||||||
|
)
|
||||||
|
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="repair_estimates.revoke",
|
||||||
|
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, "mail_sent": mail_sent},
|
||||||
|
)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.release",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=updated.id,
|
||||||
|
entity_label=_estimate_label(updated),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": "estimate_revoked", "repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
|
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
|
||||||
if estimate is None:
|
if estimate is None:
|
||||||
|
|
@ -288,6 +391,7 @@ class RepairEstimateService:
|
||||||
return PublicEstimateResponse(
|
return PublicEstimateResponse(
|
||||||
estimate_number=estimate.estimate_number,
|
estimate_number=estimate.estimate_number,
|
||||||
status=estimate.status,
|
status=estimate.status,
|
||||||
|
status_label=ESTIMATE_STATUS_LABELS.get(estimate.status, estimate.status),
|
||||||
title=estimate.title,
|
title=estimate.title,
|
||||||
customer_message=estimate.customer_message,
|
customer_message=estimate.customer_message,
|
||||||
subtotal_cents=estimate.subtotal_cents,
|
subtotal_cents=estimate.subtotal_cents,
|
||||||
|
|
@ -529,4 +633,30 @@ class RepairEstimateService:
|
||||||
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
|
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
|
||||||
<strong>Gesamtbetrag:</strong> {escape(_money(estimate.total_cents, estimate.currency))}</p>
|
<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>
|
<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>"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _revoked_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||||
|
return (
|
||||||
|
f"Hallo {repair.customer_name},\n\n"
|
||||||
|
"Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. "
|
||||||
|
"Sie erhalten in Kürze einen neuen Kostenvoranschlag.\n\n"
|
||||||
|
f"Reparatur: {repair.repair_number}\n"
|
||||||
|
f"Kostenvoranschlag: {estimate.estimate_number}\n\n"
|
||||||
|
f"Aktuellen Status ansehen:\n{public_status_url}\n\n"
|
||||||
|
"Funktechnik Schubert"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _revoked_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>Der zuvor freigegebene Kostenvoranschlag wurde aufgrund einer Korrektur zurückgenommen. Sie erhalten in Kürze einen neuen Kostenvoranschlag.</p>
|
||||||
|
<p><strong>Reparatur:</strong> {escape(repair.repair_number)}<br>
|
||||||
|
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}</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;">Status ansehen</a></p>
|
||||||
</td></tr></table></body></html>"""
|
</td></tr></table></body></html>"""
|
||||||
|
|
|
||||||
|
|
@ -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}/revoke`);
|
||||||
|
}
|
||||||
|
|
@ -175,6 +175,7 @@ export default function RepairDetailPage({ params }: Params) {
|
||||||
const canUpdateEstimates = hasPermission(currentUser, "repair_estimates.update");
|
const canUpdateEstimates = hasPermission(currentUser, "repair_estimates.update");
|
||||||
const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete");
|
const canDeleteEstimates = hasPermission(currentUser, "repair_estimates.delete");
|
||||||
const canSendEstimates = hasPermission(currentUser, "repair_estimates.send");
|
const canSendEstimates = hasPermission(currentUser, "repair_estimates.send");
|
||||||
|
const canRevokeEstimates = hasPermission(currentUser, "repair_estimates.revoke");
|
||||||
|
|
||||||
async function createPublicLink() {
|
async function createPublicLink() {
|
||||||
if (!repair || !canManagePublicLink) return;
|
if (!repair || !canManagePublicLink) return;
|
||||||
|
|
@ -442,6 +443,7 @@ export default function RepairDetailPage({ params }: Params) {
|
||||||
canUpdate={canUpdateEstimates}
|
canUpdate={canUpdateEstimates}
|
||||||
canDelete={canDeleteEstimates}
|
canDelete={canDeleteEstimates}
|
||||||
canSend={canSendEstimates}
|
canSend={canSendEstimates}
|
||||||
|
canRevoke={canRevokeEstimates}
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AlertTriangle, FileCheck2, PackageSearch, Plus, Send, Trash2, XCircle } from "lucide-react";
|
import { AlertTriangle, FileCheck2, PackageSearch, Plus, Send, Trash2, Undo2, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
|
@ -42,6 +42,7 @@ const statusLabels: Record<string, string> = {
|
||||||
declined: "Abgelehnt",
|
declined: "Abgelehnt",
|
||||||
expired: "Abgelaufen",
|
expired: "Abgelaufen",
|
||||||
cancelled: "Storniert",
|
cancelled: "Storniert",
|
||||||
|
revoked: "Zurückgenommen",
|
||||||
};
|
};
|
||||||
|
|
||||||
function humanizeValidationDetail(detail: unknown): string | null {
|
function humanizeValidationDetail(detail: unknown): string | null {
|
||||||
|
|
@ -162,6 +163,7 @@ type Props = {
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
|
canRevoke: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RepairEstimatesSection({
|
export default function RepairEstimatesSection({
|
||||||
|
|
@ -172,6 +174,7 @@ export default function RepairEstimatesSection({
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canSend,
|
canSend,
|
||||||
|
canRevoke,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [estimates, setEstimates] = useState<RepairEstimate[]>([]);
|
const [estimates, setEstimates] = useState<RepairEstimate[]>([]);
|
||||||
|
|
@ -184,6 +187,7 @@ export default function RepairEstimatesSection({
|
||||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
|
||||||
const [cancelTarget, setCancelTarget] = useState<RepairEstimate | null>(null);
|
const [cancelTarget, setCancelTarget] = useState<RepairEstimate | null>(null);
|
||||||
|
const [revokeTarget, setRevokeTarget] = useState<RepairEstimate | null>(null);
|
||||||
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
|
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
|
||||||
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
||||||
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
|
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
|
||||||
|
|
@ -388,6 +392,21 @@ export default function RepairEstimatesSection({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function revokeEstimate() {
|
||||||
|
if (!revokeTarget) return;
|
||||||
|
setPendingId(revokeTarget.id);
|
||||||
|
try {
|
||||||
|
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${revokeTarget.id}/revoke`);
|
||||||
|
await loadEstimates();
|
||||||
|
setRevokeTarget(null);
|
||||||
|
showToast({ type: "success", title: "Freigabe zurückgenommen", description: "Die Reservierungen wurden freigegeben." });
|
||||||
|
} catch (err) {
|
||||||
|
showToast({ type: "error", title: "Freigabe konnte nicht zurückgenommen werden", description: getErrorMessage(err) });
|
||||||
|
} finally {
|
||||||
|
setPendingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteEstimate() {
|
async function deleteEstimate() {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
setPendingId(deleteTarget.id);
|
setPendingId(deleteTarget.id);
|
||||||
|
|
@ -476,6 +495,7 @@ export default function RepairEstimatesSection({
|
||||||
{canUpdate && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => openEditDialog(estimate)}>Bearbeiten</Button>}
|
{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>}
|
{canSend && ["draft", "sent"].includes(estimate.status) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>}
|
||||||
{(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
|
{(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
|
||||||
|
{canRevoke && estimate.status === "approved" && <Button type="button" variant="destructive" size="sm" onClick={() => setRevokeTarget(estimate)} disabled={pendingId === estimate.id}><Undo2 />Freigabe zurücknehmen</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>}
|
{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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -621,6 +641,18 @@ export default function RepairEstimatesSection({
|
||||||
{cancelTarget && <p className="text-sm text-slate-600">{cancelTarget.estimate_number} · {cancelTarget.title}</p>}
|
{cancelTarget && <p className="text-sm text-slate-600">{cancelTarget.estimate_number} · {cancelTarget.title}</p>}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(revokeTarget)}
|
||||||
|
title="Freigabe zurücknehmen?"
|
||||||
|
description="Möchten Sie den bereits freigegebenen Kostenvoranschlag wirklich zurücknehmen? Die Lagerreservierungen werden freigegeben und der Kunde erhält eine Benachrichtigung."
|
||||||
|
confirmLabel="Freigabe zurücknehmen"
|
||||||
|
pending={pendingId === revokeTarget?.id}
|
||||||
|
onOpenChange={(open) => !open && setRevokeTarget(null)}
|
||||||
|
onConfirm={() => void revokeEstimate()}
|
||||||
|
>
|
||||||
|
{revokeTarget && <p className="text-sm text-slate-600">{revokeTarget.estimate_number} · {revokeTarget.title}</p>}
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
title="Kostenvoranschlag löschen?"
|
title="Kostenvoranschlag löschen?"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ 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 RepairEstimateStatus = "draft" | "sent" | "approved" | "declined" | "expired" | "cancelled" | "revoked";
|
||||||
export type RepairEstimateItemType = "labor" | "part" | "flat_rate" | "shipping" | "other";
|
export type RepairEstimateItemType = "labor" | "part" | "flat_rate" | "shipping" | "other";
|
||||||
|
|
||||||
export interface Repair {
|
export interface Repair {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue