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;
|
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;
|
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-letters':
|
||||||
case 'cancellation-confirmations':
|
case 'cancellation-confirmations':
|
||||||
case 'cancellation-letters-options':
|
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
|
wird abgeleitet: Firmenkunde ohne USt-Befreiung → USt-relevant an, sonst
|
||||||
aus. Jede Gutschrift speichert ihren Snapshot → späterer Statuswechsel
|
aus. Jede Gutschrift speichert ihren Snapshot → späterer Statuswechsel
|
||||||
ändert bestehende Gutschriften nicht. USt-Default „aus Kundentyp ableiten".
|
ändert bestehende Gutschriften nicht. USt-Default „aus Kundentyp ableiten".
|
||||||
- **Offen:** Phase 2b (Vertrag-Tab „Gutschriften" + Beleg-Upload + Nummernkreis-UI),
|
- **Phase 2b (erledigt):** `CreditNotesSection` im Vertragsdetail (eigene
|
||||||
Phase 3 (PDF + ZUGFeRD/PDF-A3, muss gegen ZUGFeRD-Validator geprüft
|
Section wie Rechnungen – die Vertragsansicht ist kartenbasiert, kein
|
||||||
werden). USt-Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater
|
echter Tab-Balken; ggf. später umbaubar): Liste + Anlegen/Bearbeiten
|
||||||
klären – Modell deckt beide über `vatRelevant` ab.
|
(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)
|
- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
|
||||||
- „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
|
- „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 ContractDurationList from './pages/settings/ContractDurationList';
|
||||||
import ProviderList from './pages/settings/ProviderList';
|
import ProviderList from './pages/settings/ProviderList';
|
||||||
import ContractCategoryList from './pages/settings/ContractCategoryList';
|
import ContractCategoryList from './pages/settings/ContractCategoryList';
|
||||||
|
import CreditNoteNumberRange from './pages/settings/CreditNoteNumberRange';
|
||||||
import ViewSettings from './pages/settings/ViewSettings';
|
import ViewSettings from './pages/settings/ViewSettings';
|
||||||
import PortalSettings from './pages/settings/PortalSettings';
|
import PortalSettings from './pages/settings/PortalSettings';
|
||||||
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
||||||
@@ -222,6 +223,7 @@ function App() {
|
|||||||
<Route path="settings/contract-durations" element={<ContractDurationList />} />
|
<Route path="settings/contract-durations" element={<ContractDurationList />} />
|
||||||
<Route path="settings/providers" element={<ProviderList />} />
|
<Route path="settings/providers" element={<ProviderList />} />
|
||||||
<Route path="settings/contract-categories" element={<ContractCategoryList />} />
|
<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/view" element={<ViewSettings />} />
|
||||||
<Route path="settings/portal" element={<PortalSettings />} />
|
<Route path="settings/portal" element={<PortalSettings />} />
|
||||||
<Route path="settings/deadlines" element={<DeadlineSettings />} />
|
<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 { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Card from '../components/ui/Card';
|
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() {
|
export default function Settings() {
|
||||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||||
@@ -49,6 +49,13 @@ export default function Settings() {
|
|||||||
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
|
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
|
||||||
show: hasPermission('platforms:read'),
|
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 (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdpr
|
|||||||
import { ContractEmailsSection } from '../../components/email';
|
import { ContractEmailsSection } from '../../components/email';
|
||||||
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
|
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
|
||||||
import InvoicesSection from '../../components/contracts/InvoicesSection';
|
import InvoicesSection from '../../components/contracts/InvoicesSection';
|
||||||
|
import CreditNotesSection from '../../components/contracts/CreditNotesSection';
|
||||||
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
@@ -3325,6 +3326,14 @@ export default function ContractDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Gutschriften (Subventionen) – nur Mitarbeiter/Admin */}
|
||||||
|
{!isCustomerPortal && hasPermission('contracts:read') && (
|
||||||
|
<CreditNotesSection
|
||||||
|
contractId={contractId}
|
||||||
|
canEdit={hasPermission('contracts:update')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Vertragsdokumente */}
|
{/* Vertragsdokumente */}
|
||||||
{!isCustomerPortal && (
|
{!isCustomerPortal && (
|
||||||
<ContractDocumentsSection
|
<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 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
|
// 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
|
// Addresses
|
||||||
export const addressApi = {
|
export const addressApi = {
|
||||||
getByCustomer: async (customerId: number) => {
|
getByCustomer: async (customerId: number) => {
|
||||||
|
|||||||
@@ -57,6 +57,56 @@ export interface CustomerReferrals {
|
|||||||
recruited: ReferralEntry[];
|
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 {
|
export interface RepresentativeAuthorization {
|
||||||
id: number;
|
id: number;
|
||||||
customerId: number;
|
customerId: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user