diff --git a/backend/src/controllers/buchhaltung.controller.ts b/backend/src/controllers/buchhaltung.controller.ts index 33b03b7..380a396 100644 --- a/backend/src/controllers/buchhaltung.controller.ts +++ b/backend/src/controllers/buchhaltung.controller.ts @@ -486,6 +486,33 @@ class BuchhaltungController { } } + // ── Erstattungen ──────────────────────────────────────────────────────────── + + async createErstattung(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createErstattung({ + ...req.body, + erstellt_von: req.user!.id, + }); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createErstattung', { error }); + res.status(500).json({ success: false, message: 'Erstattung konnte nicht erstellt werden' }); + } + } + + async getErstattungLinks(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.getErstattungLinks(id); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.getErstattungLinks', { error }); + res.status(500).json({ success: false, message: 'Erstattungsverknüpfungen konnten nicht geladen werden' }); + } + } + // ── Freigaben ──────────────────────────────────────────────────────────────── async requestFreigabe(req: Request, res: Response): Promise { diff --git a/backend/src/database/migrations/080_buchhaltung_budget_typ_and_erstattungen.sql b/backend/src/database/migrations/080_buchhaltung_budget_typ_and_erstattungen.sql new file mode 100644 index 0000000..44d81e9 --- /dev/null +++ b/backend/src/database/migrations/080_buchhaltung_budget_typ_and_erstattungen.sql @@ -0,0 +1,13 @@ +-- Add budget type support to buchhaltung_konten +ALTER TABLE buchhaltung_konten + ADD COLUMN IF NOT EXISTS budget_typ TEXT NOT NULL DEFAULT 'detailliert'; + +ALTER TABLE buchhaltung_konten + ADD COLUMN IF NOT EXISTS budget_gesamt NUMERIC(12,2) NOT NULL DEFAULT 0; + +-- Erstattung (reimbursement) linking table +CREATE TABLE IF NOT EXISTS buchhaltung_erstattung_zuordnungen ( + erstattung_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE, + ausgabe_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE, + PRIMARY KEY (erstattung_transaktion_id, ausgabe_transaktion_id) +); diff --git a/backend/src/routes/buchhaltung.routes.ts b/backend/src/routes/buchhaltung.routes.ts index 73afdfb..d9274d8 100644 --- a/backend/src/routes/buchhaltung.routes.ts +++ b/backend/src/routes/buchhaltung.routes.ts @@ -53,6 +53,10 @@ router.post('/wiederkehrend', authenticate, requirePermission('buchhaltung router.patch('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateWiederkehrend.bind(buchhaltungController)); router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteWiederkehrend.bind(buchhaltungController)); +// ── Erstattungen ────────────────────────────────────────────────────────────── +router.post('/erstattungen', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createErstattung.bind(buchhaltungController)); +router.get('/transaktionen/:id/erstattung-links', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getErstattungLinks.bind(buchhaltungController)); + // ── CSV Export ───────────────────────────────────────────────────────────────── router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController)); diff --git a/backend/src/services/buchhaltung.service.ts b/backend/src/services/buchhaltung.service.ts index c2a985b..2810a30 100644 --- a/backend/src/services/buchhaltung.service.ts +++ b/backend/src/services/buchhaltung.service.ts @@ -471,18 +471,28 @@ async function validateSubPotBudget( } async function createKonto( - data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string }, + data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number }, userId: string ) { try { if (data.parent_id && (data.budget_gwg || data.budget_anlagen || data.budget_instandhaltung)) { await validateSubPotBudget(data.parent_id, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0); } + + // Child konten inherit parent's budget_typ + let budgetTyp = data.budget_typ || 'detailliert'; + if (data.parent_id) { + const parentRow = await pool.query(`SELECT budget_typ FROM buchhaltung_konten WHERE id = $1`, [data.parent_id]); + if (parentRow.rows[0]) { + budgetTyp = parentRow.rows[0].budget_typ; + } + } + const result = await pool.query( - `INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von, kategorie_id, budget_typ, budget_gesamt) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, - [data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.parent_id || null, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, userId] + [data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.parent_id || null, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, userId, data.kategorie_id ?? null, budgetTyp, data.budget_gesamt || 0] ); return result.rows[0]; } catch (error: any) { @@ -497,7 +507,7 @@ async function createKonto( async function updateKonto( id: number, - data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string } + data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number } ) { try { // Budget validation for sub-pots @@ -527,7 +537,27 @@ async function updateKonto( if (data.budget_anlagen !== undefined) { fields.push(`budget_anlagen = $${idx++}`); values.push(data.budget_anlagen); } if (data.budget_instandhaltung !== undefined) { fields.push(`budget_instandhaltung = $${idx++}`); values.push(data.budget_instandhaltung); } if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); } + if ('kategorie_id' in data) { fields.push(`kategorie_id = $${idx++}`); values.push(data.kategorie_id ?? null); } + if (data.budget_typ !== undefined) { fields.push(`budget_typ = $${idx++}`); values.push(data.budget_typ); } + if (data.budget_gesamt !== undefined) { fields.push(`budget_gesamt = $${idx++}`); values.push(data.budget_gesamt); } if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); + + // Child konten must inherit parent's budget_typ + if (data.budget_typ !== undefined) { + const currentRow = await pool.query(`SELECT parent_id FROM buchhaltung_konten WHERE id = $1`, [id]); + const parentId = data.parent_id !== undefined ? data.parent_id : currentRow.rows[0]?.parent_id; + if (parentId) { + const parentRow = await pool.query(`SELECT budget_typ FROM buchhaltung_konten WHERE id = $1`, [parentId]); + if (parentRow.rows[0]) { + // Override budget_typ with parent's value + const btIdx = fields.findIndex(f => f.startsWith('budget_typ')); + if (btIdx !== -1) { + values[btIdx] = parentRow.rows[0].budget_typ; + } + } + } + } + values.push(id); const result = await pool.query( `UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, @@ -1231,6 +1261,94 @@ async function exportTransaktionenCsv(haushaltsjahrId: number): Promise } } +// --------------------------------------------------------------------------- +// Erstattungen (Reimbursements) +// --------------------------------------------------------------------------- + +interface Transaktion { + id: number; + [key: string]: unknown; +} + +async function createErstattung(data: { + konto_id: number; + bankkonto_id: number; + betrag: number; + datum: string; + beschreibung?: string; + empfaenger_auftraggeber?: string; + verwendungszweck?: string; + ausgabe_ids: number[]; + erstellt_von?: number; +}): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Look up haushaltsjahr_id from the konto + const kontoResult = await client.query( + `SELECT haushaltsjahr_id FROM buchhaltung_konten WHERE id = $1`, + [data.konto_id] + ); + const haushaltsjahrId = kontoResult.rows[0]?.haushaltsjahr_id; + + const txResult = await client.query( + `INSERT INTO buchhaltung_transaktionen (typ, konto_id, bankkonto_id, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, erstellt_von, haushaltsjahr_id, status) + VALUES ('einnahme', $1, $2, $3, $4, $5, $6, $7, $8, $9, 'entwurf') + RETURNING *`, + [data.konto_id, data.bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.empfaenger_auftraggeber || null, data.verwendungszweck || null, data.erstellt_von || null, haushaltsjahrId] + ); + const tx = txResult.rows[0]; + + for (const ausgabeId of data.ausgabe_ids) { + await client.query( + `INSERT INTO buchhaltung_erstattung_zuordnungen (erstattung_transaktion_id, ausgabe_transaktion_id) VALUES ($1, $2)`, + [tx.id, ausgabeId] + ); + } + + await client.query('COMMIT'); + return tx; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('BuchhaltungService.createErstattung failed', { error }); + throw new Error('Erstattung konnte nicht erstellt werden'); + } finally { + client.release(); + } +} + +async function getErstattungLinks(transaktionId: number): Promise<{ + erstattung?: any; + ausgaben?: any[]; +}> { + try { + // If this transaction is an Ausgabe, find its reimbursement + const erstattungResult = await pool.query( + `SELECT t.* FROM buchhaltung_transaktionen t + JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.erstattung_transaktion_id + WHERE ez.ausgabe_transaktion_id = $1`, + [transaktionId] + ); + + // If this transaction is an Erstattung, find its linked Ausgaben + const ausgabenResult = await pool.query( + `SELECT t.* FROM buchhaltung_transaktionen t + JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.ausgabe_transaktion_id + WHERE ez.erstattung_transaktion_id = $1`, + [transaktionId] + ); + + return { + erstattung: erstattungResult.rows[0] || undefined, + ausgaben: ausgabenResult.rows, + }; + } catch (error) { + logger.error('BuchhaltungService.getErstattungLinks failed', { error, transaktionId }); + throw new Error('Erstattungsverknüpfungen konnten nicht geladen werden'); + } +} + // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- @@ -1286,6 +1404,8 @@ const buchhaltungService = { updateWiederkehrend, deleteWiederkehrend, exportTransaktionenCsv, + createErstattung, + getErstattungLinks, }; export default buchhaltungService; diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index b904c50..bf79667 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef } from 'react'; import { + Alert, Box, Typography, Paper, @@ -205,6 +206,7 @@ export default function BestellungDetail() { const canApprove = hasPermission('bestellungen:approve'); const canExport = hasPermission('bestellungen:export'); const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : []; + const allCostsEntered = positionen.length === 0 || positionen.every(p => p.einzelpreis != null && Number(p.einzelpreis) > 0); // All statuses except current, for force override const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', 'abgeschlossen']; @@ -766,7 +768,11 @@ export default function BestellungDetail() { {/* ── Status Action ── */} {(canManageOrders || canCreate || canApprove) && ( - + + {validTransitions.includes('abgeschlossen' as BestellungStatus) && !allCostsEntered && ( + Nicht alle Positionen haben Kosten. Bitte Einzelpreise eintragen, bevor die Bestellung abgeschlossen wird. + )} + {validTransitions .filter((s) => { // Approve/reject transitions from wartet_auf_genehmigung require canApprove @@ -787,11 +793,13 @@ export default function BestellungDetail() { ? 'Ablehnen' : `Status: ${BESTELLUNG_STATUS_LABELS[s]}`; const color = isApprove ? 'success' : isReject ? 'error' : 'primary'; + const isAbgeschlossen = s === 'abgeschlossen'; return ( + + + + ); +} + // ─── Tab 1: Transaktionen ───────────────────────────────────────────────────── function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { @@ -709,6 +876,21 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { const [filters, setFilters] = useState({ haushaltsjahr_id: selectedJahrId || undefined }); const [createOpen, setCreateOpen] = useState(false); const [filterAusgabenTyp, setFilterAusgabenTyp] = useState(''); + const [txSubTab, setTxSubTab] = useState(0); + + // ── Erstattung state ── + const [erstattungOpen, setErstattungOpen] = useState(false); + + // ── Wiederkehrend state ── + const [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false }); + + const { data: kontenFlat = [] } = useQuery({ + queryKey: ['buchhaltung-konten', selectedJahrId], + queryFn: () => buchhaltungApi.getKonten(selectedJahrId!), + enabled: selectedJahrId != null, + }); + const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten }); + const { data: wiederkehrend = [] } = useQuery({ queryKey: ['buchhaltung-wiederkehrend'], queryFn: buchhaltungApi.getWiederkehrend }); const { data: transaktionen = [], isLoading } = useQuery({ queryKey: ['buchhaltung-transaktionen', filters], @@ -757,6 +939,32 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { onError: () => showError('Ablehnung fehlgeschlagen'), }); + // ── Wiederkehrend mutations ── + const canManage = hasPermission('buchhaltung:manage_accounts'); + + const createWiederkehrendMut = useMutation({ + mutationFn: buchhaltungApi.createWiederkehrend, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung erstellt'); }, + onError: () => showError('Erstellen fehlgeschlagen'), + }); + const updateWiederkehrendMut = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateWiederkehrend(id, data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung aktualisiert'); }, + onError: () => showError('Aktualisierung fehlgeschlagen'), + }); + const deleteWiederkehrendMut = useMutation({ + mutationFn: buchhaltungApi.deleteWiederkehrend, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); showSuccess('Wiederkehrende Buchung gelöscht'); }, + onError: () => showError('Löschen fehlgeschlagen'), + }); + + // ── Erstattung mutation ── + const createErstattungMut = useMutation({ + mutationFn: buchhaltungApi.createErstattung, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); setErstattungOpen(false); showSuccess('Erstattung erstellt'); }, + onError: () => showError('Erstattung konnte nicht erstellt werden'), + }); + const handleExportCsv = async () => { if (!filters.haushaltsjahr_id) { showError('Bitte ein Haushaltsjahr auswählen'); return; } try { @@ -798,6 +1006,15 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { return ( + + setTxSubTab(v)}> + + + + + + {txSubTab === 0 && ( + {/* Filters */} @@ -848,6 +1065,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { )} + {hasPermission('buchhaltung:create') && ( + + )} @@ -970,6 +1192,74 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { selectedJahrId={selectedJahrId} onSave={data => createMut.mutate(data)} /> + + setErstattungOpen(false)} + konten={kontenFlat} + bankkonten={bankkonten} + transaktionen={transaktionen} + onSave={data => createErstattungMut.mutate(data)} + /> + + )} + + {txSubTab === 1 && ( + + + {canManage && } + + + + + + Bezeichnung + Typ + Betrag + Intervall + Nächste Ausführung + Aktiv + {canManage && Aktionen} + + + + {wiederkehrend.length === 0 && Keine wiederkehrenden Buchungen} + {wiederkehrend.map((w: WiederkehrendBuchung) => ( + + {w.bezeichnung} + + + + + {w.typ === 'ausgabe' ? '-' : '+'}{fmtEur(w.betrag)} + + {INTERVALL_LABELS[w.intervall]} + {fmtDate(w.naechste_ausfuehrung)} + {w.aktiv ? : } + {canManage && ( + + setWiederkehrendDialog({ open: true, existing: w })}> + deleteWiederkehrendMut.mutate(w.id)}> + + )} + + ))} + +
+
+ setWiederkehrendDialog({ open: false })} + konten={kontenFlat} + bankkonten={bankkonten} + existing={wiederkehrendDialog.existing} + onSave={data => wiederkehrendDialog.existing + ? updateWiederkehrendMut.mutate({ id: wiederkehrendDialog.existing.id, data }) + : createWiederkehrendMut.mutate(data) + } + /> +
+ )}
); } @@ -1113,7 +1403,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { const [kontoSaveError, setKontoSaveError] = useState(null); const [bankDialog, setBankDialog] = useState<{ open: boolean; existing?: Bankkonto }>({ open: false }); const [jahrDialog, setJahrDialog] = useState<{ open: boolean; existing?: Haushaltsjahr }>({ open: false }); - const [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false }); const { data: kontenFlat = [] } = useQuery({ queryKey: ['buchhaltung-konten', selectedJahrId], @@ -1128,7 +1417,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { const kontenTree = buildTree(kontenTreeData); const konten = kontenFlat; const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten }); - const { data: wiederkehrend = [] } = useQuery({ queryKey: ['buchhaltung-wiederkehrend'], queryFn: buchhaltungApi.getWiederkehrend }); const { data: kategorien = [] } = useQuery({ queryKey: ['buchhaltung-kategorien', selectedJahrId], queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!), @@ -1199,22 +1487,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { onError: (e: Error) => showError(e.message || 'Abschluss fehlgeschlagen'), }); - const createWiederkehrendMut = useMutation({ - mutationFn: buchhaltungApi.createWiederkehrend, - onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung erstellt'); }, - onError: () => showError('Erstellen fehlgeschlagen'), - }); - const updateWiederkehrendMut = useMutation({ - mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateWiederkehrend(id, data), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung aktualisiert'); }, - onError: () => showError('Aktualisierung fehlgeschlagen'), - }); - const deleteWiederkehrendMut = useMutation({ - mutationFn: buchhaltungApi.deleteWiederkehrend, - onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); showSuccess('Wiederkehrende Buchung gelöscht'); }, - onError: () => showError('Löschen fehlgeschlagen'), - }); - return ( @@ -1222,7 +1494,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { - @@ -1433,64 +1704,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { /> )} - - {/* Sub-Tab 3: Wiederkehrend */} - {subTab === 3 && ( - - - {canManage && } - - - - - - Bezeichnung - Typ - Betrag - Intervall - Nächste Ausführung - Aktiv - {canManage && Aktionen} - - - - {wiederkehrend.length === 0 && Keine wiederkehrenden Buchungen} - {wiederkehrend.map((w: WiederkehrendBuchung) => ( - - {w.bezeichnung} - - - - - {w.typ === 'ausgabe' ? '-' : '+'}{fmtEur(w.betrag)} - - {INTERVALL_LABELS[w.intervall]} - {fmtDate(w.naechste_ausfuehrung)} - {w.aktiv ? : } - {canManage && ( - - setWiederkehrendDialog({ open: true, existing: w })}> - deleteWiederkehrendMut.mutate(w.id)}> - - )} - - ))} - -
-
- setWiederkehrendDialog({ open: false })} - konten={konten} - bankkonten={bankkonten} - existing={wiederkehrendDialog.existing} - onSave={data => wiederkehrendDialog.existing - ? updateWiederkehrendMut.mutate({ id: wiederkehrendDialog.existing.id, data }) - : createWiederkehrendMut.mutate(data) - } - /> -
- )}
); } diff --git a/frontend/src/pages/BuchhaltungKontoDetail.tsx b/frontend/src/pages/BuchhaltungKontoDetail.tsx index e0f8fff..e52f527 100644 --- a/frontend/src/pages/BuchhaltungKontoDetail.tsx +++ b/frontend/src/pages/BuchhaltungKontoDetail.tsx @@ -52,9 +52,13 @@ export default function BuchhaltungKontoDetail() { if (isError || !data) return Konto nicht gefunden.; const { konto, children, transaktionen } = data; + const isEinfach = (konto.budget_typ || 'detailliert') === 'einfach'; const totalEinnahmen = transaktionen .filter(t => t.typ === 'einnahme' && t.status === 'gebucht') .reduce((sum, t) => sum + Number(t.betrag), 0); + const totalAusgaben = transaktionen + .filter(t => t.typ === 'ausgabe' && t.status === 'gebucht') + .reduce((sum, t) => sum + Number(t.betrag), 0); return ( @@ -69,29 +73,47 @@ export default function BuchhaltungKontoDetail() {
- - t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'gwg').reduce((s, t) => s + Number(t.betrag), 0) - } /> - - - t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'anlagen').reduce((s, t) => s + Number(t.betrag), 0) - } /> - - - t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'instandhaltung').reduce((s, t) => s + Number(t.betrag), 0) - } /> - - - - - Einnahmen - {fmtEur(totalEinnahmen)} - - - + {isEinfach ? ( + <> + + + + + + + Einnahmen + {fmtEur(totalEinnahmen)} + + + + + ) : ( + <> + + t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'gwg').reduce((s, t) => s + Number(t.betrag), 0) + } /> + + + t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'anlagen').reduce((s, t) => s + Number(t.betrag), 0) + } /> + + + t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'instandhaltung').reduce((s, t) => s + Number(t.betrag), 0) + } /> + + + + + Einnahmen + {fmtEur(totalEinnahmen)} + + + + + )} {children.length > 0 && ( diff --git a/frontend/src/pages/BuchhaltungKontoManage.tsx b/frontend/src/pages/BuchhaltungKontoManage.tsx index f5387b2..3677feb 100644 --- a/frontend/src/pages/BuchhaltungKontoManage.tsx +++ b/frontend/src/pages/BuchhaltungKontoManage.tsx @@ -5,12 +5,13 @@ import { Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select, FormControl, InputLabel, Alert, Dialog, DialogTitle, DialogContent, DialogActions, Skeleton, Divider, LinearProgress, Grid, + ToggleButton, ToggleButtonGroup, } from '@mui/material'; import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { buchhaltungApi } from '../services/buchhaltung'; import { useNotification } from '../contexts/NotificationContext'; -import type { KontoFormData } from '../types/buchhaltung.types'; +import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types'; const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n); @@ -86,6 +87,8 @@ export default function BuchhaltungKontoManage() { budget_gwg: k.budget_gwg, budget_anlagen: k.budget_anlagen, budget_instandhaltung: k.budget_instandhaltung, + budget_typ: k.budget_typ || 'detailliert', + budget_gesamt: k.budget_gesamt || 0, parent_id: k.parent_id ?? undefined, kategorie_id: k.kategorie_id ?? undefined, notizen: k.notizen ?? '', @@ -157,6 +160,8 @@ export default function BuchhaltungKontoManage() { budget_gwg: k.budget_gwg, budget_anlagen: k.budget_anlagen, budget_instandhaltung: k.budget_instandhaltung, + budget_typ: k.budget_typ || 'detailliert', + budget_gesamt: k.budget_gesamt || 0, parent_id: k.parent_id ?? undefined, kategorie_id: k.kategorie_id ?? undefined, notizen: k.notizen ?? '', @@ -231,22 +236,53 @@ export default function BuchhaltungKontoManage() { {isEditing ? ( - setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small" - helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_gwg)}, vergeben: ${fmtEur(siblingBudgets.gwg)}, verfügbar: ${fmtEur(parentKonto.budget_gwg - siblingBudgets.gwg)}` : undefined} /> - setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small" - helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_anlagen)}, vergeben: ${fmtEur(siblingBudgets.anlagen)}, verfügbar: ${fmtEur(parentKonto.budget_anlagen - siblingBudgets.anlagen)}` : undefined} /> - setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small" - helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_instandhaltung)}, vergeben: ${fmtEur(siblingBudgets.instandhaltung)}, verfügbar: ${fmtEur(parentKonto.budget_instandhaltung - siblingBudgets.instandhaltung)}` : undefined} /> + {!konto.parent_id && ( + + Budget-Typ + { if (val) setForm(f => ({ ...f, budget_typ: val as BudgetTyp })); }} + > + Detailliert + Einfach + + + )} + {(form.budget_typ || 'detailliert') === 'einfach' ? ( + setForm(f => ({ ...f, budget_gesamt: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small" /> + ) : ( + <> + setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small" + helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_gwg)}, vergeben: ${fmtEur(siblingBudgets.gwg)}, verfügbar: ${fmtEur(parentKonto.budget_gwg - siblingBudgets.gwg)}` : undefined} /> + setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small" + helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_anlagen)}, vergeben: ${fmtEur(siblingBudgets.anlagen)}, verfügbar: ${fmtEur(parentKonto.budget_anlagen - siblingBudgets.anlagen)}` : undefined} /> + setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small" + helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_instandhaltung)}, vergeben: ${fmtEur(siblingBudgets.instandhaltung)}, verfügbar: ${fmtEur(parentKonto.budget_instandhaltung - siblingBudgets.instandhaltung)}` : undefined} /> + + )} {saveError && setSaveError(null)}>{saveError}} ) : ( <> - - - - - - + {(konto.budget_typ || 'detailliert') === 'einfach' ? ( + <> + + + + + + ) : ( + <> + + + + + + + + )} )} diff --git a/frontend/src/services/buchhaltung.ts b/frontend/src/services/buchhaltung.ts index 17d3765..9565433 100644 --- a/frontend/src/services/buchhaltung.ts +++ b/frontend/src/services/buchhaltung.ts @@ -10,6 +10,7 @@ import type { WiederkehrendBuchung, WiederkehrendFormData, Freigabe, Kategorie, + ErstattungFormData, ErstattungLinks, } from '../types/buchhaltung.types'; export const buchhaltungApi = { @@ -196,4 +197,14 @@ export const buchhaltungApi = { const r = await api.patch(`/api/buchhaltung/freigaben/${id}/ablehnen`, { kommentar }); return r.data.data; }, + + // ── Erstattungen ───────────────────────────────────────────────────────────── + createErstattung: async (data: ErstattungFormData): Promise => { + const r = await api.post('/api/buchhaltung/erstattungen', data); + return r.data.data; + }, + getErstattungLinks: async (transaktionId: number): Promise => { + const r = await api.get(`/api/buchhaltung/transaktionen/${transaktionId}/erstattung-links`); + return r.data.data; + }, }; diff --git a/frontend/src/types/buchhaltung.types.ts b/frontend/src/types/buchhaltung.types.ts index b46b3e7..ea35273 100644 --- a/frontend/src/types/buchhaltung.types.ts +++ b/frontend/src/types/buchhaltung.types.ts @@ -6,6 +6,7 @@ export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt'; export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich'; export type PlanungStatus = 'entwurf' | 'aktiv' | 'abgeschlossen'; export type AusgabenTyp = 'gwg' | 'anlagen' | 'instandhaltung'; +export type BudgetTyp = 'detailliert' | 'einfach'; export const AUSGABEN_TYP_LABELS: Record = { gwg: 'GWG', @@ -98,6 +99,8 @@ export interface Konto { budget_gwg: number; budget_anlagen: number; budget_instandhaltung: number; + budget_typ: BudgetTyp; + budget_gesamt: number; notizen: string | null; aktiv: boolean; erstellt_von: string | null; @@ -235,6 +238,8 @@ export interface KontoFormData { budget_gwg: number; budget_anlagen: number; budget_instandhaltung: number; + budget_typ?: BudgetTyp; + budget_gesamt?: number; parent_id?: number | null; kategorie_id?: number | null; notizen?: string; @@ -286,3 +291,18 @@ export interface KontoDetailResponse { children: KontoTreeNode[]; transaktionen: Transaktion[]; } + +export interface ErstattungFormData { + konto_id: number; + bankkonto_id: number | null; + betrag: number; + datum: string; + beschreibung?: string; + empfaenger_auftraggeber?: string; + quell_transaktion_ids: number[]; +} + +export interface ErstattungLinks { + erstattung_transaktion_id: number | null; + quell_transaktion_ids: number[]; +}