Vertrag: Auto-CANCELLED bei Kuendigungsbestaetigung + Cockpit-Filter

1) Auto-Status: Wird eine Kuendigungsbestaetigung hinzugefuegt (Datum
   ueber Formular und/oder Dokument-Upload), wird der Vertrag von ACTIVE
   auf CANCELLED gesetzt und das Vertragsende = Kuendigungsdatum.
   Zentrale Funktion maybeCancelOnCancellationConfirmation (idempotent,
   nur aus ACTIVE). Upload-Route ersetzt die alte Inline-Logik (setzt
   jetzt auch endDate); Update-Controller triggert nur bei neu/geaendertem
   Bestaetigungsdatum (manuelle Status-Korrekturen bleiben unangetastet).

2) Cockpit-Filter 'Kuendigungsbestaetigung': neue Liste
   cancellationConfirmations (Vertraege mit Bestaetigung in Status
   ACTIVE/DRAFT/CANCELLED) + Filter-Option im Cockpit-Dropdown. Eigene
   Liste, weil bereits CANCELLED-Vertraege mangels Issue sonst nicht in
   der Cockpit-Liste auftauchen.

Beides lokal verifiziert (Helper: ACTIVE->CANCELLED + endDate; Cockpit:
Vertrag erscheint in der Liste mit korrektem Status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 15:13:05 +02:00
co-authored by Claude Opus 4.8
parent 5423c83009
commit 8169c74d8e
7 changed files with 159 additions and 20 deletions
@@ -157,6 +157,70 @@ export async function withContractDocumentLock<T>(
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
* getrimmt zur Robustheit.
*/
/**
* Wird aufgerufen, wenn zu einem Vertrag eine Kündigungsbestätigung
* hinzugefügt wird entweder als Datum (`cancellationConfirmationDate`,
* z.B. über das Vertragsformular) und/oder als Dokument
* (`cancellationConfirmationPath`, Upload). Effekt:
* - Vertrag ACTIVE → CANCELLED (nur aus ACTIVE; andere Status werden
* bewusst nicht angetastet).
* - Berechnetes Vertragsende (`endDate`) = Kündigungs(bestätigungs)datum,
* sofern eines vorliegt.
* Idempotent: läuft nur, wenn tatsächlich eine Bestätigung vorhanden ist,
* und schreibt nur bei echten Änderungen.
*/
export async function maybeCancelOnCancellationConfirmation(
contractId: number,
req: unknown,
): Promise<void> {
const c = await prisma.contract.findUnique({
where: { id: contractId },
select: {
status: true,
endDate: true,
contractNumber: true,
customerId: true,
cancellationConfirmationPath: true,
cancellationConfirmationDate: true,
},
});
if (!c) return;
const hasConfirmation = !!c.cancellationConfirmationPath || !!c.cancellationConfirmationDate;
if (!hasConfirmation) return;
const asDay = (d: Date | null | undefined) =>
d ? new Date(d).toISOString().split('T')[0] : null;
const updateData: Record<string, unknown> = {};
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
if (c.status === 'ACTIVE') {
updateData.status = 'CANCELLED';
changes.status = { vorher: 'ACTIVE', nachher: 'CANCELLED' };
}
// Vertragsende = Kündigungsdatum (Bestätigungsdatum), falls vorhanden.
if (c.cancellationConfirmationDate && asDay(c.endDate) !== asDay(c.cancellationConfirmationDate)) {
updateData.endDate = c.cancellationConfirmationDate;
changes.endDate = { vorher: asDay(c.endDate), nachher: asDay(c.cancellationConfirmationDate) };
}
if (Object.keys(updateData).length === 0) return;
await prisma.contract.update({ where: { id: contractId }, data: updateData });
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${c.contractNumber} automatisch aktualisiert (Kündigungsbestätigung)`,
details: { ...changes, trigger: 'Kündigungsbestätigung' },
customerId: c.customerId,
});
}
export async function maybeActivateOnDeliveryConfirmation(
contractId: number,
documentType: string,