From 9e38cb32f0884212c01c3b362d8ca3c7279b8ea2 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 6 Aug 2026 13:15:02 +0200 Subject: [PATCH] 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) --- backend/src/routes/upload.routes.ts | 83 ++++ backend/src/services/fileDownload.service.ts | 13 + docs/todo.md | 15 +- frontend/src/App.tsx | 2 + .../contracts/CreditNotesSection.tsx | 460 ++++++++++++++++++ frontend/src/pages/Settings.tsx | 9 +- .../src/pages/contracts/ContractDetail.tsx | 9 + .../pages/settings/CreditNoteNumberRange.tsx | 119 +++++ frontend/src/services/api.ts | 50 +- frontend/src/types/index.ts | 50 ++ 10 files changed, 804 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/contracts/CreditNotesSection.tsx create mode 100644 frontend/src/pages/settings/CreditNoteNumberRange.tsx diff --git a/backend/src/routes/upload.routes.ts b/backend/src/routes/upload.routes.ts index 07986a4d..ca71661b 100644 --- a/backend/src/routes/upload.routes.ts +++ b/backend/src/routes/upload.routes.ts @@ -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; diff --git a/backend/src/services/fileDownload.service.ts b/backend/src/services/fileDownload.service.ts index 93497895..4033e6c5 100644 --- a/backend/src/services/fileDownload.service.ts +++ b/backend/src/services/fileDownload.service.ts @@ -93,6 +93,19 @@ export async function findUploadOwner(uploadPath: string): Promise} /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/contracts/CreditNotesSection.tsx b/frontend/src/components/contracts/CreditNotesSection.tsx new file mode 100644 index 00000000..54b82e91 --- /dev/null +++ b/frontend/src/components/contracts/CreditNotesSection.tsx @@ -0,0 +1,460 @@ +import { useState, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit, Trash2, Gift, Banknote, Package, Upload, Eye, X, Check } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { formatDate } from '../../utils/dateFormat'; +import { fileUrl } from '../../utils/fileUrl'; +import Modal from '../ui/Modal'; +import Button from '../ui/Button'; +import Input from '../ui/Input'; +import Select from '../ui/Select'; +import Badge from '../ui/Badge'; +import { creditNoteApi } from '../../services/api'; +import type { CreditNote, CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '../../types'; + +const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100; + +function formatEuro(n: number, currency = 'EUR') { + return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n || 0); +} + +// Client-Vorschau der Beträge (der Server rechnet autoritativ nach). +function calcAmounts(amount: number, vatRelevant: boolean, basis: CreditNoteAmountBasis, rate: number) { + const a = round2(amount || 0); + if (!vatRelevant || rate <= 0) return { net: a, vat: 0, gross: a }; + const r = rate / 100; + if (basis === 'NETTO') { + const net = a; + const vat = round2(net * r); + return { net, vat, gross: round2(net + vat) }; + } + const gross = a; + const net = round2(gross / (1 + r)); + return { net, vat: round2(gross - net), gross }; +} + +const todayIso = () => new Date().toISOString().split('T')[0]; + +interface FormState { + type: CreditNoteType; + sachwertDescription: string; + customerType: CreditNoteCustomerType; + vatRelevant: boolean; + amountBasis: CreditNoteAmountBasis; + vatRate: string; + amount: string; + creditDate: string; + place: string; + signedAt: string; + goodsReceived: boolean; + notes: string; +} + +function emptyForm(defaults?: { customerType: CreditNoteCustomerType; vatRelevant: boolean }): FormState { + return { + type: 'GELD', + sachwertDescription: '', + customerType: defaults?.customerType ?? 'PRIVAT', + vatRelevant: defaults?.vatRelevant ?? false, + amountBasis: 'BRUTTO', + vatRate: '19', + amount: '', + creditDate: todayIso(), + place: '', + signedAt: '', + goodsReceived: false, + notes: '', + }; +} + +function formFromCreditNote(cn: CreditNote): FormState { + return { + type: cn.type, + sachwertDescription: cn.sachwertDescription ?? '', + customerType: cn.customerType, + vatRelevant: cn.vatRelevant, + amountBasis: cn.amountBasis, + vatRate: String(cn.vatRate), + // Beim Bearbeiten den Betrag auf der ursprünglichen Basis anzeigen. + amount: String(cn.amountBasis === 'NETTO' ? cn.amountNet : cn.amountGross), + creditDate: cn.creditDate ? cn.creditDate.split('T')[0] : todayIso(), + place: cn.place ?? '', + signedAt: cn.signedAt ? cn.signedAt.split('T')[0] : '', + goodsReceived: cn.goodsReceived, + notes: cn.notes ?? '', + }; +} + +// ---------- Formular-Modal ---------- +function CreditNoteFormModal({ + contractId, + editing, + defaults, + onClose, + onSaved, +}: { + contractId: number; + editing: CreditNote | null; + defaults?: { customerType: CreditNoteCustomerType; vatRelevant: boolean }; + onClose: () => void; + onSaved: () => void; +}) { + const [form, setForm] = useState(editing ? formFromCreditNote(editing) : emptyForm(defaults)); + const set = (key: K, value: FormState[K]) => setForm((f) => ({ ...f, [key]: value })); + + const preview = calcAmounts(parseFloat(form.amount), form.vatRelevant, form.amountBasis, parseFloat(form.vatRate) || 0); + + const saveMutation = useMutation({ + mutationFn: () => { + const payload = { + type: form.type, + sachwertDescription: form.type === 'SACHWERT' ? form.sachwertDescription : null, + customerType: form.customerType, + vatRelevant: form.vatRelevant, + amountBasis: form.amountBasis, + vatRate: Number(form.vatRate) || 0, + amount: Number(form.amount) || 0, + creditDate: form.creditDate, + place: form.place || null, + signedAt: form.signedAt || null, + goodsReceived: form.type === 'SACHWERT' ? form.goodsReceived : false, + notes: form.notes || null, + }; + return editing ? creditNoteApi.update(editing.id, payload) : creditNoteApi.create(contractId, payload); + }, + onSuccess: () => { + toast.success(editing ? 'Gutschrift gespeichert' : 'Gutschrift angelegt'); + onSaved(); + }, + onError: (err: Error) => toast.error(err.message || 'Speichern fehlgeschlagen'), + }); + + const amountLabel = form.vatRelevant + ? `Betrag (${form.amountBasis === 'NETTO' ? 'netto' : 'brutto'})` + : 'Betrag'; + + const canSave = + !!form.amount && + Number(form.amount) >= 0 && + (form.type !== 'SACHWERT' || form.sachwertDescription.trim().length > 0); + + return ( + +
+
+ set('customerType', e.target.value as CreditNoteCustomerType)} + options={[ + { value: 'PRIVAT', label: 'Privatkunde' }, + { value: 'FIRMA', label: 'Firmenkunde' }, + ]} + /> +
+ + {form.type === 'SACHWERT' && ( + set('sachwertDescription', e.target.value)} + placeholder="z.B. Smartphone Samsung Galaxy A55" + /> + )} + + {/* USt-Block */} +
+ + +
+ set('amount', e.target.value)} + placeholder="0,00" + /> + {form.vatRelevant && ( + <> + set('vatRate', e.target.value)} + /> + + )} +
+ +
+ Netto: {formatEuro(preview.net)} + USt: {formatEuro(preview.vat)} + Brutto: {formatEuro(preview.gross)} +
+
+ +
+ set('creditDate', e.target.value)} + /> + set('place', e.target.value)} + placeholder="z.B. Oldenburg" + /> +
+ +
+ set('signedAt', e.target.value)} + /> + {form.type === 'SACHWERT' && ( + + )} +
+ +
+ +