Gutschriften Phase 2b: Vertrags-UI + Beleg-Upload + Nummernkreis-Seite

Frontend:
- CreditNotesSection im Vertragsdetail (eigene Section wie Rechnungen):
  Liste + Anlegen/Bearbeiten (Modal). Geld/Sachwert mit passenden
  Feldern, USt-Block mit Live-Vorschau Netto/USt/Brutto, Datum, Ort,
  Unterschrift, 'Ware erhalten'. Loeschen mit Bestaetigung.
- Ueberweisungsbeleg-Upload/-Anzeige/-Entfernen (nur Geld-Gutschriften).
- Nummernkreis-Verwaltung als Settings-Seite mit Live-Vorschau der
  naechsten Nummer.
- Frontend-Typen (CreditNote etc.) + creditNoteApi.

Backend:
- Ueberweisungsbeleg-Upload-Route /upload/credit-notes/:id/receipt
  (+ DELETE), Portal geblockt, Ownership ueber Vertrag.
- fileDownload: Ownership-Aufloesung fuer credit-note-receipts +
  credit-notes (Vertrag) ergaenzt.

Als Section statt Tab-Balken, weil die Vertragsansicht kartenbasiert
ist (kein bestehender Tab-Balken); spaeter umbaubar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 13:15:02 +02:00
co-authored by Claude Opus 4.8
parent 3580c51cb1
commit 9e38cb32f0
10 changed files with 804 additions and 6 deletions
+83
View File
@@ -946,4 +946,87 @@ router.delete(
}
);
// Upload für Gutschrift-Überweisungsbeleg. Interner Bereich → Portal blocken.
router.post(
'/credit-notes/:id/receipt',
authenticate,
requirePermission('contracts:update'),
setUploadDir('credit-note-receipts'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (req.user?.isCustomerPortal) {
cleanupFile(req.file?.path);
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return;
}
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const id = parseInt(req.params.id);
const cn = await prisma.creditNote.findUnique({
where: { id },
select: { contractId: true, receiptPath: true },
});
if (!cn) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
return;
}
if (!(await canAccessContract(req, res, cn.contractId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/credit-note-receipts/${req.file.filename}`;
if (cn.receiptPath) cleanupFile(path.join(process.cwd(), cn.receiptPath));
await prisma.creditNote.update({ where: { id }, data: { receiptPath: relativePath } });
await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
label: 'Überweisungsbeleg zur Gutschrift hochgeladen',
});
res.json({ success: true, data: { receiptPath: relativePath } });
} catch (error) {
console.error('Credit-note receipt upload error:', error);
cleanupFile(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
);
router.delete(
'/credit-notes/:id/receipt',
authenticate,
requirePermission('contracts:update'),
async (req: AuthRequest, res: Response) => {
try {
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return;
}
const id = parseInt(req.params.id);
const cn = await prisma.creditNote.findUnique({
where: { id },
select: { contractId: true, receiptPath: true },
});
if (!cn) {
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
return;
}
if (!(await canAccessContract(req, res, cn.contractId))) return;
if (cn.receiptPath) cleanupFile(path.join(process.cwd(), cn.receiptPath));
await prisma.creditNote.update({ where: { id }, data: { receiptPath: null } });
await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
label: 'Überweisungsbeleg zur Gutschrift entfernt',
});
res.json({ success: true });
} catch (error) {
console.error('Credit-note receipt delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
);
export default router;
@@ -93,6 +93,19 @@ export async function findUploadOwner(uploadPath: string): Promise<FileOwner | n
return r?.contractId ? { kind: 'contract', contractId: r.contractId } : null;
}
case 'credit-note-receipts':
case 'credit-notes': {
// Überweisungsbeleg bzw. generiertes Gutschrift-PDF Owner ist der
// Vertrag der Gutschrift.
const r = await prisma.creditNote.findFirst({
where: subDir === 'credit-note-receipts'
? { receiptPath: uploadPath }
: { pdfPath: uploadPath },
select: { contractId: true },
});
return r ? { kind: 'contract', contractId: r.contractId } : null;
}
case 'cancellation-letters':
case 'cancellation-confirmations':
case 'cancellation-letters-options':