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