Files
opencrm/frontend/src/components/contracts/CreditNotesSection.tsx
T
duffyduckandClaude Opus 4.8 5047bfaab3 Gutschrift: nur mit Empfaengeradresse anlegbar (Rechnung > Liefer)
Eine Gutschrift/ein Lieferschein braucht eine Empfaengeradresse aufs
Dokument. Rechnungsadresse hat Vorrang, sonst Lieferadresse. Ist keine
von beiden hinterlegt -> Anlegen blockiert.

- Frontend: Klick auf 'Gutschrift anlegen' prueft defaults.hasRecipient-
  Address; wenn false -> Modal-OK-Meldung statt Formular.
- Backend Defense-in-Depth: createCreditNote wirft 400, wenn weder
  billingAddressId noch addressId gesetzt. getCreditNoteDefaults liefert
  hasRecipientAddress.

Verifiziert: ohne Adresse -> hasRecipientAddress false + create 400;
mit Adresse -> ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 09:39:04 +02:00

578 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Gift, Banknote, Package, Upload, Eye, X, Check, FileText } 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, CreditNotePayoutBankCard } 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];
// Betragsloser Sachwert = Lieferschein → zeigt die Lieferscheinnummer,
// sonst die Gutschriftsnummer.
function displayNumber(cn: CreditNote): string {
const nonMonetary = cn.type === 'SACHWERT' && cn.amountGross === 0;
return (nonMonetary ? cn.deliveryNoteNumber : cn.number) ?? `#${cn.id}`;
}
interface FormDefaults {
customerType: CreditNoteCustomerType;
vatRelevant: boolean;
bankCards: CreditNotePayoutBankCard[];
contractBankCardId: number | null;
}
interface FormState {
type: CreditNoteType;
sachwertDescription: string;
customerType: CreditNoteCustomerType;
vatRelevant: boolean;
amountBasis: CreditNoteAmountBasis;
vatRate: string;
amount: string;
creditDate: string;
place: string;
signedAt: string;
goodsReceived: boolean;
payoutBankCardId: string;
notes: string;
}
function emptyForm(defaults?: FormDefaults): FormState {
return {
type: 'GELD',
sachwertDescription: '',
customerType: defaults?.customerType ?? 'PRIVAT',
vatRelevant: defaults?.vatRelevant ?? false,
amountBasis: 'BRUTTO',
vatRate: '19',
amount: '',
creditDate: todayIso(),
place: '',
signedAt: '',
goodsReceived: false,
// Vorschlag: das Abbuchkonto des Vertrags (kann umgestellt werden).
payoutBankCardId: defaults?.contractBankCardId ? String(defaults.contractBankCardId) : '',
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,
payoutBankCardId: cn.payoutBankCardId ? String(cn.payoutBankCardId) : '',
notes: cn.notes ?? '',
};
}
// ---------- Formular-Modal ----------
function CreditNoteFormModal({
contractId,
editing,
defaults,
onClose,
onSaved,
}: {
contractId: number;
editing: CreditNote | null;
defaults?: FormDefaults;
onClose: () => void;
onSaved: () => void;
}) {
const [form, setForm] = useState<FormState>(editing ? formFromCreditNote(editing) : emptyForm(defaults));
const bankCards = defaults?.bankCards ?? [];
const set = <K extends keyof FormState>(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,
payoutBankCardId: form.type === 'GELD' && form.payoutBankCardId ? Number(form.payoutBankCardId) : null,
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 amountNum = Number(form.amount) || 0;
// Sachwert darf betragslos sein (reine Übergabe → keine Rechnung).
const isSachwert = form.type === 'SACHWERT';
const nonMonetary = isSachwert && amountNum <= 0;
const amountLabel = isSachwert
? 'Wert (optional)'
: form.vatRelevant
? `Betrag (${form.amountBasis === 'NETTO' ? 'netto' : 'brutto'})`
: 'Betrag';
const canSave = isSachwert
? form.sachwertDescription.trim().length > 0 // Betrag optional
: amountNum > 0; // Geld: Betrag > 0 Pflicht
return (
<Modal isOpen onClose={onClose} title={editing ? `Beleg ${displayNumber(editing)} bearbeiten` : 'Gutschrift anlegen'} size="lg">
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Art"
value={form.type}
onChange={(e) => set('type', e.target.value as CreditNoteType)}
options={[
{ value: 'GELD', label: 'Geld (Überweisung, EUR)' },
{ value: 'SACHWERT', label: 'Sachwert (Smartphone, Elektro …)' },
]}
/>
<Select
label="Kundentyp"
value={form.customerType}
onChange={(e) => set('customerType', e.target.value as CreditNoteCustomerType)}
options={[
{ value: 'PRIVAT', label: 'Privatkunde' },
{ value: 'FIRMA', label: 'Firmenkunde' },
]}
/>
</div>
{form.type === 'SACHWERT' && (
<Input
label="Was wird gewährt? (Sachwert)"
value={form.sachwertDescription}
onChange={(e) => set('sachwertDescription', e.target.value)}
placeholder="z.B. Smartphone Samsung Galaxy A55"
/>
)}
{/* Betrag + USt-Block */}
<div className="rounded-lg border border-gray-200 p-3 space-y-3 bg-gray-50">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Input
label={amountLabel}
type="number"
step="0.01"
value={form.amount}
onChange={(e) => set('amount', e.target.value)}
placeholder="0,00"
/>
{amountNum > 0 && form.vatRelevant && (
<>
<Select
label="Basis"
value={form.amountBasis}
onChange={(e) => set('amountBasis', e.target.value as CreditNoteAmountBasis)}
options={[
{ value: 'BRUTTO', label: 'Brutto (USt herausrechnen)' },
{ value: 'NETTO', label: 'Netto (USt aufschlagen)' },
]}
/>
<Input
label="USt-Satz (%)"
type="number"
step="0.1"
value={form.vatRate}
onChange={(e) => set('vatRate', e.target.value)}
/>
</>
)}
</div>
{nonMonetary ? (
<p className="text-sm text-gray-600">
Ohne Betrag: reine Sachwert-Übergabe <strong>keine Rechnung, keine USt</strong>. Es wird nur der Empfang des Gegenstands als Subvention dokumentiert.
</p>
) : (
<>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={form.vatRelevant}
onChange={(e) => set('vatRelevant', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
USt-relevant (mit Umsatzsteuer ausweisen)
</label>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
<span>Netto: <strong>{formatEuro(preview.net)}</strong></span>
<span>USt: <strong>{formatEuro(preview.vat)}</strong></span>
<span>Brutto: <strong>{formatEuro(preview.gross)}</strong></span>
</div>
</>
)}
</div>
{form.type === 'GELD' && (
bankCards.length > 0 ? (
<Select
label="Auszahlungskonto (Bankkonto des Kunden)"
value={form.payoutBankCardId}
onChange={(e) => set('payoutBankCardId', e.target.value)}
options={bankCards.map((bc) => ({
value: String(bc.id),
label: `${bc.iban} (${bc.accountHolder})${bc.description ? ` ${bc.description}` : ''}`,
}))}
placeholder="Kein Konto angeben"
/>
) : (
<p className="text-xs text-gray-500">
Für diesen Kunden sind keine Bankkonten hinterlegt Auszahlungskonto kann daher nicht gewählt werden.
</p>
)
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Datum der Gutschrift"
type="date"
value={form.creditDate}
onChange={(e) => set('creditDate', e.target.value)}
/>
{/* Ort/Unterschrift nur bei Sachwert Überweisung wird nicht unterschrieben. */}
{isSachwert && (
<Input
label="Ort (Unterschrift)"
value={form.place}
onChange={(e) => set('place', e.target.value)}
placeholder="z.B. Oldenburg"
/>
)}
</div>
{isSachwert && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Unterschrift am (optional)"
type="date"
value={form.signedAt}
onChange={(e) => set('signedAt', e.target.value)}
/>
<label className="flex items-center gap-2 text-sm mt-7">
<input
type="checkbox"
checked={form.goodsReceived}
onChange={(e) => set('goodsReceived', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Ware erhalten (Empfang bestätigt)
</label>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notiz (optional)</label>
<textarea
value={form.notes}
onChange={(e) => set('notes', e.target.value)}
rows={2}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{!editing && (
<p className="text-xs text-gray-500">
{isSachwert
? 'Das unterschriebene Dokument kann nach dem Speichern in der Liste hochgeladen werden.'
: 'Der Überweisungsbeleg kann nach dem Speichern in der Liste hochgeladen werden.'}
</p>
)}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={() => saveMutation.mutate()} disabled={!canSave || saveMutation.isPending}>
{saveMutation.isPending ? 'Speichern …' : 'Speichern'}
</Button>
</div>
</div>
</Modal>
);
}
// ---------- Beleg-Upload (nur GELD) ----------
function ReceiptControls({ cn, canEdit, onChanged, label = 'Beleg' }: { cn: CreditNote; canEdit: boolean; onChanged: () => void; label?: string }) {
const fileRef = useRef<HTMLInputElement>(null);
const uploadMutation = useMutation({
mutationFn: (file: File) => creditNoteApi.uploadReceipt(cn.id, file),
onSuccess: () => { toast.success('Beleg hochgeladen'); onChanged(); },
onError: (err: Error) => toast.error(err.message || 'Upload fehlgeschlagen'),
});
const removeMutation = useMutation({
mutationFn: () => creditNoteApi.deleteReceipt(cn.id),
onSuccess: () => { toast.success('Beleg entfernt'); onChanged(); },
onError: (err: Error) => toast.error(err.message || 'Löschen fehlgeschlagen'),
});
if (cn.receiptPath) {
return (
<span className="inline-flex items-center gap-2 text-xs">
<a href={fileUrl(cn.receiptPath, { inline: true })} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
<Eye className="w-3.5 h-3.5" /> {label}
</a>
{canEdit && (
<button onClick={() => removeMutation.mutate()} className="text-gray-400 hover:text-red-600" title={`${label} entfernen`}>
<X className="w-3.5 h-3.5" />
</button>
)}
</span>
);
}
if (!canEdit) return <span className="text-xs text-gray-400">kein {label}</span>;
return (
<>
<input
ref={fileRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadMutation.mutate(f); e.target.value = ''; }}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={uploadMutation.isPending}
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<Upload className="w-3.5 h-3.5" /> {uploadMutation.isPending ? 'lädt …' : label}
</button>
</>
);
}
// ---------- Section ----------
export default function CreditNotesSection({ contractId, canEdit }: { contractId: number; canEdit: boolean }) {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [showNoAddress, setShowNoAddress] = useState(false);
const [editing, setEditing] = useState<CreditNote | null>(null);
const { data: listRes } = useQuery({
queryKey: ['credit-notes', contractId],
queryFn: () => creditNoteApi.listByContract(contractId),
});
const { data: defaultsRes } = useQuery({
queryKey: ['credit-notes-defaults', contractId],
queryFn: () => creditNoteApi.getDefaults(contractId),
});
const creditNotes = listRes?.data ?? [];
const defaults = defaultsRes?.data;
const refresh = () => queryClient.invalidateQueries({ queryKey: ['credit-notes', contractId] });
const deleteMutation = useMutation({
mutationFn: (id: number) => creditNoteApi.remove(id),
onSuccess: () => { toast.success('Gutschrift gelöscht'); refresh(); },
onError: (err: Error) => toast.error(err.message || 'Löschen fehlgeschlagen'),
});
const pdfMutation = useMutation({
mutationFn: (id: number) => creditNoteApi.generatePdf(id),
onSuccess: (res) => {
toast.success('PDF erzeugt');
refresh();
if (res.data?.pdfPath) window.open(fileUrl(res.data.pdfPath, { inline: true }), '_blank', 'noopener');
},
onError: (err: Error) => toast.error(err.message || 'PDF fehlgeschlagen'),
});
const openCreate = () => {
// Ohne Empfängeradresse (Rechnungs- ODER Lieferadresse) kein Beleg möglich.
if (defaults && !defaults.hasRecipientAddress) {
setShowNoAddress(true);
return;
}
setEditing(null);
setShowForm(true);
};
const openEdit = (cn: CreditNote) => { setEditing(cn); setShowForm(true); };
const totalGross = creditNotes.reduce((sum, cn) => sum + (cn.amountGross || 0), 0);
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Gift className="w-5 h-5 text-gray-500" />
Gutschriften
{creditNotes.length > 0 && (
<span className="text-sm font-normal text-gray-500">
({creditNotes.length} · Summe {formatEuro(totalGross)})
</span>
)}
</h2>
{canEdit && (
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-2" /> Gutschrift anlegen
</Button>
)}
</div>
{creditNotes.length === 0 ? (
<p className="text-sm text-gray-400 italic">Noch keine Gutschriften.</p>
) : (
<div className="divide-y">
{creditNotes.map((cn) => (
<div key={cn.id} className="py-3 flex items-start gap-3">
<div className="mt-0.5 text-gray-400">
{cn.type === 'GELD' ? <Banknote className="w-5 h-5" /> : <Package className="w-5 h-5" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-medium">{displayNumber(cn)}</span>
<Badge variant={cn.type === 'GELD' ? 'info' : 'default'}>
{cn.type === 'GELD' ? 'Geld' : 'Sachwert'}
</Badge>
{!(cn.type === 'SACHWERT' && cn.amountGross === 0) && (
cn.vatRelevant ? (
<Badge variant="default">USt {cn.vatRate}%</Badge>
) : (
<Badge variant="default">ohne USt</Badge>
)
)}
<Badge variant="default">{cn.customerType === 'FIRMA' ? 'Firma' : 'Privat'}</Badge>
{cn.type === 'SACHWERT' && cn.goodsReceived && (
<span className="inline-flex items-center gap-1 text-xs text-green-700">
<Check className="w-3.5 h-3.5" /> Ware erhalten
</span>
)}
</div>
<div className="text-sm text-gray-700 mt-1">
{cn.type === 'SACHWERT' && cn.amountGross === 0 ? (
<span className="italic text-gray-600">Sachwert ohne Betrag (keine Rechnung)</span>
) : (
<>
<strong>{formatEuro(cn.amountGross, cn.currency)}</strong>
{cn.vatRelevant && (
<span className="text-gray-500"> (netto {formatEuro(cn.amountNet, cn.currency)} + USt {formatEuro(cn.amountVat, cn.currency)})</span>
)}
</>
)}
<span className="text-gray-500"> · {formatDate(cn.creditDate)}</span>
{cn.place && <span className="text-gray-500"> · {cn.place}</span>}
</div>
{cn.type === 'SACHWERT' && cn.sachwertDescription && (
<div className="text-sm text-gray-600 mt-0.5">{cn.sachwertDescription}</div>
)}
{cn.notes && <div className="text-xs text-gray-500 mt-0.5 italic">{cn.notes}</div>}
{cn.type === 'GELD' && cn.payoutBankCardId && (() => {
const card = defaults?.bankCards.find((bc) => bc.id === cn.payoutBankCardId);
return card ? (
<div className="text-xs text-gray-500 mt-0.5">an Bankkonto: <span className="font-mono">{card.iban}</span> ({card.accountHolder})</div>
) : null;
})()}
<div className="mt-1">
<ReceiptControls
cn={cn}
canEdit={canEdit}
onChanged={refresh}
label={cn.type === 'GELD' ? 'Überweisungsbeleg' : 'Unterschr. Dokument'}
/>
</div>
</div>
{canEdit && (
<div className="flex items-center gap-1">
{cn.pdfPath ? (
<a href={fileUrl(cn.pdfPath, { inline: true })} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-blue-600 p-1" title="PDF (ZUGFeRD) ansehen">
<FileText className="w-4 h-4" />
</a>
) : (
<button onClick={() => pdfMutation.mutate(cn.id)} disabled={pdfMutation.isPending} className="text-gray-400 hover:text-blue-600 p-1" title="PDF (ZUGFeRD) erzeugen">
<FileText className="w-4 h-4" />
</button>
)}
<button onClick={() => openEdit(cn)} className="text-gray-400 hover:text-blue-600 p-1" title="Bearbeiten">
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => { if (confirm(`Beleg ${displayNumber(cn)} wirklich löschen?`)) deleteMutation.mutate(cn.id); }}
className="text-gray-400 hover:text-red-600 p-1"
title="Löschen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
))}
</div>
)}
{showForm && (
<CreditNoteFormModal
contractId={contractId}
editing={editing}
defaults={defaults}
onClose={() => setShowForm(false)}
onSaved={() => { setShowForm(false); refresh(); }}
/>
)}
{showNoAddress && (
<Modal isOpen onClose={() => setShowNoAddress(false)} title="Keine Adresse hinterlegt" size="sm">
<div className="space-y-4">
<p className="text-sm text-gray-700">
Für diesen Vertrag ist weder eine Rechnungs- noch eine Lieferadresse hinterlegt.
Ohne Empfängeradresse kann keine Gutschrift/kein Lieferschein erstellt werden.
Bitte ordne dem Vertrag zuerst eine Adresse zu.
</p>
<div className="flex justify-end">
<Button onClick={() => setShowNoAddress(false)}>OK</Button>
</div>
</div>
</Modal>
)}
</div>
);
}