fix(estimates): add cancel confirmation and permissions

This commit is contained in:
Schubert Ferenc 2026-07-05 12:10:09 +02:00
parent 663bdc41b1
commit 31bdc1047d
2 changed files with 21 additions and 6 deletions

View file

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.requests import Request from starlette.requests import Request
from app.core.rbac import require_permission from app.core.rbac import require_any_permission, require_permission
from app.db.database import get_db from app.db.database import get_db
from app.models.repair import Repair from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate from app.models.repair_estimate import RepairEstimate
@ -117,7 +117,7 @@ def cancel_estimate(
estimate_id: int, estimate_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.update")), current_user: User = Depends(require_any_permission(["repair_estimates.update", "repair_estimates.send"])),
): ):
repair = get_repair_or_404(db, repair_id) repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id) estimate = get_estimate_or_404(db, repair_id, estimate_id)

View file

@ -183,6 +183,7 @@ export default function RepairEstimatesSection({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
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 [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[]>([]);
@ -372,11 +373,13 @@ export default function RepairEstimatesSection({
} }
} }
async function cancelEstimate(estimate: RepairEstimate) { async function cancelEstimate() {
setPendingId(estimate.id); if (!cancelTarget) return;
setPendingId(cancelTarget.id);
try { try {
await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${estimate.id}/cancel`); await api.post<RepairEstimate>(`/repairs/${repairId}/estimates/${cancelTarget.id}/cancel`);
await loadEstimates(); await loadEstimates();
setCancelTarget(null);
showToast({ type: "success", title: "Kostenvoranschlag storniert" }); showToast({ type: "success", title: "Kostenvoranschlag storniert" });
} catch (err) { } catch (err) {
showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) }); showToast({ type: "error", title: "Stornierung fehlgeschlagen", description: getErrorMessage(err) });
@ -472,7 +475,7 @@ export default function RepairEstimatesSection({
<div className="mt-4 flex flex-wrap gap-2"> <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>} {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 && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => void cancelEstimate(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>}
{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>
@ -606,6 +609,18 @@ export default function RepairEstimatesSection({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog
open={Boolean(cancelTarget)}
title="Kostenvoranschlag stornieren?"
description="Der Kostenvoranschlag wird storniert. Reservierte Lagerartikel werden freigegeben."
confirmLabel="Stornieren"
pending={pendingId === cancelTarget?.id}
onOpenChange={(open) => !open && setCancelTarget(null)}
onConfirm={() => void cancelEstimate()}
>
{cancelTarget && <p className="text-sm text-slate-600">{cancelTarget.estimate_number} · {cancelTarget.title}</p>}
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={Boolean(deleteTarget)} open={Boolean(deleteTarget)}
title="Kostenvoranschlag löschen?" title="Kostenvoranschlag löschen?"