fix(estimates): allow approved estimate revocation

This commit is contained in:
Schubert Ferenc 2026-07-05 12:26:45 +02:00
parent 31bdc1047d
commit 8d37eb29b3
14 changed files with 295 additions and 8 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}",

View file

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

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/revoke`);
}

View file

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

View file

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

View file

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