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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user