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
@@ -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<FormState>(editing ? formFromCreditNote(editing) : emptyForm(defaults));
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,
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 (
<Modal isOpen onClose={onClose} title={editing ? `Gutschrift ${editing.number} 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"
/>
)}
{/* USt-Block */}
<div className="rounded-lg border border-gray-200 p-3 space-y-3 bg-gray-50">
<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="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"
/>
{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>
<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>
<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)}
/>
<Input
label="Ort (Unterschrift)"
value={form.place}
onChange={(e) => set('place', e.target.value)}
placeholder="z.B. Oldenburg"
/>
</div>
<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)}
/>
{form.type === 'SACHWERT' && (
<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>
{form.type === 'GELD' && !editing && (
<p className="text-xs text-gray-500">
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 }: { cn: CreditNote; canEdit: boolean; onChanged: () => void }) {
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" /> Beleg
</a>
{canEdit && (
<button onClick={() => removeMutation.mutate()} className="text-gray-400 hover:text-red-600" title="Beleg entfernen">
<X className="w-3.5 h-3.5" />
</button>
)}
</span>
);
}
if (!canEdit) return <span className="text-xs text-gray-400">kein Beleg</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 …' : 'Beleg'}
</button>
</>
);
}
// ---------- Section ----------
export default function CreditNotesSection({ contractId, canEdit }: { contractId: number; canEdit: boolean }) {
const queryClient = useQueryClient();
const [showForm, setShowForm] = 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 openCreate = () => { 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">{cn.number}</span>
<Badge variant={cn.type === 'GELD' ? 'info' : 'default'}>
{cn.type === 'GELD' ? 'Geld' : 'Sachwert'}
</Badge>
{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">
<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' && (
<div className="mt-1">
<ReceiptControls cn={cn} canEdit={canEdit} onChanged={refresh} />
</div>
)}
</div>
{canEdit && (
<div className="flex items-center gap-1">
<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(`Gutschrift ${cn.number} 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(); }}
/>
)}
</div>
);
}