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>
578 lines
23 KiB
TypeScript
578 lines
23 KiB
TypeScript
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>
|
||
);
|
||
}
|