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,119 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Hash } from 'lucide-react';
import toast from 'react-hot-toast';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import { creditNoteApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
export default function CreditNoteNumberRange() {
const { hasPermission } = useAuth();
const canEdit = hasPermission('settings:update');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['credit-note-number-range'],
queryFn: () => creditNoteApi.getNumberRange(),
});
const range = data?.data;
const [prefix, setPrefix] = useState('GS-');
const [includeYear, setIncludeYear] = useState(true);
const [separator, setSeparator] = useState('-');
const [padding, setPadding] = useState(4);
const [nextNumber, setNextNumber] = useState(1);
const [resetYearly, setResetYearly] = useState(true);
useEffect(() => {
if (range) {
setPrefix(range.prefix);
setIncludeYear(range.includeYear);
setSeparator(range.separator);
setPadding(range.padding);
setNextNumber(range.nextNumber);
setResetYearly(range.resetYearly);
}
}, [range]);
const saveMutation = useMutation({
mutationFn: () => creditNoteApi.updateNumberRange({ prefix, includeYear, separator, padding, nextNumber, resetYearly }),
onSuccess: () => {
toast.success('Nummernkreis gespeichert');
queryClient.invalidateQueries({ queryKey: ['credit-note-number-range'] });
},
onError: (err: Error) => toast.error(err.message || 'Speichern fehlgeschlagen'),
});
// Live-Vorschau der nächsten Nummer aus den aktuellen Eingaben.
const year = new Date().getFullYear();
const previewValue =
resetYearly && range && range.currentYear !== year ? 1 : nextNumber;
const preview = `${prefix}${includeYear ? `${year}${separator}` : ''}${String(previewValue).padStart(padding, '0')}`;
if (isLoading) {
return <div className="p-6 text-gray-500">Laden </div>;
}
return (
<div className="max-w-2xl">
<Link to="/settings" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4" /> Einstellungen
</Link>
<h1 className="text-2xl font-bold flex items-center gap-2 mb-1">
<Hash className="w-6 h-6 text-blue-600" /> Gutschrift-Nummernkreis
</h1>
<p className="text-gray-500 mb-6">Fortlaufende Nummerierung für Gutschriften.</p>
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Präfix" value={prefix} onChange={(e) => setPrefix(e.target.value)} disabled={!canEdit} placeholder="GS-" />
<Input label="Trennzeichen (vor der Nummer)" value={separator} onChange={(e) => setSeparator(e.target.value)} disabled={!canEdit} placeholder="-" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Stellen (Nullen auffüllen)"
type="number"
min={1}
max={10}
value={padding}
onChange={(e) => setPadding(Math.min(Math.max(parseInt(e.target.value) || 1, 1), 10))}
disabled={!canEdit}
/>
<Input
label="Nächste Nummer"
type="number"
min={1}
value={nextNumber}
onChange={(e) => setNextNumber(Math.max(parseInt(e.target.value) || 1, 1))}
disabled={!canEdit}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={includeYear} onChange={(e) => setIncludeYear(e.target.checked)} disabled={!canEdit} className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
Jahr in die Nummer aufnehmen (z.B. {prefix}{year}{separator}0001)
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={resetYearly} onChange={(e) => setResetYearly(e.target.checked)} disabled={!canEdit} className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
Zähler zum Jahreswechsel auf 1 zurücksetzen
</label>
<div className="rounded-lg bg-blue-50 border border-blue-200 px-3 py-2 text-sm">
Nächste Nummer: <span className="font-mono font-semibold">{preview}</span>
</div>
{canEdit && (
<div className="flex justify-end">
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Speichern …' : 'Speichern'}
</Button>
</div>
)}
</div>
</div>
);
}