From 0c5432b50eaf916fa24d8767c3114c59deef8ea0 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 30 Mar 2026 09:49:28 +0200 Subject: [PATCH] feat: add account hierarchy, budget types (GWG/Anlagen/Instandhaltung), and Buchhaltung UI overhaul with collapsible tree, pending badge, and konto detail page --- .../src/controllers/buchhaltung.controller.ts | 36 ++ .../migrations/077_buchhaltung_hierarchy.sql | 25 ++ backend/src/routes/buchhaltung.routes.ts | 3 + backend/src/services/buchhaltung.service.ts | 132 ++++++- frontend/src/App.tsx | 9 + frontend/src/pages/Buchhaltung.tsx | 361 +++++++++++++----- frontend/src/pages/BuchhaltungKontoDetail.tsx | 173 +++++++++ frontend/src/services/buchhaltung.ts | 15 +- frontend/src/types/buchhaltung.types.ts | 35 +- 9 files changed, 673 insertions(+), 116 deletions(-) create mode 100644 backend/src/database/migrations/077_buchhaltung_hierarchy.sql create mode 100644 frontend/src/pages/BuchhaltungKontoDetail.tsx diff --git a/backend/src/controllers/buchhaltung.controller.ts b/backend/src/controllers/buchhaltung.controller.ts index 1d010ef..68812d5 100644 --- a/backend/src/controllers/buchhaltung.controller.ts +++ b/backend/src/controllers/buchhaltung.controller.ts @@ -127,6 +127,42 @@ class BuchhaltungController { } } + async getKontenTree(req: Request, res: Response): Promise { + const haushaltsjahrId = req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined; + if (!haushaltsjahrId || isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; } + try { + const tree = await buchhaltungService.getKontenTree(haushaltsjahrId); + res.json(tree); + } catch (error) { + logger.error('BuchhaltungController.getKontenTree', { error }); + res.status(500).json({ error: 'Fehler beim Laden des Kontenbaums' }); + } + } + + async getKontoDetail(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 detail = await buchhaltungService.getKontoDetail(id); + if (!detail) { res.status(404).json({ error: 'Konto nicht gefunden' }); return; } + res.json(detail); + } catch (error) { + logger.error('BuchhaltungController.getKontoDetail', { error }); + res.status(500).json({ error: 'Fehler beim Laden des Kontos' }); + } + } + + async getPendingCount(req: Request, res: Response): Promise { + try { + const haushaltsjahrId = req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined; + const count = await buchhaltungService.getPendingCount(haushaltsjahrId); + res.json({ count }); + } catch (error) { + logger.error('BuchhaltungController.getPendingCount', { error }); + res.status(500).json({ error: 'Fehler' }); + } + } + async createKonto(req: Request, res: Response): Promise { try { const data = await buchhaltungService.createKonto(req.body, req.user!.id); diff --git a/backend/src/database/migrations/077_buchhaltung_hierarchy.sql b/backend/src/database/migrations/077_buchhaltung_hierarchy.sql new file mode 100644 index 0000000..1025e53 --- /dev/null +++ b/backend/src/database/migrations/077_buchhaltung_hierarchy.sql @@ -0,0 +1,25 @@ +-- 1. Add parent_id for account hierarchy +ALTER TABLE buchhaltung_konten ADD COLUMN parent_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL; +CREATE INDEX idx_buch_konten_parent ON buchhaltung_konten(parent_id); + +-- 2. Replace budget_betrag with three type-specific budget columns +ALTER TABLE buchhaltung_konten ADD COLUMN budget_gwg NUMERIC(12,2) NOT NULL DEFAULT 0; +ALTER TABLE buchhaltung_konten ADD COLUMN budget_anlagen NUMERIC(12,2) NOT NULL DEFAULT 0; +ALTER TABLE buchhaltung_konten ADD COLUMN budget_instandhaltung NUMERIC(12,2) NOT NULL DEFAULT 0; +-- Migrate existing budget to GWG as default +UPDATE buchhaltung_konten SET budget_gwg = COALESCE(budget_betrag, 0); +ALTER TABLE buchhaltung_konten DROP COLUMN budget_betrag; + +-- 3. Add ausgaben_typ to transactions (nullable: einnahmen have no type) +ALTER TABLE buchhaltung_transaktionen ADD COLUMN ausgaben_typ TEXT CHECK (ausgaben_typ IN ('gwg', 'anlagen', 'instandhaltung')); + +-- 4. Add wiederkehrend_id to track auto-generated transactions +ALTER TABLE buchhaltung_transaktionen ADD COLUMN wiederkehrend_id INT REFERENCES buchhaltung_wiederkehrend(id) ON DELETE SET NULL; +CREATE INDEX idx_buch_trans_wiederkehrend ON buchhaltung_transaktionen(wiederkehrend_id); + +-- 5. Update planpositionen to have type-specific budgets +ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_gwg NUMERIC(12,2) NOT NULL DEFAULT 0; +ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_anlagen NUMERIC(12,2) NOT NULL DEFAULT 0; +ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_instandhaltung NUMERIC(12,2) NOT NULL DEFAULT 0; +UPDATE buchhaltung_planpositionen SET budget_gwg = COALESCE(plan_betrag, 0); +ALTER TABLE buchhaltung_planpositionen DROP COLUMN plan_betrag; diff --git a/backend/src/routes/buchhaltung.routes.ts b/backend/src/routes/buchhaltung.routes.ts index e658bf4..b37ad03 100644 --- a/backend/src/routes/buchhaltung.routes.ts +++ b/backend/src/routes/buchhaltung.routes.ts @@ -8,6 +8,7 @@ const router = Router(); // ── Stats ───────────────────────────────────────────────────────────────────── router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController)); +router.get('/stats/pending', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPendingCount.bind(buchhaltungController)); // ── Haushaltsjahre ───────────────────────────────────────────────────────────── router.get('/haushaltsjahre', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listHaushaltsjahre.bind(buchhaltungController)); @@ -25,8 +26,10 @@ router.patch('/bankkonten/:id', authenticate, requirePermission('buchhaltung:man router.delete('/bankkonten/:id',authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteBankkonto.bind(buchhaltungController)); // ── Konten ──────────────────────────────────────────────────────────────────── +router.get('/konten/tree', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontenTree.bind(buchhaltungController)); router.get('/konten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKonten.bind(buchhaltungController)); router.post('/konten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createKonto.bind(buchhaltungController)); +router.get('/konten/:id/detail', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoDetail.bind(buchhaltungController)); router.patch('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateKonto.bind(buchhaltungController)); router.delete('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteKonto.bind(buchhaltungController)); router.get('/konten/:id/budget', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoBudget.bind(buchhaltungController)); diff --git a/backend/src/services/buchhaltung.service.ts b/backend/src/services/buchhaltung.service.ts index 5f0a86e..8842076 100644 --- a/backend/src/services/buchhaltung.service.ts +++ b/backend/src/services/buchhaltung.service.ts @@ -245,9 +245,11 @@ async function deactivateBankkonto(id: number) { async function getAllKonten(haushaltsjahrId: number) { try { const result = await pool.query( - `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art + `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, + k.parent_id, pk.bezeichnung AS parent_bezeichnung FROM buchhaltung_konten k LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id + LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE ORDER BY k.kontonummer`, [haushaltsjahrId] @@ -262,9 +264,11 @@ async function getAllKonten(haushaltsjahrId: number) { async function getKontoById(id: number) { try { const result = await pool.query( - `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art + `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, + pk.bezeichnung AS parent_bezeichnung FROM buchhaltung_konten k LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id + LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id WHERE k.id = $1`, [id] ); @@ -275,16 +279,100 @@ async function getKontoById(id: number) { } } +async function getKontenTree(haushaltsjahrId: number) { + try { + const result = await pool.query( + `SELECT k.*, + kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, + pk.bezeichnung AS parent_bezeichnung, + COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='gwg' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_gwg, + COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='anlagen' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_anlagen, + COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='instandhaltung' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_instandhaltung, + COALESCE(SUM(CASE WHEN t.typ='einnahme' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS einnahmen_betrag + FROM buchhaltung_konten k + LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id + LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id + LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1 + WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE + GROUP BY k.id, kt.bezeichnung, kt.art, pk.bezeichnung + ORDER BY k.kontonummer`, + [haushaltsjahrId] + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getKontenTree failed', { error, haushaltsjahrId }); + throw new Error('Kontenbaum konnte nicht geladen werden'); + } +} + +async function getKontoDetail(kontoId: number) { + try { + const kontoResult = await pool.query( + `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, + pk.bezeichnung AS parent_bezeichnung + FROM buchhaltung_konten k + LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id + LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id + WHERE k.id = $1`, + [kontoId] + ); + if (!kontoResult.rows[0]) return null; + + const childrenResult = await pool.query( + `SELECT * FROM buchhaltung_konten WHERE parent_id = $1 AND aktiv = TRUE ORDER BY kontonummer`, + [kontoId] + ); + + const transaktionenResult = await pool.query( + `SELECT t.*, + k.bezeichnung as konto_bezeichnung, + k.kontonummer as konto_kontonummer, + bk.bezeichnung as bankkonto_bezeichnung + FROM buchhaltung_transaktionen t + LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id + LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id + WHERE t.konto_id = $1 + ORDER BY t.datum DESC, t.id DESC`, + [kontoId] + ); + + return { + konto: kontoResult.rows[0], + children: childrenResult.rows, + transaktionen: transaktionenResult.rows, + }; + } catch (error) { + logger.error('BuchhaltungService.getKontoDetail failed', { error, kontoId }); + throw new Error('Kontodetails konnten nicht geladen werden'); + } +} + +async function getPendingCount(haushaltsjahrId?: number): Promise { + try { + let query = `SELECT COUNT(*) FROM buchhaltung_transaktionen WHERE status = 'entwurf'`; + const params: unknown[] = []; + if (haushaltsjahrId) { + query += ` AND haushaltsjahr_id = $1`; + params.push(haushaltsjahrId); + } + const result = await pool.query(query, params); + return parseInt(result.rows[0].count, 10); + } catch (error) { + logger.error('BuchhaltungService.getPendingCount failed', { error }); + throw new Error('Anzahl offener Entwürfe konnte nicht geladen werden'); + } +} + async function createKonto( - data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; budget_betrag?: 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 }, userId: string ) { try { const result = await pool.query( - `INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, budget_betrag, notizen, erstellt_von) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `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) RETURNING *`, - [data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.budget_betrag || 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] ); return result.rows[0]; } catch (error) { @@ -295,17 +383,20 @@ async function createKonto( async function updateKonto( id: number, - data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; budget_betrag?: 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 } ) { try { const fields: string[] = []; const values: unknown[] = []; let idx = 1; - if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); } - if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); } - if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } - if (data.budget_betrag !== undefined){ fields.push(`budget_betrag = $${idx++}`); values.push(data.budget_betrag); } - if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); } + if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); } + if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); } + if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } + if (data.parent_id !== undefined) { fields.push(`parent_id = $${idx++}`); values.push(data.parent_id || null); } + if (data.budget_gwg !== undefined) { fields.push(`budget_gwg = $${idx++}`); values.push(data.budget_gwg); } + 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 (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); values.push(id); const result = await pool.query( @@ -348,7 +439,7 @@ async function getBudgetUtilisation(id: number) { const row = result.rows[0]; const gebucht = parseFloat(row.gebucht_betrag); const ausstehend = parseFloat(row.ausstehend_betrag); - const budget = parseFloat(row.budget_betrag); + const budget = parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung); return { ...row, gebucht_betrag: gebucht, @@ -451,14 +542,15 @@ async function createTransaktion( empfaenger_auftraggeber?: string; verwendungszweck?: string; beleg_nr?: string; + ausgaben_typ?: string | null; }, userId: string ) { try { const result = await pool.query( `INSERT INTO buchhaltung_transaktionen - (haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, erstellt_von) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + (haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, ausgaben_typ, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ data.haushaltsjahr_id, @@ -471,6 +563,7 @@ async function createTransaktion( data.empfaenger_auftraggeber || null, data.verwendungszweck || null, data.beleg_nr || null, + data.typ === 'ausgabe' ? (data.ausgaben_typ || null) : null, userId, ] ); @@ -798,9 +891,9 @@ async function getOverview(haushaltsjahrId: number) { ...row, gebucht_betrag: parseFloat(row.gebucht_betrag), ausstehend_betrag: parseFloat(row.ausstehend_betrag), - verfuegbar_betrag: parseFloat(row.budget_betrag) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag), - auslastung_prozent: parseFloat(row.budget_betrag) > 0 - ? Math.round((parseFloat(row.gebucht_betrag) / parseFloat(row.budget_betrag)) * 100) + verfuegbar_betrag: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag), + auslastung_prozent: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) > 0 + ? Math.round((parseFloat(row.gebucht_betrag) / (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung))) * 100) : 0, })), }; @@ -1019,6 +1112,9 @@ const buchhaltungService = { deactivateBankkonto, getAllKonten, getKontoById, + getKontenTree, + getKontoDetail, + getPendingCount, createKonto, updateKonto, deleteKonto, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 66a7dea..13048ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikel import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; import Checklisten from './pages/Checklisten'; import Buchhaltung from './pages/Buchhaltung'; +import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail'; import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen'; import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung'; import Issues from './pages/Issues'; @@ -379,6 +380,14 @@ function App() { } /> + + + + } + /> {children} : null; +} + // ─── Sub-components ──────────────────────────────────────────────────────────── function HaushaltsjahrDialog({ @@ -172,21 +185,23 @@ function KontoDialog({ onClose, haushaltsjahrId, existing, + konten, onSave, }: { open: boolean; onClose: () => void; haushaltsjahrId: number; existing?: Konto; + konten: Konto[]; onSave: (data: KontoFormData) => void; }) { const { data: kontoTypen = [] } = useQuery({ queryKey: ['konto-typen'], queryFn: buchhaltungApi.getKontoTypen }); - const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: '', bezeichnung: '', budget_betrag: 0, notizen: '' }; + const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: '', bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, parent_id: null, notizen: '' }; const [form, setForm] = useState(empty); useEffect(() => { if (existing) { - setForm({ haushaltsjahr_id: haushaltsjahrId, konto_typ_id: existing.konto_typ_id ?? undefined, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_betrag: existing.budget_betrag, notizen: existing.notizen || '' }); + setForm({ haushaltsjahr_id: haushaltsjahrId, konto_typ_id: existing.konto_typ_id ?? undefined, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_gwg: existing.budget_gwg, budget_anlagen: existing.budget_anlagen, budget_instandhaltung: existing.budget_instandhaltung, parent_id: existing.parent_id, notizen: existing.notizen || '' }); } else { setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId }); } @@ -207,7 +222,25 @@ function KontoDialog({ {kontoTypen.map(kt => {kt.bezeichnung})} - setForm(f => ({ ...f, budget_betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01' }} /> + + Elternkonto (optional) + + + setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} /> + setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} /> + setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} /> setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} /> @@ -245,6 +278,7 @@ function TransaktionDialog({ verwendungszweck: '', beleg_nr: '', bestellung_id: null, + ausgaben_typ: null, }); const { data: konten = [] } = useQuery({ @@ -282,6 +316,21 @@ function TransaktionDialog({ Einnahme + {form.typ === 'ausgabe' && ( + + Ausgaben-Typ + + + )} setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required /> setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required /> @@ -319,6 +368,69 @@ function TransaktionDialog({ ); } +// ─── Tree helpers ───────────────────────────────────────────────────────────── + +function buildTree(flat: KontoTreeNode[]): KontoTreeNode[] { + const map = new Map(); + flat.forEach(k => map.set(k.id, { ...k, children: [] })); + const roots: KontoTreeNode[] = []; + flat.forEach(k => { + if (k.parent_id && map.has(k.parent_id)) { + map.get(k.parent_id)!.children.push(map.get(k.id)!); + } else { + roots.push(map.get(k.id)!); + } + }); + return roots; +} + +function KontoRow({ konto, depth = 0, onNavigate }: { konto: KontoTreeNode; depth?: number; onNavigate: (id: number) => void }) { + const [open, setOpen] = useState(false); + const totalBudget = konto.budget_gwg + konto.budget_anlagen + konto.budget_instandhaltung; + const totalSpent = konto.spent_gwg + konto.spent_anlagen + konto.spent_instandhaltung; + const utilization = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0; + + return ( + <> + + + + {konto.children.length > 0 && ( + setOpen(!open)}> + {open ? : } + + )} + onNavigate(konto.id)} + > + {konto.kontonummer} — {konto.bezeichnung} + + + {totalBudget > 0 && ( + 100 ? 'error' : utilization > 80 ? 'warning' : 'primary'} + sx={{ mt: 0.5, height: 4, borderRadius: 2 }} /> + )} + + {fmtEur(konto.budget_gwg)} + {fmtEur(konto.budget_anlagen)} + {fmtEur(konto.budget_instandhaltung)} + {fmtEur(totalBudget)} + {fmtEur(konto.spent_gwg)} + {fmtEur(konto.spent_anlagen)} + {fmtEur(konto.spent_instandhaltung)} + {fmtEur(totalSpent)} + {fmtEur(konto.einnahmen_betrag)} + + {open && konto.children.map(child => ( + + ))} + + ); +} + // ─── Tab 0: Übersicht ───────────────────────────────────────────────────────── function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { @@ -326,12 +438,19 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { selectedJahrId: number | null; onJahrChange: (id: number) => void; }) { - const { data: stats, isLoading } = useQuery({ - queryKey: ['buchhaltung-stats', selectedJahrId], - queryFn: () => buchhaltungApi.getStats(selectedJahrId!), + const navigate = useNavigate(); + const { data: treeData = [], isLoading } = useQuery({ + queryKey: ['kontenTree', selectedJahrId], + queryFn: () => buchhaltungApi.getKontenTree(selectedJahrId!), enabled: selectedJahrId != null, }); + const tree = buildTree(treeData); + + const totalEinnahmen = treeData.reduce((s, k) => s + Number(k.einnahmen_betrag), 0); + const totalAusgaben = treeData.reduce((s, k) => s + Number(k.spent_gwg) + Number(k.spent_anlagen) + Number(k.spent_instandhaltung), 0); + const saldo = totalEinnahmen - totalAusgaben; + return ( @@ -344,53 +463,56 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {isLoading && } - {stats && ( + {!isLoading && selectedJahrId && ( <> Einnahmen - {fmtEur(stats.total_einnahmen)} + {fmtEur(totalEinnahmen)} Ausgaben - {fmtEur(stats.total_ausgaben)} + {fmtEur(totalAusgaben)} Saldo - = 0 ? 'success.main' : 'error.main'}>{fmtEur(stats.saldo)} + = 0 ? 'success.main' : 'error.main'}>{fmtEur(saldo)} Konten - - {stats.konten_budget.map(k => ( - - - {k.kontonummer} – {k.bezeichnung} - {k.konto_typ_bezeichnung || '–'} - - Gebucht: {fmtEur(k.gebucht_betrag)} - Budget: {fmtEur(k.budget_betrag)} - - = 90 ? 'error' : k.auslastung_prozent >= 75 ? 'warning' : 'primary'} - sx={{ height: 8, borderRadius: 4 }} - /> - = 90 ? 'error' : 'text.secondary'}> - {k.auslastung_prozent}% · Verfügbar: {fmtEur(k.verfuegbar_betrag)} - - - - ))} - + + + + + Konto + Budget GWG + Budget Anlagen + Budget Instandh. + Budget Gesamt + Ausgaben GWG + Ausgaben Anlagen + Ausgaben Instandh. + Ausgaben Gesamt + Einnahmen + + + + {tree.length === 0 && ( + Keine Konten + )} + {tree.map(k => ( + navigate(`/buchhaltung/konto/${id}`)} /> + ))} + +
+
)} {!selectedJahrId && !isLoading && ( @@ -412,6 +534,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { const { hasPermission } = usePermissionContext(); const [filters, setFilters] = useState({ haushaltsjahr_id: selectedJahrId || undefined }); const [createOpen, setCreateOpen] = useState(false); + const [filterAusgabenTyp, setFilterAusgabenTyp] = useState(''); const { data: transaktionen = [], isLoading } = useQuery({ queryKey: ['buchhaltung-transaktionen', filters], @@ -479,45 +602,82 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { setFilters(f => ({ ...f, haushaltsjahr_id: selectedJahrId || undefined })); }, [selectedJahrId]); + const activeFilterCount = [ + filters.status, + filters.typ, + filters.search, + filterAusgabenTyp, + ].filter(Boolean).length; + + const filteredTransaktionen = filterAusgabenTyp + ? transaktionen.filter(t => t.ausgaben_typ === filterAusgabenTyp) + : transaktionen; + return ( {/* Filters */} - - - Haushaltsjahr - - - - Status - - - - Typ - - - setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} /> - {hasPermission('buchhaltung:export') && ( - - - - - - )} - + + }> + + + Filter + {activeFilterCount > 0 && ( + + )} + + + + + + Haushaltsjahr + + + + Status + + + + Typ + + + setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} /> + + Ausgaben-Typ + + + {hasPermission('buchhaltung:export') && ( + + + + + + )} + + + {isLoading ? : ( @@ -535,10 +695,10 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { - {transaktionen.length === 0 && ( + {filteredTransaktionen.length === 0 && ( Keine Transaktionen )} - {transaktionen.map((t: Transaktion) => ( + {filteredTransaktionen.map((t: Transaktion) => ( {t.laufende_nummer ?? `E${t.id}`} {fmtDate(t.datum)} @@ -849,18 +1009,26 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { Kontonummer Bezeichnung Typ - Budget + Elternkonto + GWG + Anlagen + Instandh. + Gesamt {canManage && Aktionen} - {konten.length === 0 && Keine Konten} + {konten.length === 0 && Keine Konten} {konten.map((k: Konto) => ( {k.kontonummer} {k.bezeichnung} {k.konto_typ_bezeichnung || '–'} - {fmtEur(k.budget_betrag)} + {k.parent_bezeichnung || '–'} + {fmtEur(k.budget_gwg)} + {fmtEur(k.budget_anlagen)} + {fmtEur(k.budget_instandhaltung)} + {fmtEur(k.budget_gwg + k.budget_anlagen + k.budget_instandhaltung)} {canManage && ( setKontoDialog({ open: true, existing: k })}> @@ -877,6 +1045,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { onClose={() => setKontoDialog({ open: false })} haushaltsjahrId={selectedJahrId} existing={kontoDialog.existing} + konten={konten} onSave={data => kontoDialog.existing ? updateKontoMut.mutate({ id: kontoDialog.existing.id, data }) : createKontoMut.mutate(data) @@ -1051,11 +1220,11 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { // ─── Main Page ──────────────────────────────────────────────────────────────── export default function Buchhaltung() { - const location = useLocation(); - const navigate = useNavigate(); - const searchParams = new URLSearchParams(location.search); - const tabFromUrl = parseInt(searchParams.get('tab') || '0', 10); - const [tab, setTab] = useState(isNaN(tabFromUrl) ? 0 : tabFromUrl); + const [searchParams, setSearchParams] = useSearchParams(); + const tabValue = parseInt(searchParams.get('tab') || '0', 10); + const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { + setSearchParams({ tab: String(newValue) }); + }; const [selectedJahrId, setSelectedJahrId] = useState(null); const { data: haushaltsjahre = [] } = useQuery({ @@ -1063,16 +1232,18 @@ export default function Buchhaltung() { queryFn: buchhaltungApi.getHaushaltsjahre, onSuccess: (data: Haushaltsjahr[]) => { if (data.length > 0 && !selectedJahrId) { - const active = data.find(hj => !hj.abgeschlossen) || data[0]; - setSelectedJahrId(active.id); + const openYear = data.find(hj => !hj.abgeschlossen) || data[0]; + setSelectedJahrId(openYear.id); } }, }); - const handleTabChange = (_: React.SyntheticEvent, newVal: number) => { - setTab(newVal); - navigate(`/buchhaltung?tab=${newVal}`, { replace: true }); - }; + const { data: pendingCount } = useQuery({ + queryKey: ['buchhaltungPending', selectedJahrId], + queryFn: () => buchhaltungApi.getPendingCount(selectedJahrId || undefined), + enabled: !!selectedJahrId, + refetchInterval: 30000, + }); return ( @@ -1081,33 +1252,33 @@ export default function Buchhaltung() { Buchhaltung - + - + Transaktionen} /> - {tab === 0 && ( + - )} - {tab === 1 && ( + + - )} - {tab === 2 && ( + + - )} +
); diff --git a/frontend/src/pages/BuchhaltungKontoDetail.tsx b/frontend/src/pages/BuchhaltungKontoDetail.tsx new file mode 100644 index 0000000..3987d69 --- /dev/null +++ b/frontend/src/pages/BuchhaltungKontoDetail.tsx @@ -0,0 +1,173 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, Typography, Button, Grid, Card, CardContent, + Table, TableHead, TableBody, TableRow, TableCell, + LinearProgress, Chip, Alert, Skeleton, TableContainer, Paper, +} from '@mui/material'; +import { ArrowBack } from '@mui/icons-material'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { buchhaltungApi } from '../services/buchhaltung'; +import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types'; +import type { AusgabenTyp } from '../types/buchhaltung.types'; + +function BudgetCard({ label, budget, spent }: { label: string; budget: number; spent: number }) { + const utilization = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0; + const over = spent > budget && budget > 0; + return ( + + + {label} + {spent.toFixed(2).replace('.', ',')} € + {budget > 0 && ( + <> + Budget: {budget.toFixed(2).replace('.', ',')} € + 80 ? 'warning' : 'primary'} + sx={{ mt: 1, height: 6, borderRadius: 3 }} + /> + + )} + + + ); +} + +export default function BuchhaltungKontoDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const kontoId = Number(id); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['kontoDetail', kontoId], + queryFn: () => buchhaltungApi.getKontoDetail(kontoId), + enabled: !!kontoId, + }); + + if (isLoading) return ; + if (isError || !data) return Konto nicht gefunden.; + + const { konto, children, transaktionen } = data; + const totalEinnahmen = transaktionen + .filter(t => t.typ === 'einnahme' && t.status === 'gebucht') + .reduce((sum, t) => sum + Number(t.betrag), 0); + + return ( + + + + + + {konto.kontonummer} — {konto.bezeichnung} + + + + + + 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 + {totalEinnahmen.toFixed(2).replace('.', ',')} € + + + + + + {children.length > 0 && ( + + Unterkonten + + + + + Konto + Budget GWG + Budget Anlagen + Budget Instandh. + Budget Gesamt + + + + {children.map(child => ( + navigate(`/buchhaltung/konto/${child.id}`)} + > + {child.kontonummer} — {child.bezeichnung} + {Number(child.budget_gwg).toFixed(2)} € + {Number(child.budget_anlagen).toFixed(2)} € + {Number(child.budget_instandhaltung).toFixed(2)} € + + {(Number(child.budget_gwg) + Number(child.budget_anlagen) + Number(child.budget_instandhaltung)).toFixed(2)} € + + + ))} + +
+
+
+ )} + + + Transaktionen + + + + + Datum + Beschreibung + Typ + Ausgaben-Typ + Betrag + Status + + + + {transaktionen.length === 0 && ( + Keine Transaktionen + )} + {transaktionen.map(t => ( + + {new Date(t.datum).toLocaleDateString('de-DE')} + {t.beschreibung} + + + + {t.ausgaben_typ ? AUSGABEN_TYP_LABELS[t.ausgaben_typ as AusgabenTyp] : '—'} + + {t.typ === 'einnahme' ? '+' : '-'}{Number(t.betrag).toFixed(2).replace('.', ',')} € + + {t.status} + + ))} + +
+
+
+
+
+ ); +} diff --git a/frontend/src/services/buchhaltung.ts b/frontend/src/services/buchhaltung.ts index 2b239ab..a38a02f 100644 --- a/frontend/src/services/buchhaltung.ts +++ b/frontend/src/services/buchhaltung.ts @@ -3,7 +3,7 @@ import type { Haushaltsjahr, HaushaltsjahrFormData, Bankkonto, BankkontoFormData, Konto, KontoFormData, KontoBudgetInfo, - KontoTyp, + KontoTyp, KontoTreeNode, KontoDetailResponse, Transaktion, TransaktionFormData, TransaktionFilters, Beleg, BuchhaltungStats, @@ -74,12 +74,25 @@ export const buchhaltungApi = { const r = await api.get(`/api/buchhaltung/konten/${id}/budget`); return r.data.data; }, + getKontenTree: async (haushaltsjahrId: number): Promise => { + const r = await api.get(`/api/buchhaltung/konten/tree?haushaltsjahr_id=${haushaltsjahrId}`); + return r.data; + }, + getKontoDetail: async (id: number): Promise => { + const r = await api.get(`/api/buchhaltung/konten/${id}/detail`); + return r.data; + }, // ── Stats ──────────────────────────────────────────────────────────────────── getStats: async (haushaltsjahrId: number): Promise => { const r = await api.get(`/api/buchhaltung/stats?haushaltsjahr_id=${haushaltsjahrId}`); return r.data.data; }, + getPendingCount: async (haushaltsjahrId?: number): Promise => { + const params = haushaltsjahrId ? `?haushaltsjahr_id=${haushaltsjahrId}` : ''; + const r = await api.get(`/api/buchhaltung/stats/pending${params}`); + return r.data.count; + }, // ── Transaktionen ───────────────────────────────────────────────────────────── getTransaktionen: async (filters?: TransaktionFilters): Promise => { diff --git a/frontend/src/types/buchhaltung.types.ts b/frontend/src/types/buchhaltung.types.ts index f99832d..ab6f5c9 100644 --- a/frontend/src/types/buchhaltung.types.ts +++ b/frontend/src/types/buchhaltung.types.ts @@ -5,6 +5,13 @@ export type TransaktionStatus = 'entwurf' | 'gebucht' | 'freigegeben' | 'stornie 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 const AUSGABEN_TYP_LABELS: Record = { + gwg: 'GWG', + anlagen: 'Anlagen', + instandhaltung: 'Instandhaltung', +}; // Label maps export const TRANSAKTION_STATUS_LABELS: Record = { @@ -79,7 +86,10 @@ export interface Konto { konto_typ_id: number | null; kontonummer: string; bezeichnung: string; - budget_betrag: number; + parent_id: number | null; + budget_gwg: number; + budget_anlagen: number; + budget_instandhaltung: number; notizen: string | null; aktiv: boolean; erstellt_von: string | null; @@ -88,6 +98,15 @@ export interface Konto { // Joined fields konto_typ_bezeichnung?: string; konto_typ_art?: KontoArt; + parent_bezeichnung?: string; +} + +export interface KontoTreeNode extends Konto { + spent_gwg: number; + spent_anlagen: number; + spent_instandhaltung: number; + einnahmen_betrag: number; + children: KontoTreeNode[]; } export interface KontoBudgetInfo extends Konto { @@ -113,6 +132,8 @@ export interface Transaktion { beleg_nr: string | null; status: TransaktionStatus; bestellung_id: number | null; + ausgaben_typ: AusgabenTyp | null; + wiederkehrend_id: number | null; erstellt_von: string | null; gebucht_von: string | null; erstellt_am: string; @@ -200,7 +221,10 @@ export interface KontoFormData { konto_typ_id?: number; kontonummer: string; bezeichnung: string; - budget_betrag?: number; + budget_gwg: number; + budget_anlagen: number; + budget_instandhaltung: number; + parent_id?: number | null; notizen?: string; } @@ -216,6 +240,7 @@ export interface TransaktionFormData { verwendungszweck?: string; beleg_nr?: string; bestellung_id?: number | null; + ausgaben_typ?: AusgabenTyp | null; } // Filter type for transaction list @@ -241,3 +266,9 @@ export interface WiederkehrendFormData { naechste_ausfuehrung: string; aktiv?: boolean; } + +export interface KontoDetailResponse { + konto: Konto; + children: KontoTreeNode[]; + transaktionen: Transaktion[]; +}