diff --git a/backend/src/controllers/buchhaltung.controller.ts b/backend/src/controllers/buchhaltung.controller.ts index 72a0060..33b03b7 100644 --- a/backend/src/controllers/buchhaltung.controller.ts +++ b/backend/src/controllers/buchhaltung.controller.ts @@ -6,6 +6,55 @@ const param = (req: Request, key: string): string => req.params[key] as string; class BuchhaltungController { + // ── Kategorien ────────────────────────────────────────────────────────────── + + async listKategorien(req: Request, res: Response): Promise { + const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10); + if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; } + try { + const data = await buchhaltungService.getKategorien(haushaltsjahrId); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.listKategorien', { error }); + res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' }); + } + } + + async createKategorie(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createKategorie(req.body); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createKategorie', { error }); + res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' }); + } + } + + async updateKategorie(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.updateKategorie(id, req.body); + if (!data) { res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.updateKategorie', { error }); + res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' }); + } + } + + async deleteKategorie(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 { + await buchhaltungService.deleteKategorie(id); + res.json({ success: true }); + } catch (error) { + logger.error('BuchhaltungController.deleteKategorie', { error }); + res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht werden' }); + } + } + // ── Haushaltsjahre ────────────────────────────────────────────────────────── async listHaushaltsjahre(_req: Request, res: Response): Promise { @@ -181,9 +230,10 @@ class BuchhaltungController { const data = await buchhaltungService.updateKonto(id, req.body); if (!data) { res.status(404).json({ success: false, message: 'Konto nicht gefunden' }); return; } res.json({ success: true, data }); - } catch (error) { + } catch (error: any) { logger.error('BuchhaltungController.updateKonto', { error }); - res.status(500).json({ success: false, message: 'Konto konnte nicht aktualisiert werden' }); + const status = error.statusCode || 500; + res.status(status).json({ success: false, message: error.message || 'Konto konnte nicht aktualisiert werden' }); } } diff --git a/backend/src/database/migrations/079_buchhaltung_categories_and_recurring.sql b/backend/src/database/migrations/079_buchhaltung_categories_and_recurring.sql new file mode 100644 index 0000000..9d371bc --- /dev/null +++ b/backend/src/database/migrations/079_buchhaltung_categories_and_recurring.sql @@ -0,0 +1,14 @@ +-- Categories for Buchhaltung Konten +CREATE TABLE IF NOT EXISTS buchhaltung_kategorien ( + id SERIAL PRIMARY KEY, + haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id), + bezeichnung TEXT NOT NULL, + sortierung INT DEFAULT 0, + erstellt_am TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE buchhaltung_konten ADD COLUMN IF NOT EXISTS kategorie_id INT REFERENCES buchhaltung_kategorien(id); + +-- Recurring transaction execution day configuration +ALTER TABLE buchhaltung_wiederkehrend ADD COLUMN IF NOT EXISTS ausfuehrungstag TEXT DEFAULT 'erster' CHECK (ausfuehrungstag IN ('erster', 'mitte', 'letzter')); +ALTER TABLE buchhaltung_wiederkehrend ADD COLUMN IF NOT EXISTS ausfuehrungs_monat INT CHECK (ausfuehrungs_monat BETWEEN 1 AND 12); diff --git a/backend/src/routes/buchhaltung.routes.ts b/backend/src/routes/buchhaltung.routes.ts index b37ad03..73afdfb 100644 --- a/backend/src/routes/buchhaltung.routes.ts +++ b/backend/src/routes/buchhaltung.routes.ts @@ -6,6 +6,12 @@ import { uploadBuchhaltung } from '../middleware/upload'; const router = Router(); +// ── Kategorien ──────────────────────────────────────────────────────────────── +router.get('/kategorien', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKategorien.bind(buchhaltungController)); +router.post('/kategorien', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createKategorie.bind(buchhaltungController)); +router.patch('/kategorien/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateKategorie.bind(buchhaltungController)); +router.delete('/kategorien/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteKategorie.bind(buchhaltungController)); + // ── Stats ───────────────────────────────────────────────────────────────────── router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController)); router.get('/stats/pending', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPendingCount.bind(buchhaltungController)); diff --git a/backend/src/services/buchhaltung.service.ts b/backend/src/services/buchhaltung.service.ts index 1a62b7c..c2a985b 100644 --- a/backend/src/services/buchhaltung.service.ts +++ b/backend/src/services/buchhaltung.service.ts @@ -6,6 +6,68 @@ import pool from '../config/database'; import logger from '../utils/logger'; import fs from 'fs'; +// --------------------------------------------------------------------------- +// Kategorien (Categories) +// --------------------------------------------------------------------------- + +async function getKategorien(haushaltsjahr_id: number) { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_kategorien WHERE haushaltsjahr_id = $1 ORDER BY sortierung, bezeichnung`, + [haushaltsjahr_id] + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getKategorien failed', { error }); + throw new Error('Kategorien konnten nicht geladen werden'); + } +} + +async function createKategorie(data: { haushaltsjahr_id: number; bezeichnung: string; sortierung?: number }) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_kategorien (haushaltsjahr_id, bezeichnung, sortierung) + VALUES ($1, $2, $3) + RETURNING *`, + [data.haushaltsjahr_id, data.bezeichnung, data.sortierung ?? 0] + ); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.createKategorie failed', { error }); + throw new Error('Kategorie konnte nicht erstellt werden'); + } +} + +async function updateKategorie(id: number, data: { bezeichnung?: string; sortierung?: number }) { + try { + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } + if (data.sortierung !== undefined) { fields.push(`sortierung = $${idx++}`); values.push(data.sortierung); } + if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); + values.push(id); + const result = await pool.query( + `UPDATE buchhaltung_kategorien SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.updateKategorie failed', { error, id }); + throw new Error('Kategorie konnte nicht aktualisiert werden'); + } +} + +async function deleteKategorie(id: number) { + try { + await pool.query(`UPDATE buchhaltung_konten SET kategorie_id = NULL WHERE kategorie_id = $1`, [id]); + await pool.query(`DELETE FROM buchhaltung_kategorien WHERE id = $1`, [id]); + } catch (error) { + logger.error('BuchhaltungService.deleteKategorie failed', { error, id }); + throw new Error('Kategorie konnte nicht gelöscht werden'); + } +} + // --------------------------------------------------------------------------- // Haushaltsjahre (Fiscal Years) // --------------------------------------------------------------------------- @@ -285,6 +347,7 @@ async function getKontenTree(haushaltsjahrId: number) { `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art, pk.bezeichnung AS parent_bezeichnung, + k.kategorie_id, kat.bezeichnung AS kategorie_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, @@ -292,9 +355,10 @@ async function getKontenTree(haushaltsjahrId: number) { 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_kategorien kat ON kat.id = k.kategorie_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 + GROUP BY k.id, kt.bezeichnung, kt.art, pk.bezeichnung, kat.bezeichnung ORDER BY k.kontonummer`, [haushaltsjahrId] ); @@ -363,11 +427,57 @@ async function getPendingCount(haushaltsjahrId?: number): Promise { } } +async function validateSubPotBudget( + parentId: number, + budgetGwg: number, + budgetAnlagen: number, + budgetInstandhaltung: number, + excludeKontoId?: number +) { + const parentResult = await pool.query( + `SELECT budget_gwg, budget_anlagen, budget_instandhaltung FROM buchhaltung_konten WHERE id = $1`, + [parentId] + ); + if (!parentResult.rows[0]) return; // parent not found, skip validation + + const parent = parentResult.rows[0]; + const excludeClause = excludeKontoId ? ` AND id != $2` : ''; + const siblingParams: unknown[] = [parentId]; + if (excludeKontoId) siblingParams.push(excludeKontoId); + + const siblingResult = await pool.query( + `SELECT COALESCE(SUM(budget_gwg), 0) AS sum_gwg, + COALESCE(SUM(budget_anlagen), 0) AS sum_anlagen, + COALESCE(SUM(budget_instandhaltung), 0) AS sum_instandhaltung + FROM buchhaltung_konten WHERE parent_id = $1${excludeClause}`, + siblingParams + ); + const sibling = siblingResult.rows[0]; + + const checks: { label: string; sum: number; parentBudget: number }[] = [ + { label: 'GWG', sum: parseFloat(sibling.sum_gwg) + budgetGwg, parentBudget: parseFloat(parent.budget_gwg) }, + { label: 'Anlagen', sum: parseFloat(sibling.sum_anlagen) + budgetAnlagen, parentBudget: parseFloat(parent.budget_anlagen) }, + { label: 'Instandhaltung', sum: parseFloat(sibling.sum_instandhaltung) + budgetInstandhaltung, parentBudget: parseFloat(parent.budget_instandhaltung) }, + ]; + + for (const c of checks) { + if (c.parentBudget > 0 && c.sum > c.parentBudget) { + throw Object.assign( + new Error(`Budget ${c.label}: Summe der Untertöpfe (${c.sum.toFixed(2)} €) übersteigt das übergeordnete Budget (${c.parentBudget.toFixed(2)} €)`), + { statusCode: 400 } + ); + } + } +} + 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 }, 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); + } 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) @@ -377,6 +487,7 @@ async function createKonto( return result.rows[0]; } catch (error: any) { logger.error('BuchhaltungService.createKonto failed', { error }); + if (error.statusCode) throw error; if (error.code === '23505' && error.constraint === 'buchhaltung_konten_haushaltsjahr_id_kontonummer_key') { throw Object.assign(new Error(`Kontonummer "${data.kontonummer}" existiert bereits in diesem Haushaltsjahr`), { statusCode: 409 }); } @@ -389,6 +500,22 @@ async function updateKonto( 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 { + // Budget validation for sub-pots + const hasBudgetChange = data.budget_gwg !== undefined || data.budget_anlagen !== undefined || data.budget_instandhaltung !== undefined; + const hasParentChange = data.parent_id !== undefined; + if (hasBudgetChange || hasParentChange) { + const current = await pool.query(`SELECT parent_id, budget_gwg, budget_anlagen, budget_instandhaltung FROM buchhaltung_konten WHERE id = $1`, [id]); + if (current.rows[0]) { + const parentId = data.parent_id !== undefined ? data.parent_id : current.rows[0].parent_id; + if (parentId) { + const bGwg = data.budget_gwg !== undefined ? data.budget_gwg : parseFloat(current.rows[0].budget_gwg); + const bAnl = data.budget_anlagen !== undefined ? data.budget_anlagen : parseFloat(current.rows[0].budget_anlagen); + const bIns = data.budget_instandhaltung !== undefined ? data.budget_instandhaltung : parseFloat(current.rows[0].budget_instandhaltung); + await validateSubPotBudget(parentId, bGwg, bAnl, bIns, id); + } + } + } + const fields: string[] = []; const values: unknown[] = []; let idx = 1; @@ -407,8 +534,9 @@ async function updateKonto( values ); return result.rows[0] || null; - } catch (error) { + } catch (error: any) { logger.error('BuchhaltungService.updateKonto failed', { error, id }); + if (error.statusCode) throw error; throw new Error('Konto konnte nicht aktualisiert werden'); } } @@ -953,14 +1081,16 @@ async function createWiederkehrend( intervall: string; naechste_ausfuehrung: string; aktiv?: boolean; + ausfuehrungstag?: string; + ausfuehrungs_monat?: number; }, userId: string ) { try { const result = await pool.query( `INSERT INTO buchhaltung_wiederkehrend - (bezeichnung, konto_id, bankkonto_id, typ, betrag, beschreibung, empfaenger_auftraggeber, intervall, naechste_ausfuehrung, aktiv, erstellt_von) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + (bezeichnung, konto_id, bankkonto_id, typ, betrag, beschreibung, empfaenger_auftraggeber, intervall, naechste_ausfuehrung, aktiv, erstellt_von, ausfuehrungstag, ausfuehrungs_monat) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, [ data.bezeichnung, @@ -974,6 +1104,8 @@ async function createWiederkehrend( data.naechste_ausfuehrung, data.aktiv !== false, userId, + data.ausfuehrungstag || 'erster', + data.ausfuehrungs_monat ?? null, ] ); return result.rows[0]; @@ -996,6 +1128,8 @@ async function updateWiederkehrend( intervall?: string; naechste_ausfuehrung?: string; aktiv?: boolean; + ausfuehrungstag?: string; + ausfuehrungs_monat?: number | null; } ) { try { @@ -1012,6 +1146,8 @@ async function updateWiederkehrend( if (data.intervall !== undefined) { fields.push(`intervall = $${idx++}`); values.push(data.intervall); } if (data.naechste_ausfuehrung !== undefined) { fields.push(`naechste_ausfuehrung = $${idx++}`); values.push(data.naechste_ausfuehrung); } if (data.aktiv !== undefined) { fields.push(`aktiv = $${idx++}`); values.push(data.aktiv); } + if (data.ausfuehrungstag !== undefined) { fields.push(`ausfuehrungstag = $${idx++}`); values.push(data.ausfuehrungstag); } + if (data.ausfuehrungs_monat !== undefined) { fields.push(`ausfuehrungs_monat = $${idx++}`); values.push(data.ausfuehrungs_monat); } if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); values.push(id); const result = await pool.query( @@ -1100,6 +1236,10 @@ async function exportTransaktionenCsv(haushaltsjahrId: number): Promise // --------------------------------------------------------------------------- const buchhaltungService = { + getKategorien, + createKategorie, + updateKategorie, + deleteKategorie, getAllHaushaltsjahre, getHaushaltsjahrById, getCurrentHaushaltsjahr, diff --git a/frontend/src/pages/Buchhaltung.tsx b/frontend/src/pages/Buchhaltung.tsx index ee28200..8a77011 100644 --- a/frontend/src/pages/Buchhaltung.tsx +++ b/frontend/src/pages/Buchhaltung.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { + Alert, Badge, Box, Button, @@ -62,6 +63,7 @@ import type { Bankkonto, BankkontoFormData, Konto, KontoFormData, KontoTreeNode, + Kategorie, Transaktion, TransaktionFormData, TransaktionFilters, TransaktionStatus, AusgabenTyp, @@ -184,20 +186,35 @@ function KontoDialog({ haushaltsjahrId, existing, konten, + kategorien = [], onSave, + externalError, }: { open: boolean; onClose: () => void; haushaltsjahrId: number; existing?: Konto; konten: Konto[]; + kategorien?: Kategorie[]; onSave: (data: KontoFormData) => void; + externalError?: string | null; }) { - const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: 0, bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, parent_id: null, notizen: '' }; + const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: 0, bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, parent_id: null, kategorie_id: null, notizen: '' }; const [form, setForm] = useState(empty); + const [saveError, setSaveError] = useState(null); const selectedParent = konten.find(k => k.id === form.parent_id); + // Compute sibling budget usage for parent reference + const siblingBudgets = selectedParent ? (() => { + const siblings = konten.filter(k => k.parent_id === selectedParent.id && k.id !== existing?.id); + return { + gwg: siblings.reduce((s, k) => s + Number(k.budget_gwg || 0), 0), + anlagen: siblings.reduce((s, k) => s + Number(k.budget_anlagen || 0), 0), + instandhaltung: siblings.reduce((s, k) => s + Number(k.budget_instandhaltung || 0), 0), + }; + })() : null; + // suffix = form.kontonummer - parent.kontonummer (arithmetic) const suffixValue = selectedParent ? form.kontonummer - selectedParent.kontonummer : form.kontonummer; @@ -217,7 +234,7 @@ function KontoDialog({ useEffect(() => { if (existing) { - setForm({ haushaltsjahr_id: haushaltsjahrId, 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 || '' }); + setForm({ haushaltsjahr_id: haushaltsjahrId, 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, kategorie_id: existing.kategorie_id ?? null, notizen: existing.notizen || '' }); } else { setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId }); } @@ -258,10 +275,42 @@ function KontoDialog({ } + {kategorien.length > 0 && ( + + Kategorie + + + )} setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} /> + {selectedParent && siblingBudgets && ( + + Eltern-Budget: {fmtEur(selectedParent.budget_gwg)}, vergeben: {fmtEur(siblingBudgets.gwg)}, verfügbar: {fmtEur(selectedParent.budget_gwg - siblingBudgets.gwg)} + + )} setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} /> + {selectedParent && siblingBudgets && ( + + Eltern-Budget: {fmtEur(selectedParent.budget_anlagen)}, vergeben: {fmtEur(siblingBudgets.anlagen)}, verfügbar: {fmtEur(selectedParent.budget_anlagen - siblingBudgets.anlagen)} + + )} setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} /> + {selectedParent && siblingBudgets && ( + + Eltern-Budget: {fmtEur(selectedParent.budget_instandhaltung)}, vergeben: {fmtEur(siblingBudgets.instandhaltung)}, verfügbar: {fmtEur(selectedParent.budget_instandhaltung - siblingBudgets.instandhaltung)} + + )} setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} /> + {saveError && setSaveError(null)}>{saveError}} + {!saveError && externalError && {externalError}} @@ -493,18 +542,23 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { queryFn: () => buchhaltungApi.getKontenTree(selectedJahrId!), enabled: selectedJahrId != null, }); + const { data: kategorien = [] } = useQuery({ + queryKey: ['buchhaltung-kategorien', selectedJahrId], + queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!), + enabled: selectedJahrId != null, + }); const tree = buildTree(treeData); - const sumBudgetGwg = treeData.reduce((s, k) => s + Number(k.budget_gwg), 0); - const sumBudgetAnlagen = treeData.reduce((s, k) => s + Number(k.budget_anlagen), 0); - const sumBudgetInst = treeData.reduce((s, k) => s + Number(k.budget_instandhaltung), 0); + const sumBudgetGwg = treeData.reduce((s, k) => s + Number(k.budget_gwg || 0), 0); + const sumBudgetAnlagen = treeData.reduce((s, k) => s + Number(k.budget_anlagen || 0), 0); + const sumBudgetInst = treeData.reduce((s, k) => s + Number(k.budget_instandhaltung || 0), 0); const sumBudgetGesamt = sumBudgetGwg + sumBudgetAnlagen + sumBudgetInst; - const sumSpentGwg = treeData.reduce((s, k) => s + Number(k.spent_gwg), 0); - const sumSpentAnlagen = treeData.reduce((s, k) => s + Number(k.spent_anlagen), 0); - const sumSpentInst = treeData.reduce((s, k) => s + Number(k.spent_instandhaltung), 0); + const sumSpentGwg = treeData.reduce((s, k) => s + Number(k.spent_gwg || 0), 0); + const sumSpentAnlagen = treeData.reduce((s, k) => s + Number(k.spent_anlagen || 0), 0); + const sumSpentInst = treeData.reduce((s, k) => s + Number(k.spent_instandhaltung || 0), 0); const sumSpentGesamt = sumSpentGwg + sumSpentAnlagen + sumSpentInst; - const sumEinnahmen = treeData.reduce((s, k) => s + Number(k.einnahmen_betrag), 0); + const sumEinnahmen = treeData.reduce((s, k) => s + Number(k.einnahmen_betrag || 0), 0); const totalEinnahmen = sumEinnahmen; const totalAusgaben = sumSpentGesamt; @@ -567,9 +621,54 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {tree.length === 0 && ( Keine Konten )} - {tree.map(k => ( - navigate(`/buchhaltung/konto/${id}`)} /> - ))} + {(() => { + const grouped = new Map(); + tree.forEach(k => { + const key = k.kategorie_id ?? null; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(k); + }); + const categoryOrder = kategorien.map(kat => kat.id); + const sortedKeys = [...grouped.keys()].sort((a, b) => { + if (a === null) return 1; + if (b === null) return -1; + return categoryOrder.indexOf(a) - categoryOrder.indexOf(b); + }); + const hasMultipleGroups = sortedKeys.length > 1 || (sortedKeys.length === 1 && sortedKeys[0] !== null); + return sortedKeys.flatMap(key => { + const items = grouped.get(key)!; + const rows: React.ReactNode[] = []; + if (hasMultipleGroups) { + const katName = key !== null ? kategorien.find(k => k.id === key)?.bezeichnung : 'Ohne Kategorie'; + const catBudgetGwg = items.reduce((s, k) => s + Number(k.budget_gwg || 0), 0); + const catBudgetAnl = items.reduce((s, k) => s + Number(k.budget_anlagen || 0), 0); + const catBudgetInst = items.reduce((s, k) => s + Number(k.budget_instandhaltung || 0), 0); + const catSpentGwg = items.reduce((s, k) => s + Number(k.spent_gwg || 0), 0); + const catSpentAnl = items.reduce((s, k) => s + Number(k.spent_anlagen || 0), 0); + const catSpentInst = items.reduce((s, k) => s + Number(k.spent_instandhaltung || 0), 0); + const catEinnahmen = items.reduce((s, k) => s + Number(k.einnahmen_betrag || 0), 0); + rows.push( + + {katName} + {fmtEur(catBudgetGwg)} + {fmtEur(catBudgetAnl)} + {fmtEur(catBudgetInst)} + {fmtEur(catBudgetGwg + catBudgetAnl + catBudgetInst)} + {fmtEur(catSpentGwg)} + {fmtEur(catSpentAnl)} + {fmtEur(catSpentInst)} + {fmtEur(catSpentGwg + catSpentAnl + catSpentInst)} + {fmtEur(catEinnahmen)} + + + ); + } + items.forEach(k => rows.push( + navigate(`/buchhaltung/konto/${id}`)} /> + )); + return rows; + }); + })()} {tree.length > 0 && ( Gesamt @@ -702,7 +801,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {/* Filters */} - + Haushaltsjahr - + Status - + Typ + + Ausführungstag + + + {form.intervall === 'jaehrlich' && ( + + Ausführungsmonat + + + )} setForm(f => ({ ...f, naechste_ausfuehrung: e.target.value }))} InputLabelProps={{ shrink: true }} required /> Konto @@ -989,6 +1110,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { const { hasPermission } = usePermissionContext(); const [subTab, setSubTab] = useState(0); const [kontoDialog, setKontoDialog] = useState<{ open: boolean; existing?: Konto }>({ open: false }); + 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 }); @@ -1007,18 +1129,43 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { 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!), + enabled: selectedJahrId != null, + }); + + const [newKategorie, setNewKategorie] = useState(''); + const [addingKategorie, setAddingKategorie] = useState(false); + + const createKategorieMut = useMutation({ + mutationFn: buchhaltungApi.createKategorie, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-kategorien'] }); setNewKategorie(''); setAddingKategorie(false); showSuccess('Kategorie erstellt'); }, + onError: () => showError('Kategorie konnte nicht erstellt werden'), + }); + const deleteKategorieMut = useMutation({ + mutationFn: buchhaltungApi.deleteKategorie, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-kategorien'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); showSuccess('Kategorie gelöscht'); }, + onError: () => showError('Kategorie konnte nicht gelöscht werden'), + }); const canManage = hasPermission('buchhaltung:manage_accounts'); const createKontoMut = useMutation({ mutationFn: buchhaltungApi.createKonto, - onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); setKontoDialog({ open: false }); showSuccess('Konto erstellt'); }, - onError: (err: any) => showError(err?.message || err?.response?.data?.message || 'Konto konnte nicht erstellt werden'), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); setKontoDialog({ open: false }); setKontoSaveError(null); showSuccess('Konto erstellt'); }, + onError: (err: any) => { + const msg = err?.response?.data?.message || err?.message || 'Konto konnte nicht erstellt werden'; + if (err?.response?.status === 400) { setKontoSaveError(msg); } else { showError(msg); } + }, }); const updateKontoMut = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateKonto(id, data), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); setKontoDialog({ open: false }); showSuccess('Konto aktualisiert'); }, - onError: () => showError('Konto konnte nicht aktualisiert werden'), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); setKontoDialog({ open: false }); setKontoSaveError(null); showSuccess('Konto aktualisiert'); }, + onError: (err: any) => { + const msg = err?.response?.data?.message || err?.message || 'Konto konnte nicht aktualisiert werden'; + if (err?.response?.status === 400) { setKontoSaveError(msg); } else { showError(msg); } + }, }); const createBankMut = useMutation({ mutationFn: buchhaltungApi.createBankkonto, @@ -1070,12 +1217,14 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { return ( - setSubTab(v)} sx={{ mb: 2 }}> - - - - - + + setSubTab(v)}> + + + + + + {/* Sub-Tab 0: Konten */} {subTab === 0 && ( @@ -1089,36 +1238,92 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {canManage && } - - - - - Konto - GWG - Anlagen - Instandh. - Gesamt - - - - - {kontenTree.length === 0 && Keine Konten} - {kontenTree.map(k => ( - navigate('/buchhaltung/konto/' + id + '/verwalten')} - /> - ))} - -
-
+ {selectedJahrId && canManage && ( + + {kategorien.map(kat => ( + deleteKategorieMut.mutate(kat.id)} size="small" /> + ))} + {addingKategorie ? ( + setNewKategorie(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && newKategorie.trim() && selectedJahrId) { + createKategorieMut.mutate({ haushaltsjahr_id: selectedJahrId, bezeichnung: newKategorie.trim() }); + } else if (e.key === 'Escape') { + setAddingKategorie(false); setNewKategorie(''); + } + }} + onBlur={() => { setAddingKategorie(false); setNewKategorie(''); }} + autoFocus + sx={{ width: 160 }} + /> + ) : ( + setAddingKategorie(true)} /> + )} + + )} + {(() => { + const grouped = new Map(); + kontenTree.forEach(k => { + const key = k.kategorie_id ?? null; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(k); + }); + const categoryOrder = kategorien.map(kat => kat.id); + const sortedKeys = [...grouped.keys()].sort((a, b) => { + if (a === null) return 1; + if (b === null) return -1; + return categoryOrder.indexOf(a) - categoryOrder.indexOf(b); + }); + return sortedKeys.map(key => { + const katName = key !== null ? kategorien.find(k => k.id === key)?.bezeichnung : 'Ohne Kategorie'; + const items = grouped.get(key)!; + return ( + + {(sortedKeys.length > 1 || key !== null) && ( + {katName} + )} + + + + + Konto + GWG + Anlagen + Instandh. + Gesamt + + + + + {items.map(k => ( + navigate('/buchhaltung/konto/' + id + '/verwalten')} + /> + ))} + +
+
+
+ ); + }); + })()} + {kontenTree.length === 0 && selectedJahrId && ( + Keine Konten + )} {selectedJahrId && setKontoDialog({ open: false })} + onClose={() => { setKontoDialog({ open: false }); setKontoSaveError(null); }} haushaltsjahrId={selectedJahrId} existing={kontoDialog.existing} konten={konten} + kategorien={kategorien} + externalError={kontoSaveError} onSave={data => kontoDialog.existing ? updateKontoMut.mutate({ id: kontoDialog.existing.id, data }) : createKontoMut.mutate(data) diff --git a/frontend/src/pages/BuchhaltungKontoDetail.tsx b/frontend/src/pages/BuchhaltungKontoDetail.tsx index 3e8cac5..e0f8fff 100644 --- a/frontend/src/pages/BuchhaltungKontoDetail.tsx +++ b/frontend/src/pages/BuchhaltungKontoDetail.tsx @@ -11,6 +11,8 @@ import { buchhaltungApi } from '../services/buchhaltung'; import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types'; import type { AusgabenTyp } from '../types/buchhaltung.types'; +const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n); + 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; @@ -18,10 +20,10 @@ function BudgetCard({ label, budget, spent }: { label: string; budget: number; s {label} - {Number(spent).toFixed(2).replace('.', ',')} € + {fmtEur(Number(spent))} {budget > 0 && ( <> - Budget: {Number(budget).toFixed(2).replace('.', ',')} € + Budget: {fmtEur(Number(budget))} Einnahmen - {totalEinnahmen.toFixed(2).replace('.', ',')} € + {fmtEur(totalEinnahmen)} @@ -115,11 +117,11 @@ export default function BuchhaltungKontoDetail() { onClick={() => 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)} € + {fmtEur(Number(child.budget_gwg))} + {fmtEur(Number(child.budget_anlagen))} + {fmtEur(Number(child.budget_instandhaltung))} - {(Number(child.budget_gwg) + Number(child.budget_anlagen) + Number(child.budget_instandhaltung)).toFixed(2)} € + {fmtEur(Number(child.budget_gwg) + Number(child.budget_anlagen) + Number(child.budget_instandhaltung))}
))} @@ -158,7 +160,7 @@ export default function BuchhaltungKontoDetail() { {t.ausgaben_typ ? AUSGABEN_TYP_LABELS[t.ausgaben_typ as AusgabenTyp] : '—'} - {t.typ === 'einnahme' ? '+' : '-'}{Number(t.betrag).toFixed(2).replace('.', ',')} € + {t.typ === 'einnahme' ? '+' : '-'}{fmtEur(Number(t.betrag))} {t.status} diff --git a/frontend/src/pages/BuchhaltungKontoManage.tsx b/frontend/src/pages/BuchhaltungKontoManage.tsx index 70820be..f5387b2 100644 --- a/frontend/src/pages/BuchhaltungKontoManage.tsx +++ b/frontend/src/pages/BuchhaltungKontoManage.tsx @@ -3,15 +3,49 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select, - FormControl, InputLabel, CircularProgress, Alert, Dialog, DialogTitle, - DialogContent, DialogActions, Skeleton, + FormControl, InputLabel, Alert, Dialog, DialogTitle, + DialogContent, DialogActions, Skeleton, Divider, LinearProgress, Grid, } from '@mui/material'; -import { ArrowBack, Delete, Save } from '@mui/icons-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'; +const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n); + +function FieldRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + {label} + {value} + + ); +} + +function BudgetBar({ 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} + + {fmtEur(spent)} / {fmtEur(budget)} + + + {budget > 0 && ( + 80 ? 'warning' : 'primary'} + sx={{ height: 6, borderRadius: 3 }} + /> + )} + + ); +} + export default function BuchhaltungKontoManage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -19,8 +53,10 @@ export default function BuchhaltungKontoManage() { const { showSuccess, showError } = useNotification(); const kontoId = Number(id); + const [isEditing, setIsEditing] = useState(false); const [form, setForm] = useState>({}); const [deleteOpen, setDeleteOpen] = useState(false); + const [saveError, setSaveError] = useState(null); const { data, isLoading, isError } = useQuery({ queryKey: ['kontoDetail', kontoId], @@ -28,13 +64,18 @@ export default function BuchhaltungKontoManage() { enabled: !!kontoId, }); - // Load sibling konten for parent selector (exclude self) const { data: alleKonten = [] } = useQuery({ queryKey: ['buchhaltung-konten', data?.konto.haushaltsjahr_id], queryFn: () => buchhaltungApi.getKonten(data!.konto.haushaltsjahr_id), enabled: !!data?.konto.haushaltsjahr_id, }); + const { data: kategorien = [] } = useQuery({ + queryKey: ['buchhaltung-kategorien', data?.konto.haushaltsjahr_id], + queryFn: () => buchhaltungApi.getKategorien(data!.konto.haushaltsjahr_id), + enabled: !!data?.konto.haushaltsjahr_id, + }); + useEffect(() => { if (data?.konto) { const k = data.konto; @@ -46,6 +87,7 @@ export default function BuchhaltungKontoManage() { budget_anlagen: k.budget_anlagen, budget_instandhaltung: k.budget_instandhaltung, parent_id: k.parent_id ?? undefined, + kategorie_id: k.kategorie_id ?? undefined, notizen: k.notizen ?? '', }); } @@ -58,8 +100,13 @@ export default function BuchhaltungKontoManage() { qc.invalidateQueries({ queryKey: ['kontenTree'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); showSuccess('Konto gespeichert'); + setIsEditing(false); + setSaveError(null); + }, + onError: (err: any) => { + const msg = err?.response?.data?.message || err?.message || 'Speichern fehlgeschlagen'; + if (err?.response?.status === 400) { setSaveError(msg); } else { showError(msg); } }, - onError: () => showError('Speichern fehlgeschlagen'), }); const deleteMut = useMutation({ @@ -77,13 +124,47 @@ export default function BuchhaltungKontoManage() { if (isError || !data) return Konto nicht gefunden.; const konto = data.konto; + const { transaktionen } = data; const otherKonten = alleKonten.filter(k => k.id !== kontoId); + const parentKonto = alleKonten.find(k => k.id === konto.parent_id); + const kategorie = kategorien.find(k => k.id === konto.kategorie_id); + + const spentGwg = transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'gwg').reduce((s, t) => s + Number(t.betrag), 0); + const spentAnlagen = transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'anlagen').reduce((s, t) => s + Number(t.betrag), 0); + const spentInst = transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'instandhaltung').reduce((s, t) => s + Number(t.betrag), 0); + + // Sibling budget usage for parent reference + const siblingBudgets = parentKonto ? (() => { + const siblings = alleKonten.filter(k => k.parent_id === parentKonto.id && k.id !== kontoId); + return { + gwg: siblings.reduce((s, k) => s + Number(k.budget_gwg || 0), 0), + anlagen: siblings.reduce((s, k) => s + Number(k.budget_anlagen || 0), 0), + instandhaltung: siblings.reduce((s, k) => s + Number(k.budget_instandhaltung || 0), 0), + }; + })() : null; const handleSave = () => { if (!form.bezeichnung) return; updateMut.mutate(form); }; + const handleCancel = () => { + const k = konto; + setForm({ + haushaltsjahr_id: k.haushaltsjahr_id, + kontonummer: k.kontonummer, + bezeichnung: k.bezeichnung, + budget_gwg: k.budget_gwg, + budget_anlagen: k.budget_anlagen, + budget_instandhaltung: k.budget_instandhaltung, + parent_id: k.parent_id ?? undefined, + kategorie_id: k.kategorie_id ?? undefined, + notizen: k.notizen ?? '', + }); + setIsEditing(false); + setSaveError(null); + }; + return ( @@ -93,88 +174,83 @@ export default function BuchhaltungKontoManage() { {konto.kontonummer} — {konto.bezeichnung} - - + {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} - - - setForm(f => ({ ...f, kontonummer: Number(e.target.value) }))} - required - fullWidth - /> - setForm(f => ({ ...f, bezeichnung: e.target.value }))} - required - fullWidth - /> - - Übergeordnetes Konto - - - setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} - inputProps={{ step: '0.01', min: '0' }} - fullWidth - /> - setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} - inputProps={{ step: '0.01', min: '0' }} - fullWidth - /> - setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} - inputProps={{ step: '0.01', min: '0' }} - fullWidth - /> - setForm(f => ({ ...f, notizen: e.target.value }))} - multiline - rows={3} - fullWidth - /> - - + + {/* Section: Allgemein */} + + Allgemein + + {isEditing ? ( + + setForm(f => ({ ...f, kontonummer: Number(e.target.value) }))} required fullWidth size="small" /> + setForm(f => ({ ...f, bezeichnung: e.target.value }))} required fullWidth size="small" /> + + Übergeordnetes Konto + + + {kategorien.length > 0 && ( + + Kategorie + + + )} + setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={3} fullWidth size="small" /> + + ) : ( + <> + + + + + + + )} + + + {/* Section: Budget */} + + Budget + + {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} /> + {saveError && setSaveError(null)}>{saveError}} + + ) : ( + <> + + + + + + + + )} + + setDeleteOpen(false)}> Konto löschen diff --git a/frontend/src/services/buchhaltung.ts b/frontend/src/services/buchhaltung.ts index a38a02f..17d3765 100644 --- a/frontend/src/services/buchhaltung.ts +++ b/frontend/src/services/buchhaltung.ts @@ -9,6 +9,7 @@ import type { BuchhaltungStats, WiederkehrendBuchung, WiederkehrendFormData, Freigabe, + Kategorie, } from '../types/buchhaltung.types'; export const buchhaltungApi = { @@ -165,6 +166,23 @@ export const buchhaltungApi = { return r.data; }, + // ── Kategorien ────────────────────────────────────────────────────────────── + getKategorien: async (haushaltsjahrId: number): Promise => { + const r = await api.get(`/api/buchhaltung/kategorien?haushaltsjahr_id=${haushaltsjahrId}`); + return r.data.data; + }, + createKategorie: async (data: { haushaltsjahr_id: number; bezeichnung: string; sortierung?: number }): Promise => { + const r = await api.post('/api/buchhaltung/kategorien', data); + return r.data.data; + }, + updateKategorie: async (id: number, data: Partial<{ bezeichnung: string; sortierung: number }>): Promise => { + const r = await api.patch(`/api/buchhaltung/kategorien/${id}`, data); + return r.data.data; + }, + deleteKategorie: async (id: number): Promise => { + await api.delete(`/api/buchhaltung/kategorien/${id}`); + }, + // ── Freigaben ───────────────────────────────────────────────────────────────── requestFreigabe: async (transaktionId: number): Promise => { const r = await api.post(`/api/buchhaltung/${transaktionId}/freigabe`); diff --git a/frontend/src/types/buchhaltung.types.ts b/frontend/src/types/buchhaltung.types.ts index 9944b92..b46b3e7 100644 --- a/frontend/src/types/buchhaltung.types.ts +++ b/frontend/src/types/buchhaltung.types.ts @@ -48,6 +48,13 @@ export const INTERVALL_LABELS: Record = { }; // Entities +export interface Kategorie { + id: number; + haushaltsjahr_id: number; + bezeichnung: string; + sortierung: number; +} + export interface KontoTyp { id: number; bezeichnung: string; @@ -87,6 +94,7 @@ export interface Konto { kontonummer: number; bezeichnung: string; parent_id: number | null; + kategorie_id: number | null; budget_gwg: number; budget_anlagen: number; budget_instandhaltung: number; @@ -106,6 +114,7 @@ export interface KontoTreeNode extends Konto { spent_anlagen: number; spent_instandhaltung: number; einnahmen_betrag: number; + kategorie_bezeichnung?: string; children: KontoTreeNode[]; } @@ -177,6 +186,8 @@ export interface WiederkehrendBuchung { empfaenger_auftraggeber: string | null; intervall: WiederkehrendIntervall; naechste_ausfuehrung: string; + ausfuehrungstag: 'erster' | 'mitte' | 'letzter'; + ausfuehrungs_monat: number | null; aktiv: boolean; erstellt_von: string | null; erstellt_am: string; @@ -225,6 +236,7 @@ export interface KontoFormData { budget_anlagen: number; budget_instandhaltung: number; parent_id?: number | null; + kategorie_id?: number | null; notizen?: string; } @@ -264,6 +276,8 @@ export interface WiederkehrendFormData { empfaenger_auftraggeber?: string; intervall: WiederkehrendIntervall; naechste_ausfuehrung: string; + ausfuehrungstag?: 'erster' | 'mitte' | 'letzter'; + ausfuehrungs_monat?: number; aktiv?: boolean; }