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
+83
View File
@@ -946,4 +946,87 @@ router.delete(
}
);
// Upload für Gutschrift-Überweisungsbeleg. Interner Bereich → Portal blocken.
router.post(
'/credit-notes/:id/receipt',
authenticate,
requirePermission('contracts:update'),
setUploadDir('credit-note-receipts'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (req.user?.isCustomerPortal) {
cleanupFile(req.file?.path);
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return;
}
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const id = parseInt(req.params.id);
const cn = await prisma.creditNote.findUnique({
where: { id },
select: { contractId: true, receiptPath: true },
});
if (!cn) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
return;
}
if (!(await canAccessContract(req, res, cn.contractId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/credit-note-receipts/${req.file.filename}`;
if (cn.receiptPath) cleanupFile(path.join(process.cwd(), cn.receiptPath));
await prisma.creditNote.update({ where: { id }, data: { receiptPath: relativePath } });
await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
label: 'Überweisungsbeleg zur Gutschrift hochgeladen',
});
res.json({ success: true, data: { receiptPath: relativePath } });
} catch (error) {
console.error('Credit-note receipt upload error:', error);
cleanupFile(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
);
router.delete(
'/credit-notes/:id/receipt',
authenticate,
requirePermission('contracts:update'),
async (req: AuthRequest, res: Response) => {
try {
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return;
}
const id = parseInt(req.params.id);
const cn = await prisma.creditNote.findUnique({
where: { id },
select: { contractId: true, receiptPath: true },
});
if (!cn) {
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
return;
}
if (!(await canAccessContract(req, res, cn.contractId))) return;
if (cn.receiptPath) cleanupFile(path.join(process.cwd(), cn.receiptPath));
await prisma.creditNote.update({ where: { id }, data: { receiptPath: null } });
await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
label: 'Überweisungsbeleg zur Gutschrift entfernt',
});
res.json({ success: true });
} catch (error) {
console.error('Credit-note receipt delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
);
export default router;
@@ -93,6 +93,19 @@ export async function findUploadOwner(uploadPath: string): Promise<FileOwner | n
return r?.contractId ? { kind: 'contract', contractId: r.contractId } : null;
}
case 'credit-note-receipts':
case 'credit-notes': {
// Überweisungsbeleg bzw. generiertes Gutschrift-PDF Owner ist der
// Vertrag der Gutschrift.
const r = await prisma.creditNote.findFirst({
where: subDir === 'credit-note-receipts'
? { receiptPath: uploadPath }
: { pdfPath: uploadPath },
select: { contractId: true },
});
return r ? { kind: 'contract', contractId: r.contractId } : null;
}
case 'cancellation-letters':
case 'cancellation-confirmations':
case 'cancellation-letters-options':
+11 -4
View File
@@ -121,10 +121,17 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
wird abgeleitet: Firmenkunde ohne USt-Befreiung → USt-relevant an, sonst
aus. Jede Gutschrift speichert ihren Snapshot → späterer Statuswechsel
ändert bestehende Gutschriften nicht. USt-Default „aus Kundentyp ableiten".
- **Offen:** Phase 2b (Vertrag-Tab „Gutschriften" + Beleg-Upload + Nummernkreis-UI),
Phase 3 (PDF + ZUGFeRD/PDF-A3, muss gegen ZUGFeRD-Validator geprüft
werden). USt-Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater
klären Modell deckt beide über `vatRelevant` ab.
- **Phase 2b (erledigt):** `CreditNotesSection` im Vertragsdetail (eigene
Section wie Rechnungen die Vertragsansicht ist kartenbasiert, kein
echter Tab-Balken; ggf. später umbaubar): Liste + Anlegen/Bearbeiten
(Modal, Geld/Sachwert, USt-Block mit Live-Netto/USt/Brutto, Datum, Ort,
Unterschrift, „Ware erhalten"), Überweisungsbeleg-Upload (nur GELD, über
`/upload/credit-notes/:id/receipt`, fileDownload-Ownership ergänzt).
Nummernkreis-Verwaltung als Settings-Seite `/settings/credit-note-number-
range` mit Live-Vorschau. Frontend-Typen + `creditNoteApi`.
- **Offen:** Phase 3 (PDF + ZUGFeRD/PDF-A3, muss gegen ZUGFeRD-Validator
geprüft werden). USt-Einordnung (Vermittlung vs. Abschlussbonus) mit
Steuerberater klären Modell deckt beide über `vatRelevant` ab.
- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
- „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
+2
View File
@@ -23,6 +23,7 @@ import CancellationPeriodList from './pages/settings/CancellationPeriodList';
import ContractDurationList from './pages/settings/ContractDurationList';
import ProviderList from './pages/settings/ProviderList';
import ContractCategoryList from './pages/settings/ContractCategoryList';
import CreditNoteNumberRange from './pages/settings/CreditNoteNumberRange';
import ViewSettings from './pages/settings/ViewSettings';
import PortalSettings from './pages/settings/PortalSettings';
import DeadlineSettings from './pages/settings/DeadlineSettings';
@@ -222,6 +223,7 @@ function App() {
<Route path="settings/contract-durations" element={<ContractDurationList />} />
<Route path="settings/providers" element={<ProviderList />} />
<Route path="settings/contract-categories" element={<ContractCategoryList />} />
<Route path="settings/credit-note-number-range" element={<CreditNoteNumberRange />} />
<Route path="settings/view" element={<ViewSettings />} />
<Route path="settings/portal" element={<PortalSettings />} />
<Route path="settings/deadlines" element={<DeadlineSettings />} />
@@ -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>
);
}
+8 -1
View File
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Card from '../components/ui/Card';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck } from 'lucide-react';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck, Hash } from 'lucide-react';
export default function Settings() {
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
@@ -49,6 +49,13 @@ export default function Settings() {
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
show: hasPermission('platforms:read'),
},
{
to: '/settings/credit-note-number-range',
icon: Hash,
title: 'Gutschrift-Nummernkreis',
description: 'Präfix, Jahr und Zähler für die fortlaufende Nummerierung von Gutschriften.',
show: hasPermission('settings:read'),
},
];
return (
@@ -6,6 +6,7 @@ import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdpr
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection';
import CreditNotesSection from '../../components/contracts/CreditNotesSection';
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
@@ -3325,6 +3326,14 @@ export default function ContractDetail() {
</Card>
)}
{/* Gutschriften (Subventionen) nur Mitarbeiter/Admin */}
{!isCustomerPortal && hasPermission('contracts:read') && (
<CreditNotesSection
contractId={contractId}
canEdit={hasPermission('contracts:update')}
/>
)}
{/* Vertragsdokumente */}
{!isCustomerPortal && (
<ContractDocumentsSection
@@ -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>
);
}
+49 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, CustomerReferrals, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, CustomerReferrals, CreditNote, CreditNoteDefaults, CreditNoteNumberRange, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types';
// ============================================================================
// In-Memory-Token-Store
@@ -263,6 +263,54 @@ export const referralApi = {
},
};
// Gutschriften (Subventionen am Vertrag)
export const creditNoteApi = {
listByContract: async (contractId: number) => {
const res = await api.get<ApiResponse<CreditNote[]>>(`/contracts/${contractId}/credit-notes`);
return res.data;
},
getDefaults: async (contractId: number) => {
const res = await api.get<ApiResponse<CreditNoteDefaults>>(`/contracts/${contractId}/credit-notes/defaults`);
return res.data;
},
create: async (contractId: number, payload: Record<string, unknown>) => {
const res = await api.post<ApiResponse<CreditNote>>(`/contracts/${contractId}/credit-notes`, payload);
return res.data;
},
get: async (id: number) => {
const res = await api.get<ApiResponse<CreditNote>>(`/credit-notes/${id}`);
return res.data;
},
update: async (id: number, payload: Record<string, unknown>) => {
const res = await api.put<ApiResponse<CreditNote>>(`/credit-notes/${id}`, payload);
return res.data;
},
remove: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/credit-notes/${id}`);
return res.data;
},
getNumberRange: async () => {
const res = await api.get<ApiResponse<CreditNoteNumberRange>>(`/credit-notes/number-range`);
return res.data;
},
updateNumberRange: async (payload: Partial<CreditNoteNumberRange>) => {
const res = await api.put<ApiResponse<CreditNoteNumberRange>>(`/credit-notes/number-range`, payload);
return res.data;
},
uploadReceipt: async (id: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ receiptPath: string }>>(`/upload/credit-notes/${id}/receipt`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
deleteReceipt: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/credit-notes/${id}/receipt`);
return res.data;
},
};
// Addresses
export const addressApi = {
getByCustomer: async (customerId: number) => {
+50
View File
@@ -57,6 +57,56 @@ export interface CustomerReferrals {
recruited: ReferralEntry[];
}
// ==================== GUTSCHRIFTEN (CREDIT NOTES) ====================
export type CreditNoteType = 'GELD' | 'SACHWERT';
export type CreditNoteCustomerType = 'PRIVAT' | 'FIRMA';
export type CreditNoteAmountBasis = 'NETTO' | 'BRUTTO';
export interface CreditNote {
id: number;
contractId: number;
number: string;
type: CreditNoteType;
sachwertDescription?: string | null;
customerType: CreditNoteCustomerType;
vatRelevant: boolean;
amountBasis: CreditNoteAmountBasis;
vatRate: number;
amountNet: number;
amountVat: number;
amountGross: number;
currency: string;
creditDate: string;
place?: string | null;
signedAt?: string | null;
goodsReceived: boolean;
receiptPath?: string | null;
pdfPath?: string | null;
notes?: string | null;
createdBy?: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreditNoteDefaults {
customerType: CreditNoteCustomerType;
vatRelevant: boolean;
nextNumber: string;
}
export interface CreditNoteNumberRange {
id: number;
prefix: string;
includeYear: boolean;
separator: string;
padding: number;
nextNumber: number;
resetYearly: boolean;
currentYear: number | null;
preview?: string;
}
export interface RepresentativeAuthorization {
id: number;
customerId: number;