diff --git a/backend/src/controllers/buchhaltung.controller.ts b/backend/src/controllers/buchhaltung.controller.ts index 660ce1e..dd23bcc 100644 --- a/backend/src/controllers/buchhaltung.controller.ts +++ b/backend/src/controllers/buchhaltung.controller.ts @@ -201,6 +201,23 @@ class BuchhaltungController { } } + async getBankkontoStatement(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 filters = { + von: req.query.von as string | undefined, + bis: req.query.bis as string | undefined, + }; + const data = await buchhaltungService.getBankkontoStatement(id, filters); + if (!data) { res.status(404).json({ success: false, message: 'Bankkonto nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.getBankkontoStatement', { error }); + res.status(500).json({ success: false, message: 'Kontoauszug konnte nicht geladen werden' }); + } + } + // ── Konten ─────────────────────────────────────────────────────────────────── async listKonten(req: Request, res: Response): Promise { @@ -411,6 +428,18 @@ class BuchhaltungController { } } + // ── Transfers ───────────────────────────────────────────────────────────────── + + async createTransfer(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createTransfer(req.body, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createTransfer', { error }); + res.status(500).json({ success: false, message: 'Umbuchung konnte nicht erstellt werden' }); + } + } + // ── Belege ─────────────────────────────────────────────────────────────────── async uploadBeleg(req: Request, res: Response): Promise { @@ -566,6 +595,128 @@ class BuchhaltungController { } } + // ── Planung ───────────────────────────────────────────────────────────────── + + async listPlanungen(_req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.listPlanungen(); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.listPlanungen', { error }); + res.status(500).json({ success: false, message: 'Planungen konnten nicht geladen werden' }); + } + } + + async getPlanung(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.getPlanungById(id); + if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.getPlanung', { error }); + res.status(500).json({ success: false, message: 'Planung konnte nicht geladen werden' }); + } + } + + async createPlanung(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createPlanung(req.body, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createPlanung', { error }); + res.status(500).json({ success: false, message: 'Planung konnte nicht erstellt werden' }); + } + } + + async updatePlanung(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.updatePlanung(id, req.body); + if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.updatePlanung', { error }); + res.status(500).json({ success: false, message: 'Planung konnte nicht aktualisiert werden' }); + } + } + + async deletePlanung(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.deletePlanung(id); + res.json({ success: true }); + } catch (error) { + logger.error('BuchhaltungController.deletePlanung', { error }); + res.status(500).json({ success: false, message: 'Planung konnte nicht gelöscht werden' }); + } + } + + async createPlanposition(req: Request, res: Response): Promise { + const planungId = parseInt(param(req, 'id'), 10); + if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.createPlanposition(planungId, req.body); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createPlanposition', { error }); + res.status(500).json({ success: false, message: 'Planposition konnte nicht erstellt werden' }); + } + } + + async updatePlanposition(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.updatePlanposition(id, req.body); + if (!data) { res.status(404).json({ success: false, message: 'Planposition nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.updatePlanposition', { error }); + res.status(500).json({ success: false, message: 'Planposition konnte nicht aktualisiert werden' }); + } + } + + async deletePlanposition(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.deletePlanposition(id); + res.json({ success: true }); + } catch (error) { + logger.error('BuchhaltungController.deletePlanposition', { error }); + res.status(500).json({ success: false, message: 'Planposition konnte nicht gelöscht werden' }); + } + } + + async listPlanpositionen(req: Request, res: Response): Promise { + const planungId = parseInt(param(req, 'id'), 10); + if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.getPlanpositionenByPlanung(planungId); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.listPlanpositionen', { error }); + res.status(500).json({ success: false, message: 'Planpositionen konnten nicht geladen werden' }); + } + } + + async createHaushaltsjahrFromPlan(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.createHaushaltsjahrFromPlan(id, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error: any) { + logger.error('BuchhaltungController.createHaushaltsjahrFromPlan', { error }); + const status = error.statusCode || 500; + res.status(status).json({ success: false, message: error.message || 'Haushaltsjahr konnte nicht erstellt werden' }); + } + } + // ── Freigaben ──────────────────────────────────────────────────────────────── async requestFreigabe(req: Request, res: Response): Promise { diff --git a/backend/src/database/migrations/082_buchhaltung_transfer.sql b/backend/src/database/migrations/082_buchhaltung_transfer.sql new file mode 100644 index 0000000..0918f12 --- /dev/null +++ b/backend/src/database/migrations/082_buchhaltung_transfer.sql @@ -0,0 +1,18 @@ +-- Migration 082: Add 'transfer' transaction type + target bank account column + +-- 1. Drop and recreate the typ CHECK constraint to include 'transfer' +ALTER TABLE buchhaltung_transaktionen + DROP CONSTRAINT IF EXISTS buchhaltung_transaktionen_typ_check; + +ALTER TABLE buchhaltung_transaktionen + ADD CONSTRAINT buchhaltung_transaktionen_typ_check + CHECK (typ IN ('einnahme', 'ausgabe', 'transfer')); + +-- 2. Add target bank account column for transfers +ALTER TABLE buchhaltung_transaktionen + ADD COLUMN IF NOT EXISTS transfer_ziel_bankkonto_id INT + REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_buch_trans_transfer_ziel + ON buchhaltung_transaktionen(transfer_ziel_bankkonto_id) + WHERE transfer_ziel_bankkonto_id IS NOT NULL; diff --git a/backend/src/routes/buchhaltung.routes.ts b/backend/src/routes/buchhaltung.routes.ts index 95a3cf6..d81ad5b 100644 --- a/backend/src/routes/buchhaltung.routes.ts +++ b/backend/src/routes/buchhaltung.routes.ts @@ -33,6 +33,7 @@ router.get('/bankkonten', authenticate, requirePermission('buchhaltung:vie router.post('/bankkonten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createBankkonto.bind(buchhaltungController)); router.patch('/bankkonten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateBankkonto.bind(buchhaltungController)); router.delete('/bankkonten/:id',authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteBankkonto.bind(buchhaltungController)); +router.get('/bankkonten/:id/transaktionen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getBankkontoStatement.bind(buchhaltungController)); // ── Konten ──────────────────────────────────────────────────────────────────── router.get('/konten/tree', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontenTree.bind(buchhaltungController)); @@ -60,6 +61,18 @@ router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung 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)); +// ── Planung ────────────────────────────────────────────────────────────────── +router.get('/planung', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listPlanungen.bind(buchhaltungController)); +router.post('/planung', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createPlanung.bind(buchhaltungController)); +router.get('/planung/:id', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPlanung.bind(buchhaltungController)); +router.put('/planung/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updatePlanung.bind(buchhaltungController)); +router.delete('/planung/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deletePlanung.bind(buchhaltungController)); +router.get('/planung/:id/positionen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listPlanpositionen.bind(buchhaltungController)); +router.post('/planung/:id/positionen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createPlanposition.bind(buchhaltungController)); +router.put('/planung/positionen/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updatePlanposition.bind(buchhaltungController)); +router.delete('/planung/positionen/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deletePlanposition.bind(buchhaltungController)); +router.post('/planung/:id/create-haushaltsjahr', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createHaushaltsjahrFromPlan.bind(buchhaltungController)); + // ── Audit ────────────────────────────────────────────────────────────────────── router.get('/audit/:transaktionId', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getAudit.bind(buchhaltungController)); @@ -70,6 +83,9 @@ router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), router.patch('/freigaben/:id/genehmigen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.approveFreigabe.bind(buchhaltungController)); router.patch('/freigaben/:id/ablehnen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.rejectFreigabe.bind(buchhaltungController)); +// ── Transfers ───────────────────────────────────────────────────────────────── +router.post('/transfers', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransfer.bind(buchhaltungController)); + // ── Transaktionen (list/create — before /:id) ───────────────────────────────── router.get('/', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listTransaktionen.bind(buchhaltungController)); router.post('/', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransaktion.bind(buchhaltungController)); diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index e9ce2ae..de52aec 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -8,6 +8,7 @@ import fs from 'fs'; import notificationService from './notification.service'; import { permissionService } from './permission.service'; import ausruestungsanfrageService from './ausruestungsanfrage.service'; +import buchhaltungService from './buchhaltung.service'; // --------------------------------------------------------------------------- // Catalog (shared ausruestung_artikel via ausruestungsanfrageService) @@ -381,6 +382,17 @@ async function updateOrderStatus(id: number, status: string, userId: string, for } } + // Block transition to abgeschlossen if positions have missing prices + if (status === 'abgeschlossen') { + const posCheck = await pool.query( + `SELECT COUNT(*) FROM bestellpositionen WHERE bestellung_id = $1 AND (einzelpreis IS NULL OR einzelpreis = 0)`, + [id] + ); + if (parseInt(posCheck.rows[0].count) > 0) { + throw new Error('Alle Positionen müssen einen Einzelpreis haben, bevor die Bestellung abgeschlossen werden kann.'); + } + } + const updates: string[] = ['status = $1', 'aktualisiert_am = NOW()']; const params: unknown[] = [status]; let paramIndex = 2; @@ -452,6 +464,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for }); } + // Create pending buchhaltung transaction when order is completed + if (status === 'abgeschlossen') { + buchhaltungService.createPendingFromBestellung(id, userId).catch(err => + logger.error('createPendingFromBestellung failed (non-fatal)', { err, orderId: id }) + ); + } + return result.rows[0]; } catch (error) { logger.error('BestellungService.updateOrderStatus failed', { error, id }); diff --git a/backend/src/services/buchhaltung.service.ts b/backend/src/services/buchhaltung.service.ts index 5912339..486b9d1 100644 --- a/backend/src/services/buchhaltung.service.ts +++ b/backend/src/services/buchhaltung.service.ts @@ -354,6 +354,98 @@ async function deactivateBankkonto(id: number) { } } +async function getBankkontoStatement( + bankkontoId: number, + filters?: { von?: string; bis?: string } +) { + try { + // 1. Get the bank account + const kontoResult = await pool.query( + `SELECT * FROM buchhaltung_bankkonten WHERE id = $1`, + [bankkontoId] + ); + if (!kontoResult.rows[0]) return null; + + // 2. Fetch all transactions involving this bank account (both directions for transfers) + const conditions: string[] = [ + `(t.bankkonto_id = $1 OR t.transfer_ziel_bankkonto_id = $1)` + ]; + const values: unknown[] = [bankkontoId]; + let idx = 2; + + if (filters?.von) { conditions.push(`t.datum >= $${idx++}`); values.push(filters.von); } + if (filters?.bis) { conditions.push(`t.datum <= $${idx++}`); values.push(filters.bis); } + + // Only include booked/approved transactions for the statement + conditions.push(`t.status IN ('gebucht', 'freigegeben')`); + + const where = `WHERE ${conditions.join(' AND ')}`; + const txResult = await pool.query( + `SELECT t.*, + k.bezeichnung as konto_bezeichnung, + k.kontonummer as konto_kontonummer, + bk.bezeichnung as bankkonto_bezeichnung, + bk2.bezeichnung AS transfer_ziel_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 + LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id + ${where} + ORDER BY t.datum ASC, t.id ASC`, + values + ); + + // 3. Compute running balance + let laufenderSaldo = 0; + let gesamteinnahmen = 0; + let gesamtausgaben = 0; + + const rows = txResult.rows.map(row => { + const betrag = parseFloat(row.betrag); + let aenderung = 0; + + if (row.typ === 'transfer') { + if (row.bankkonto_id === bankkontoId) { + // Money leaving this account + aenderung = -betrag; + gesamtausgaben += betrag; + } else { + // Money arriving at this account (transfer_ziel_bankkonto_id matches) + aenderung = betrag; + gesamteinnahmen += betrag; + } + } else if (row.typ === 'einnahme') { + aenderung = betrag; + gesamteinnahmen += betrag; + } else if (row.typ === 'ausgabe') { + aenderung = -betrag; + gesamtausgaben += betrag; + } + + laufenderSaldo += aenderung; + + return { + ...row, + aenderung, + laufender_saldo: Math.round(laufenderSaldo * 100) / 100, + }; + }); + + return { + konto: kontoResult.rows[0], + rows, + summary: { + gesamteinnahmen: Math.round(gesamteinnahmen * 100) / 100, + gesamtausgaben: Math.round(gesamtausgaben * 100) / 100, + saldo: Math.round(laufenderSaldo * 100) / 100, + }, + }; + } catch (error) { + logger.error('BuchhaltungService.getBankkontoStatement failed', { error, bankkontoId }); + throw new Error('Kontoauszug konnte nicht geladen werden'); + } +} + // --------------------------------------------------------------------------- // Konten (Budget Accounts) // --------------------------------------------------------------------------- @@ -445,10 +537,12 @@ async function getKontoDetail(kontoId: number) { `SELECT t.*, k.bezeichnung as konto_bezeichnung, k.kontonummer as konto_kontonummer, - bk.bezeichnung as bankkonto_bezeichnung + bk.bezeichnung as bankkonto_bezeichnung, + bk2.bezeichnung AS transfer_ziel_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 + LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id WHERE t.konto_id = $1 ORDER BY t.datum DESC, t.id DESC`, [kontoId] @@ -702,10 +796,12 @@ async function listTransaktionen(filters: { `SELECT t.*, k.bezeichnung as konto_bezeichnung, k.kontonummer as konto_kontonummer, - bk.bezeichnung as bankkonto_bezeichnung + bk.bezeichnung as bankkonto_bezeichnung, + bk2.bezeichnung AS transfer_ziel_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 + LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id ${where} ORDER BY t.datum DESC, t.id DESC`, values @@ -723,10 +819,12 @@ async function getTransaktionById(id: number) { `SELECT t.*, k.bezeichnung as konto_bezeichnung, k.kontonummer as konto_kontonummer, - bk.bezeichnung as bankkonto_bezeichnung + bk.bezeichnung as bankkonto_bezeichnung, + bk2.bezeichnung AS transfer_ziel_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 + LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id WHERE t.id = $1`, [id] ); @@ -909,6 +1007,59 @@ async function deleteTransaktion(id: number) { } } +// --------------------------------------------------------------------------- +// Transfers (Umbuchungen) +// --------------------------------------------------------------------------- + +async function createTransfer( + data: { + haushaltsjahr_id: number; + bankkonto_id: number; + transfer_ziel_bankkonto_id: number; + betrag: number; + datum: string; + beschreibung?: string; + beleg_nr?: string; + }, + userId: string +) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Debit row (money leaves source account) + const debitResult = await client.query( + `INSERT INTO buchhaltung_transaktionen + (haushaltsjahr_id, bankkonto_id, transfer_ziel_bankkonto_id, typ, betrag, datum, beschreibung, beleg_nr, erstellt_von, status) + VALUES ($1, $2, $3, 'transfer', $4, $5, $6, $7, $8, 'gebucht') + RETURNING *`, + [data.haushaltsjahr_id, data.bankkonto_id, data.transfer_ziel_bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.beleg_nr || null, userId] + ); + + // Credit row (money arrives at target account) + const creditResult = await client.query( + `INSERT INTO buchhaltung_transaktionen + (haushaltsjahr_id, bankkonto_id, transfer_ziel_bankkonto_id, typ, betrag, datum, beschreibung, beleg_nr, erstellt_von, status) + VALUES ($1, $2, $3, 'transfer', $4, $5, $6, $7, $8, 'gebucht') + RETURNING *`, + [data.haushaltsjahr_id, data.transfer_ziel_bankkonto_id, data.bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.beleg_nr || null, userId] + ); + + await client.query('COMMIT'); + + await logAudit(debitResult.rows[0].id, 'transfer_erstellt', { betrag: data.betrag, von: data.bankkonto_id, nach: data.transfer_ziel_bankkonto_id }, userId); + await logAudit(creditResult.rows[0].id, 'transfer_erstellt', { betrag: data.betrag, von: data.bankkonto_id, nach: data.transfer_ziel_bankkonto_id }, userId); + + return { debit: debitResult.rows[0], credit: creditResult.rows[0] }; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('BuchhaltungService.createTransfer failed', { error }); + throw new Error('Umbuchung konnte nicht erstellt werden'); + } finally { + client.release(); + } +} + // --------------------------------------------------------------------------- // Belege (Receipts) // --------------------------------------------------------------------------- @@ -1435,6 +1586,331 @@ async function getErstattungLinks(transaktionId: number): Promise<{ } } +// --------------------------------------------------------------------------- +// Bestellung → Buchhaltung Integration +// --------------------------------------------------------------------------- + +async function createPendingFromBestellung(bestellungId: number, userId: string) { + try { + // 1. Fetch the bestellung with total + const bestResult = await pool.query( + `SELECT b.*, l.name AS lieferant_name, COALESCE(SUM(bp.menge * bp.einzelpreis), 0) AS gesamtbetrag + FROM bestellungen b + LEFT JOIN lieferanten l ON l.id = b.lieferant_id + LEFT JOIN bestellpositionen bp ON bp.bestellung_id = b.id + WHERE b.id = $1 + GROUP BY b.id, l.name`, + [bestellungId] + ); + if (!bestResult.rows[0]) { + logger.warn('createPendingFromBestellung: Bestellung not found', { bestellungId }); + return; + } + const bestellung = bestResult.rows[0]; + const gesamtbetrag = parseFloat(bestellung.gesamtbetrag); + if (gesamtbetrag <= 0) { + logger.info('createPendingFromBestellung: Gesamtbetrag is 0, skipping', { bestellungId }); + return; + } + + // 2. Find active Haushaltsjahr + const hjResult = await pool.query( + `SELECT id FROM buchhaltung_haushaltsjahre WHERE abgeschlossen = FALSE ORDER BY jahr DESC LIMIT 1` + ); + if (!hjResult.rows[0]) { + logger.info('createPendingFromBestellung: No active Haushaltsjahr found, skipping', { bestellungId }); + return; + } + const haushaltsjahrId = hjResult.rows[0].id; + + // 3. Find default bank account + const bkResult = await pool.query( + `SELECT id FROM buchhaltung_bankkonten WHERE aktiv = TRUE ORDER BY ist_standard DESC LIMIT 1` + ); + const bankkontoId = bkResult.rows[0]?.id || null; + + // 4. Insert pending transaction + const label = bestellung.laufende_nummer ? `Bestellung #${bestellung.laufende_nummer}` : `Bestellung #${bestellung.id}`; + await pool.query( + `INSERT INTO buchhaltung_transaktionen + (haushaltsjahr_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, bestellung_id, erstellt_von, status) + VALUES ($1, $2, 'ausgabe', $3, NOW(), $4, $5, $6, $7, 'entwurf')`, + [haushaltsjahrId, bankkontoId, gesamtbetrag, label, bestellung.lieferant_name || null, bestellungId, userId] + ); + + logger.info('createPendingFromBestellung: Created pending transaction', { bestellungId, gesamtbetrag }); + } catch (error) { + logger.error('createPendingFromBestellung failed (non-fatal)', { error, bestellungId }); + // Non-fatal: don't throw + } +} + +// --------------------------------------------------------------------------- +// Planung (Budget Plans) +// --------------------------------------------------------------------------- + +async function listPlanungen() { + try { + const result = await pool.query( + `SELECT p.*, + hj.bezeichnung AS haushaltsjahr_bezeichnung, + hj.jahr AS haushaltsjahr_jahr, + COALESCE(pos.positionen_count, 0) AS positionen_count, + COALESCE(pos.total_gwg, 0) AS total_gwg, + COALESCE(pos.total_anlagen, 0) AS total_anlagen, + COALESCE(pos.total_instandhaltung, 0) AS total_instandhaltung + FROM buchhaltung_planung p + LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS positionen_count, + SUM(budget_gwg) AS total_gwg, + SUM(budget_anlagen) AS total_anlagen, + SUM(budget_instandhaltung) AS total_instandhaltung + FROM buchhaltung_planpositionen + WHERE planung_id = p.id + ) pos ON true + ORDER BY p.erstellt_am DESC` + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.listPlanungen failed', { error }); + throw new Error('Planungen konnten nicht geladen werden'); + } +} + +async function getPlanungById(id: number) { + try { + const planungResult = await pool.query( + `SELECT p.*, + hj.bezeichnung AS haushaltsjahr_bezeichnung, + hj.jahr AS haushaltsjahr_jahr + FROM buchhaltung_planung p + LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id + WHERE p.id = $1`, + [id] + ); + if (!planungResult.rows[0]) return null; + + const positionenResult = await pool.query( + `SELECT pp.*, + k.bezeichnung AS konto_bezeichnung, + k.kontonummer AS konto_kontonummer + FROM buchhaltung_planpositionen pp + LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id + WHERE pp.planung_id = $1 + ORDER BY pp.sort_order, pp.id`, + [id] + ); + + return { + ...planungResult.rows[0], + positionen: positionenResult.rows, + }; + } catch (error) { + logger.error('BuchhaltungService.getPlanungById failed', { error, id }); + throw new Error('Planung konnte nicht geladen werden'); + } +} + +async function createPlanung(data: { haushaltsjahr_id: number; bezeichnung: string }, userId: string) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_planung (haushaltsjahr_id, bezeichnung, erstellt_von) + VALUES ($1, $2, $3) + RETURNING *`, + [data.haushaltsjahr_id, data.bezeichnung, userId] + ); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.createPlanung failed', { error }); + throw new Error('Planung konnte nicht erstellt werden'); + } +} + +async function updatePlanung(id: number, data: { bezeichnung?: string; status?: string }) { + try { + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } + if (data.status !== undefined) { fields.push(`status = $${idx++}`); values.push(data.status); } + if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); + values.push(id); + const result = await pool.query( + `UPDATE buchhaltung_planung SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.updatePlanung failed', { error, id }); + throw new Error('Planung konnte nicht aktualisiert werden'); + } +} + +async function deletePlanung(id: number) { + try { + await pool.query(`DELETE FROM buchhaltung_planung WHERE id = $1`, [id]); + } catch (error) { + logger.error('BuchhaltungService.deletePlanung failed', { error, id }); + throw new Error('Planung konnte nicht gelöscht werden'); + } +} + +async function createPlanposition(planungId: number, data: { konto_id?: number | null; bezeichnung: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_planpositionen (planung_id, konto_id, bezeichnung, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [planungId, data.konto_id || null, data.bezeichnung, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, data.sort_order || 0] + ); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.createPlanposition failed', { error, planungId }); + throw new Error('Planposition konnte nicht erstellt werden'); + } +} + +async function updatePlanposition(id: number, data: { konto_id?: number | null; bezeichnung?: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }) { + try { + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + if (data.konto_id !== undefined) { fields.push(`konto_id = $${idx++}`); values.push(data.konto_id); } + if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } + 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 (data.sort_order !== undefined) { fields.push(`sort_order = $${idx++}`); values.push(data.sort_order); } + if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); + values.push(id); + const result = await pool.query( + `UPDATE buchhaltung_planpositionen SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.updatePlanposition failed', { error, id }); + throw new Error('Planposition konnte nicht aktualisiert werden'); + } +} + +async function deletePlanposition(id: number) { + try { + await pool.query(`DELETE FROM buchhaltung_planpositionen WHERE id = $1`, [id]); + } catch (error) { + logger.error('BuchhaltungService.deletePlanposition failed', { error, id }); + throw new Error('Planposition konnte nicht gelöscht werden'); + } +} + +async function getPlanpositionenByPlanung(planungId: number) { + try { + const result = await pool.query( + `SELECT pp.*, + k.bezeichnung AS konto_bezeichnung, + k.kontonummer AS konto_kontonummer + FROM buchhaltung_planpositionen pp + LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id + WHERE pp.planung_id = $1 + ORDER BY pp.sort_order, pp.id`, + [planungId] + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getPlanpositionenByPlanung failed', { error, planungId }); + throw new Error('Planpositionen konnten nicht geladen werden'); + } +} + +async function createHaushaltsjahrFromPlan(planungId: number, userId: string) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const planResult = await client.query( + `SELECT p.*, hj.jahr, hj.beginn, hj.ende + FROM buchhaltung_planung p + LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id + WHERE p.id = $1`, + [planungId] + ); + if (!planResult.rows[0]) throw new Error('Planung nicht gefunden'); + const plan = planResult.rows[0]; + + const nextYear = plan.jahr + 1; + + // Check if Haushaltsjahr for next year already exists + const existingResult = await client.query( + `SELECT id FROM buchhaltung_haushaltsjahre WHERE jahr = $1`, + [nextYear] + ); + if (existingResult.rows.length > 0) { + throw Object.assign(new Error(`Haushaltsjahr ${nextYear} existiert bereits`), { statusCode: 409 }); + } + + // Create new Haushaltsjahr + const hjResult = await client.query( + `INSERT INTO buchhaltung_haushaltsjahre (jahr, bezeichnung, beginn, ende, erstellt_von) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [nextYear, `Haushaltsjahr ${nextYear}`, `${nextYear}-01-01`, `${nextYear}-12-31`, userId] + ); + const newHj = hjResult.rows[0]; + + // Get all positions from the plan + const posResult = await client.query( + `SELECT pp.*, k.kontonummer, k.bezeichnung AS konto_bezeichnung, k.konto_typ_id, k.parent_id, k.kategorie_id, k.budget_typ + FROM buchhaltung_planpositionen pp + LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id + WHERE pp.planung_id = $1 + ORDER BY pp.sort_order, pp.id`, + [planungId] + ); + + // Create konten in the new Haushaltsjahr from plan positions + const kontoIdMap = new Map(); // old konto_id -> new konto_id + for (const pos of posResult.rows) { + if (!pos.konto_id) continue; + + // Map parent_id to new parent if it was already created + const newParentId = pos.parent_id ? (kontoIdMap.get(pos.parent_id) || null) : null; + + const kontoResult = await client.query( + `INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, erstellt_von, kategorie_id, budget_typ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (haushaltsjahr_id, kontonummer) DO UPDATE SET + budget_gwg = EXCLUDED.budget_gwg, + budget_anlagen = EXCLUDED.budget_anlagen, + budget_instandhaltung = EXCLUDED.budget_instandhaltung + RETURNING id`, + [newHj.id, pos.konto_typ_id, pos.kontonummer, pos.konto_bezeichnung || pos.bezeichnung, newParentId, pos.budget_gwg, pos.budget_anlagen, pos.budget_instandhaltung, userId, pos.kategorie_id, pos.budget_typ || 'detailliert'] + ); + kontoIdMap.set(pos.konto_id, kontoResult.rows[0].id); + } + + // Mark plan as abgeschlossen + await client.query( + `UPDATE buchhaltung_planung SET status = 'abgeschlossen' WHERE id = $1`, + [planungId] + ); + + await client.query('COMMIT'); + + await logAudit(null, 'haushaltsjahr_aus_plan_erstellt', { planung_id: planungId, haushaltsjahr_id: newHj.id, jahr: nextYear }, userId); + + return { haushaltsjahr: newHj }; + } catch (error: any) { + await client.query('ROLLBACK'); + logger.error('BuchhaltungService.createHaushaltsjahrFromPlan failed', { error, planungId }); + if (error.statusCode) throw error; + throw new Error('Haushaltsjahr konnte nicht aus Planung erstellt werden'); + } finally { + client.release(); + } +} + // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- @@ -1459,6 +1935,7 @@ const buchhaltungService = { createBankkonto, updateBankkonto, deactivateBankkonto, + getBankkontoStatement, getAllKonten, getKontoById, getKontenTree, @@ -1475,6 +1952,7 @@ const buchhaltungService = { bookTransaktion, stornoTransaktion, deleteTransaktion, + createTransfer, getBelegeByTransaktion, uploadBeleg, deleteBeleg, @@ -1495,6 +1973,17 @@ const buchhaltungService = { exportTransaktionenCsv, createErstattung, getErstattungLinks, + createPendingFromBestellung, + listPlanungen, + getPlanungById, + createPlanung, + updatePlanung, + deletePlanung, + createPlanposition, + updatePlanposition, + deletePlanposition, + getPlanpositionenByPlanung, + createHaushaltsjahrFromPlan, }; export default buchhaltungService; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aba7bc1..3223850 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,9 @@ import Checklisten from './pages/Checklisten'; import Buchhaltung from './pages/Buchhaltung'; import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail'; import BuchhaltungKontoManage from './pages/BuchhaltungKontoManage'; +import BuchhaltungBankkontoDetail from './pages/BuchhaltungBankkontoDetail'; +import Haushaltsplan from './pages/Haushaltsplan'; +import HaushaltsplanDetail from './pages/HaushaltsplanDetail'; import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen'; import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung'; import Issues from './pages/Issues'; @@ -397,6 +400,30 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> )} + {statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && ( + + Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern. + + )} diff --git a/frontend/src/pages/Buchhaltung.tsx b/frontend/src/pages/Buchhaltung.tsx index 396528b..365a07a 100644 --- a/frontend/src/pages/Buchhaltung.tsx +++ b/frontend/src/pages/Buchhaltung.tsx @@ -19,7 +19,6 @@ import { IconButton, InputAdornment, InputLabel, - LinearProgress, MenuItem, Paper, Select, @@ -51,6 +50,9 @@ import { ExpandMore as ExpandMoreIcon, HowToReg, Lock, + Save, + SwapHoriz, + PictureAsPdf as PdfIcon, ThumbDown, ThumbUp, } from '@mui/icons-material'; @@ -58,6 +60,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { buchhaltungApi } from '../services/buchhaltung'; import { bestellungApi } from '../services/bestellung'; +import { configApi, type PdfSettings } from '../services/config'; +import { addPdfHeader, addPdfFooter } from '../utils/pdfExport'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; @@ -66,20 +70,24 @@ import type { Bankkonto, BankkontoFormData, Konto, KontoFormData, KontoTreeNode, + KontoTyp, Kategorie, Transaktion, TransaktionFormData, TransaktionFilters, + TransaktionTyp, TransaktionStatus, AusgabenTyp, WiederkehrendBuchung, WiederkehrendFormData, WiederkehrendIntervall, BudgetTyp, ErstattungFormData, + TransferFormData, } from '../types/buchhaltung.types'; import { TRANSAKTION_STATUS_LABELS, TRANSAKTION_STATUS_COLORS, TRANSAKTION_TYP_LABELS, INTERVALL_LABELS, + KONTO_ART_LABELS, } from '../types/buchhaltung.types'; // ─── helpers ─────────────────────────────────────────────────────────────────── @@ -98,6 +106,149 @@ function TabPanel({ children, value, index }: { children: React.ReactNode; value return value === index ? {children} : null; } +// ─── PDF Export ───────────────────────────────────────────────────────────────── + +let _pdfSettingsCache: PdfSettings | null = null; +let _pdfSettingsCacheTime = 0; + +async function fetchPdfSettings(): Promise { + if (_pdfSettingsCache && Date.now() - _pdfSettingsCacheTime < 30_000) { + return _pdfSettingsCache; + } + try { + _pdfSettingsCache = await configApi.getPdfSettings(); + _pdfSettingsCacheTime = Date.now(); + return _pdfSettingsCache; + } catch { + return { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' }; + } +} + +async function generateBuchhaltungPdf( + jahrBezeichnung: string, + totalEinnahmen: number, + totalAusgaben: number, + saldo: number, + treeData: KontoTreeNode[], + transaktionen: Transaktion[], +) { + const { jsPDF } = await import('jspdf'); + const autoTable = (await import('jspdf-autotable')).default; + const pdfSettings = await fetchPdfSettings(); + + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const pageWidth = 210; + + // ── Page 1: Summary ── + let y = await addPdfHeader(doc, pdfSettings, pageWidth); + + doc.setFontSize(16); + doc.setFont('helvetica', 'bold'); + doc.text(`Buchhaltung \u2014 ${jahrBezeichnung}`, 10, y); + y += 12; + + doc.setFontSize(11); + doc.setFont('helvetica', 'normal'); + doc.text(`Einnahmen:`, 10, y); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(46, 125, 50); + doc.text(fmtEur(totalEinnahmen), 50, y); + y += 7; + + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + doc.text(`Ausgaben:`, 10, y); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(211, 47, 47); + doc.text(fmtEur(totalAusgaben), 50, y); + y += 7; + + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + doc.text(`Saldo:`, 10, y); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(saldo >= 0 ? 46 : 211, saldo >= 0 ? 125 : 47, saldo >= 0 ? 50 : 47); + doc.text(fmtEur(saldo), 50, y); + y += 14; + + doc.setTextColor(0, 0, 0); + + // ── Page 2+: Konten tree table ── + doc.setFontSize(13); + doc.setFont('helvetica', 'bold'); + doc.text('Konten', 10, y); + y += 6; + + const kontenRows = treeData.map(k => [ + `${k.kontonummer} ${k.bezeichnung}`, + fmtEur(Number(k.budget_gwg || 0)), + fmtEur(Number(k.budget_anlagen || 0)), + fmtEur(Number(k.budget_instandhaltung || 0)), + fmtEur(Number(k.budget_gwg || 0) + Number(k.budget_anlagen || 0) + Number(k.budget_instandhaltung || 0)), + fmtEur(Number(k.spent_gwg || 0) + Number(k.spent_anlagen || 0) + Number(k.spent_instandhaltung || 0)), + fmtEur(Number(k.einnahmen_betrag || 0)), + ]); + + autoTable(doc, { + head: [['Konto', 'Budget GWG', 'Budget Anl.', 'Budget Inst.', 'Budget Ges.', 'Ausgaben Ges.', 'Einnahmen']], + body: kontenRows, + startY: y, + headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold', fontSize: 7 }, + styles: { fontSize: 7, cellPadding: 1.5 }, + alternateRowStyles: { fillColor: [250, 235, 235] }, + margin: { left: 10, right: 10 }, + columnStyles: { + 0: { cellWidth: 50 }, + 1: { halign: 'right' }, + 2: { halign: 'right' }, + 3: { halign: 'right' }, + 4: { halign: 'right' }, + 5: { halign: 'right' }, + 6: { halign: 'right' }, + }, + didDrawPage: addPdfFooter(doc, pdfSettings), + }); + + // ── Next page(s): Transactions ── + doc.addPage(); + let txY = await addPdfHeader(doc, pdfSettings, pageWidth); + + doc.setFontSize(13); + doc.setFont('helvetica', 'bold'); + doc.text('Transaktionen', 10, txY); + txY += 6; + + const txRows = transaktionen.map(t => [ + fmtDate(t.datum), + t.typ === 'transfer' ? 'Transfer' : t.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe', + t.beschreibung || t.empfaenger_auftraggeber || '', + t.beleg_nr || '', + t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung || ''}` : '', + `${t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}${fmtEur(t.betrag)}`, + ]); + + autoTable(doc, { + head: [['Datum', 'Typ', 'Beschreibung', 'Beleg-Nr.', 'Konto', 'Betrag']], + body: txRows, + startY: txY, + headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold', fontSize: 7 }, + styles: { fontSize: 7, cellPadding: 1.5 }, + alternateRowStyles: { fillColor: [250, 235, 235] }, + margin: { left: 10, right: 10 }, + columnStyles: { + 0: { cellWidth: 22 }, + 1: { cellWidth: 18 }, + 2: { cellWidth: 55 }, + 3: { cellWidth: 22 }, + 4: { cellWidth: 40 }, + 5: { halign: 'right', cellWidth: 25 }, + }, + didDrawPage: addPdfFooter(doc, pdfSettings), + }); + + doc.save(`buchhaltung_${jahrBezeichnung.replace(/\s+/g, '_')}.pdf`); +} + // ─── Sub-components ──────────────────────────────────────────────────────────── function HaushaltsjahrDialog({ @@ -566,6 +717,8 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { onJahrChange: (id: number) => void; }) { const navigate = useNavigate(); + const { hasPermission } = usePermissionContext(); + const { showError } = useNotification(); const { data: treeData = [], isLoading } = useQuery({ queryKey: ['kontenTree', selectedJahrId], queryFn: () => buchhaltungApi.getKontenTree(selectedJahrId!), @@ -576,6 +729,11 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!), enabled: selectedJahrId != null, }); + const { data: transaktionenForPdf = [] } = useQuery({ + queryKey: ['buchhaltung-transaktionen', { haushaltsjahr_id: selectedJahrId }], + queryFn: () => buchhaltungApi.getTransaktionen({ haushaltsjahr_id: selectedJahrId! }), + enabled: selectedJahrId != null, + }); const tree = buildTree(treeData); @@ -602,6 +760,23 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {haushaltsjahre.map(hj => {hj.bezeichnung}{hj.abgeschlossen ? ' (abgeschlossen)' : ''})} + {hasPermission('buchhaltung:export') && selectedJahrId && ( + + )} {isLoading && } @@ -860,6 +1035,82 @@ function ErstattungDialog({ ); } +function TransferDialog({ + open, + onClose, + haushaltsjahre, + selectedJahrId, + bankkonten, + onSave, +}: { + open: boolean; + onClose: () => void; + haushaltsjahre: Haushaltsjahr[]; + selectedJahrId: number | null; + bankkonten: Bankkonto[]; + onSave: (data: TransferFormData) => void; +}) { + const today = new Date().toISOString().slice(0, 10); + const [form, setForm] = useState({ + haushaltsjahr_id: selectedJahrId || 0, + bankkonto_id: 0, + transfer_ziel_bankkonto_id: 0, + betrag: 0, + datum: today, + beschreibung: '', + beleg_nr: '', + }); + + useEffect(() => { + if (open) setForm(f => ({ ...f, haushaltsjahr_id: selectedJahrId || 0, bankkonto_id: 0, transfer_ziel_bankkonto_id: 0, betrag: 0, datum: today, beschreibung: '', beleg_nr: '' })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const zielOptions = bankkonten.filter(bk => bk.id !== form.bankkonto_id); + + return ( + + Transfer zwischen Bankkonten + + + + Haushaltsjahr + + + + Quell-Bankkonto + + + + Ziel-Bankkonto + + + 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 /> + setForm(f => ({ ...f, beschreibung: e.target.value }))} /> + setForm(f => ({ ...f, beleg_nr: e.target.value }))} /> + + + + + + + + ); +} + // ─── Tab 1: Transaktionen ───────────────────────────────────────────────────── function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { @@ -878,6 +1129,9 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { // ── Erstattung state ── const [erstattungOpen, setErstattungOpen] = useState(false); + // ── Transfer state ── + const [transferOpen, setTransferOpen] = useState(false); + // ── Wiederkehrend state ── const [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false }); @@ -962,6 +1216,13 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { onError: () => showError('Erstattung konnte nicht erstellt werden'), }); + // ── Transfer mutation ── + const createTransferMut = useMutation({ + mutationFn: buchhaltungApi.createTransfer, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); setTransferOpen(false); showSuccess('Transfer erstellt'); }, + onError: () => showError('Transfer konnte nicht erstellt werden'), + }); + const handleExportCsv = async () => { if (!filters.haushaltsjahr_id) { showError('Bitte ein Haushaltsjahr auswählen'); return; } try { @@ -1034,10 +1295,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { Typ )} + {hasPermission('buchhaltung:create') && ( + + )} @@ -1106,7 +1373,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {t.laufende_nummer ?? `E${t.id}`} {fmtDate(t.datum)} - + @@ -1117,8 +1388,8 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung}` : '–'} - - {t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)} + + {t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)} @@ -1198,6 +1469,15 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { transaktionen={transaktionen} onSave={data => createErstattungMut.mutate(data)} /> + + setTransferOpen(false)} + haushaltsjahre={haushaltsjahre} + selectedJahrId={selectedJahrId} + bankkonten={bankkonten} + onSave={data => createTransferMut.mutate(data)} + /> )} @@ -1423,6 +1703,45 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { const [newKategorie, setNewKategorie] = useState(''); const [addingKategorie, setAddingKategorie] = useState(false); + // ── Einstellungen state ──────────────────────────────────────────────────── + const [kontoTypDialog, setKontoTypDialog] = useState<{ open: boolean; existing?: KontoTyp }>({ open: false }); + const [kontoTypForm, setKontoTypForm] = useState<{ bezeichnung: string; art: string; sort_order: number }>({ bezeichnung: '', art: 'ausgabe', sort_order: 0 }); + const [alertThreshold, setAlertThreshold] = useState('80'); + + const { data: kontoTypen = [] } = useQuery({ queryKey: ['konto-typen'], queryFn: buchhaltungApi.getKontoTypen }); + const { data: einstellungen } = useQuery({ + queryKey: ['buchhaltung-einstellungen'], + queryFn: buchhaltungApi.getEinstellungen, + enabled: hasPermission('buchhaltung:manage_settings'), + }); + + useEffect(() => { + if (einstellungen?.default_alert_threshold) { + setAlertThreshold(String(einstellungen.default_alert_threshold)); + } + }, [einstellungen]); + + const createKontoTypMut = useMutation({ + mutationFn: buchhaltungApi.createKontoTyp, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['konto-typen'] }); setKontoTypDialog({ open: false }); showSuccess('Konto-Typ erstellt'); }, + onError: () => showError('Konto-Typ konnte nicht erstellt werden'), + }); + const updateKontoTypMut = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial<{ bezeichnung: string; art: string; sort_order: number }> }) => buchhaltungApi.updateKontoTyp(id, data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['konto-typen'] }); setKontoTypDialog({ open: false }); showSuccess('Konto-Typ aktualisiert'); }, + onError: () => showError('Konto-Typ konnte nicht aktualisiert werden'), + }); + const deleteKontoTypMut = useMutation({ + mutationFn: buchhaltungApi.deleteKontoTyp, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['konto-typen'] }); showSuccess('Konto-Typ gelöscht'); }, + onError: (err: any) => showError(err?.response?.data?.message || 'Konto-Typ konnte nicht gelöscht werden'), + }); + const saveEinstellungenMut = useMutation({ + mutationFn: buchhaltungApi.setEinstellungen, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-einstellungen'] }); showSuccess('Einstellungen gespeichert'); }, + onError: () => showError('Einstellungen konnten nicht gespeichert werden'), + }); + const createKategorieMut = useMutation({ mutationFn: buchhaltungApi.createKategorie, onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-kategorien'] }); setNewKategorie(''); setAddingKategorie(false); showSuccess('Kategorie erstellt'); }, @@ -1491,6 +1810,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { + {hasPermission('buchhaltung:manage_settings') && } @@ -1620,7 +1940,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { {bankkonten.length === 0 && Keine Bankkonten} {bankkonten.map((bk: Bankkonto) => ( - + navigate(`/buchhaltung/bankkonto/${bk.id}`)} sx={{ cursor: 'pointer' }}> {bk.bezeichnung} {bk.iban || '–'} {bk.institut || '–'} @@ -1701,6 +2021,97 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { /> )} + + {/* Sub-Tab 3: Einstellungen */} + {subTab === 3 && hasPermission('buchhaltung:manage_settings') && ( + + {/* Konto-Typen CRUD */} + Konto-Typen + + + + + + + + Bezeichnung + Art + Sortierung + Aktionen + + + + {kontoTypen.length === 0 && Keine Konto-Typen} + {kontoTypen.map((kt: KontoTyp) => ( + + {kt.bezeichnung} + {KONTO_ART_LABELS[kt.art] || kt.art} + {kt.sort_order} + + { setKontoTypForm({ bezeichnung: kt.bezeichnung, art: kt.art, sort_order: kt.sort_order }); setKontoTypDialog({ open: true, existing: kt }); }}> + deleteKontoTypMut.mutate(kt.id)}> + + + ))} + +
+
+ + {/* Alert-Schwellwert */} + Budget-Warnung + + + setAlertThreshold(e.target.value)} + InputProps={{ endAdornment: % }} + helperText="Budget-Warnung wird ausgelöst, wenn die Auslastung diesen Wert erreicht" + sx={{ width: 280 }} + /> + + + + + {/* Konto-Typ Dialog */} + setKontoTypDialog({ open: false })} maxWidth="sm" fullWidth> + {kontoTypDialog.existing ? 'Konto-Typ bearbeiten' : 'Neuer Konto-Typ'} + + + setKontoTypForm(f => ({ ...f, bezeichnung: e.target.value }))} required /> + + Art + + + setKontoTypForm(f => ({ ...f, sort_order: parseInt(e.target.value, 10) || 0 }))} /> + + + + + + + +
+ )} ); } diff --git a/frontend/src/pages/BuchhaltungBankkontoDetail.tsx b/frontend/src/pages/BuchhaltungBankkontoDetail.tsx new file mode 100644 index 0000000..aa090ff --- /dev/null +++ b/frontend/src/pages/BuchhaltungBankkontoDetail.tsx @@ -0,0 +1,190 @@ +import { useState } from 'react'; +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + IconButton, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { ArrowBack } from '@mui/icons-material'; +import { useQuery } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { buchhaltungApi } from '../services/buchhaltung'; +import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types'; +import type { BankkontoStatementRow } from '../types/buchhaltung.types'; + +function fmtEur(val: number) { + return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val); +} + +function fmtDate(val: string) { + return new Date(val).toLocaleDateString('de-DE'); +} + +export default function BuchhaltungBankkontoDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const bankkontoId = Number(id); + + const [von, setVon] = useState(''); + const [bis, setBis] = useState(''); + const [appliedVon, setAppliedVon] = useState(''); + const [appliedBis, setAppliedBis] = useState(''); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['bankkonto-statement', bankkontoId, appliedVon, appliedBis], + queryFn: () => buchhaltungApi.getBankkontoStatement(bankkontoId, { + von: appliedVon || undefined, + bis: appliedBis || undefined, + }), + enabled: !!bankkontoId, + }); + + const handleApply = () => { + setAppliedVon(von); + setAppliedBis(bis); + }; + + return ( + + + + navigate('/buchhaltung?tab=2')}> + + + + + {data?.bankkonto?.bezeichnung ?? 'Bankkonto'} + {data?.bankkonto?.iban && ( + + {data.bankkonto.iban} + + )} + + + + {/* Date range filter */} + + + setVon(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setBis(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + + {isLoading && } + {isError && Fehler beim Laden der Kontodaten.} + + {data && !isLoading && ( + <> + {/* Summary cards */} + + + + Einnahmen + {fmtEur(data.einnahmen)} + + + + + Ausgaben + {fmtEur(data.ausgaben)} + + + + + Saldo + = 0 ? 'success.main' : 'error.main'}>{fmtEur(data.saldo)} + + + + + {/* Statement table */} + + + + + Datum + Typ + Beschreibung + Beleg-Nr. + Betrag + Laufender Saldo + + + + {data.rows.length === 0 && ( + + + Keine Transaktionen im Zeitraum + + + )} + {data.rows.map((row: BankkontoStatementRow) => { + const isEinnahme = row.typ === 'einnahme'; + const isTransfer = row.typ === 'transfer'; + return ( + + {fmtDate(row.datum)} + + + + {row.beschreibung || '\u2013'} + {row.beleg_nr || '\u2013'} + + {row.typ === 'ausgabe' ? '-' : row.typ === 'einnahme' ? '+' : ''}{fmtEur(row.betrag)} + + + {fmtEur(row.laufender_saldo)} + + + ); + })} + +
+
+ + )} +
+ ); +} diff --git a/frontend/src/pages/Haushaltsplan.tsx b/frontend/src/pages/Haushaltsplan.tsx new file mode 100644 index 0000000..b585716 --- /dev/null +++ b/frontend/src/pages/Haushaltsplan.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + IconButton, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { Add as AddIcon, Delete, Visibility } from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { buchhaltungApi } from '../services/buchhaltung'; +import { useNotification } from '../contexts/NotificationContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import type { Planung, PlanungStatus, Haushaltsjahr } from '../types/buchhaltung.types'; + +const STATUS_LABELS: Record = { + entwurf: 'Entwurf', + aktiv: 'Aktiv', + abgeschlossen: 'Abgeschlossen', +}; +const STATUS_COLORS: Record = { + entwurf: 'default', + aktiv: 'success', + abgeschlossen: 'info', +}; + +function fmtDate(val: string) { + return new Date(val).toLocaleDateString('de-DE'); +} + +function fmtEur(val: number) { + return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val); +} + +export default function Haushaltsplan() { + const navigate = useNavigate(); + const qc = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + const canManage = hasPermission('buchhaltung:manage_accounts'); + + const [dialogOpen, setDialogOpen] = useState(false); + const [form, setForm] = useState<{ bezeichnung: string; haushaltsjahr_id: string }>({ bezeichnung: '', haushaltsjahr_id: '' }); + + const { data: planungen = [], isLoading } = useQuery({ + queryKey: ['planungen'], + queryFn: buchhaltungApi.listPlanungen, + }); + const { data: haushaltsjahre = [] } = useQuery({ + queryKey: ['haushaltsjahre'], + queryFn: buchhaltungApi.getHaushaltsjahre, + }); + + const createMut = useMutation({ + mutationFn: buchhaltungApi.createPlanung, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['planungen'] }); setDialogOpen(false); showSuccess('Haushaltsplan erstellt'); }, + onError: () => showError('Haushaltsplan konnte nicht erstellt werden'), + }); + const deleteMut = useMutation({ + mutationFn: buchhaltungApi.deletePlanung, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['planungen'] }); showSuccess('Haushaltsplan gelöscht'); }, + onError: () => showError('Haushaltsplan konnte nicht gelöscht werden'), + }); + + return ( + + + Haushaltspläne + {canManage && ( + + )} + + + + + + + Bezeichnung + Haushaltsjahr + Status + Positionen + Gesamt (GWG + Anl. + Inst.) + Erstellt am + Aktionen + + + + {!isLoading && planungen.length === 0 && ( + Keine Haushaltspläne vorhanden + )} + {planungen.map((p: Planung) => { + const total = Number(p.total_gwg) + Number(p.total_anlagen) + Number(p.total_instandhaltung); + return ( + navigate(`/haushaltsplan/${p.id}`)}> + {p.bezeichnung} + {p.haushaltsjahr_bezeichnung || '–'} + + {p.positionen_count} + {fmtEur(total)} + {fmtDate(p.erstellt_am)} + e.stopPropagation()}> + navigate(`/haushaltsplan/${p.id}`)}> + {canManage && deleteMut.mutate(p.id)}>} + + + ); + })} + +
+
+ + setDialogOpen(false)} maxWidth="sm" fullWidth> + Neuer Haushaltsplan + + + setForm(f => ({ ...f, bezeichnung: e.target.value }))} required /> + + Haushaltsjahr (optional) + + + + + + + + + +
+ ); +} diff --git a/frontend/src/pages/HaushaltsplanDetail.tsx b/frontend/src/pages/HaushaltsplanDetail.tsx new file mode 100644 index 0000000..e5b0b0e --- /dev/null +++ b/frontend/src/pages/HaushaltsplanDetail.tsx @@ -0,0 +1,271 @@ +import { useState } from 'react'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { + Alert, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + IconButton, + InputAdornment, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { Add as AddIcon, ArrowBack, Delete, Edit, PlaylistAdd } from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate, useParams } from 'react-router-dom'; +import { buchhaltungApi } from '../services/buchhaltung'; +import { useNotification } from '../contexts/NotificationContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import type { Konto, Planposition, PlanungStatus } from '../types/buchhaltung.types'; + +const STATUS_LABELS: Record = { + entwurf: 'Entwurf', + aktiv: 'Aktiv', + abgeschlossen: 'Abgeschlossen', +}; +const STATUS_COLORS: Record = { + entwurf: 'default', + aktiv: 'success', + abgeschlossen: 'info', +}; + +function fmtEur(val: number) { + return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val); +} + +export default function HaushaltsplanDetail() { + const { id } = useParams<{ id: string }>(); + const planungId = Number(id); + const navigate = useNavigate(); + const qc = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + const canManage = hasPermission('buchhaltung:manage_accounts'); + + const [posDialog, setPosDialog] = useState<{ open: boolean; existing?: Planposition }>({ open: false }); + const [posForm, setPosForm] = useState<{ konto_id: string; bezeichnung: string; budget_gwg: string; budget_anlagen: string; budget_instandhaltung: string; notizen: string }>({ + konto_id: '', bezeichnung: '', budget_gwg: '0', budget_anlagen: '0', budget_instandhaltung: '0', notizen: '', + }); + + const { data: planung, isLoading, isError } = useQuery({ + queryKey: ['planung', planungId], + queryFn: () => buchhaltungApi.getPlanung(planungId), + enabled: !isNaN(planungId), + }); + + // Load konten for the linked haushaltsjahr (for Konto dropdown in position dialog) + const { data: konten = [] } = useQuery({ + queryKey: ['buchhaltung-konten', planung?.haushaltsjahr_id], + queryFn: () => buchhaltungApi.getKonten(planung!.haushaltsjahr_id!), + enabled: !!planung?.haushaltsjahr_id, + }); + + const createPosMut = useMutation({ + mutationFn: (data: Parameters[1]) => buchhaltungApi.createPlanposition(planungId, data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); setPosDialog({ open: false }); showSuccess('Position hinzugefügt'); }, + onError: () => showError('Position konnte nicht erstellt werden'), + }); + const updatePosMut = useMutation({ + mutationFn: ({ posId, data }: { posId: number; data: Parameters[1] }) => buchhaltungApi.updatePlanposition(posId, data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); setPosDialog({ open: false }); showSuccess('Position aktualisiert'); }, + onError: () => showError('Position konnte nicht aktualisiert werden'), + }); + const deletePosMut = useMutation({ + mutationFn: buchhaltungApi.deletePlanposition, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); showSuccess('Position gelöscht'); }, + onError: () => showError('Position konnte nicht gelöscht werden'), + }); + const createHjMut = useMutation({ + mutationFn: () => buchhaltungApi.createHaushaltsjahrFromPlan(planungId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['planung', planungId] }); + qc.invalidateQueries({ queryKey: ['haushaltsjahre'] }); + qc.invalidateQueries({ queryKey: ['planungen'] }); + showSuccess('Haushaltsjahr aus Plan erstellt'); + }, + onError: (err: any) => showError(err?.response?.data?.message || 'Haushaltsjahr konnte nicht erstellt werden'), + }); + + const openAddDialog = () => { + setPosForm({ konto_id: '', bezeichnung: '', budget_gwg: '0', budget_anlagen: '0', budget_instandhaltung: '0', notizen: '' }); + setPosDialog({ open: true }); + }; + const openEditDialog = (pos: Planposition) => { + setPosForm({ + konto_id: pos.konto_id ? String(pos.konto_id) : '', + bezeichnung: pos.bezeichnung, + budget_gwg: String(pos.budget_gwg), + budget_anlagen: String(pos.budget_anlagen), + budget_instandhaltung: String(pos.budget_instandhaltung), + notizen: pos.notizen || '', + }); + setPosDialog({ open: true, existing: pos }); + }; + const handlePosSave = () => { + const data = { + konto_id: posForm.konto_id ? Number(posForm.konto_id) : null, + bezeichnung: posForm.bezeichnung.trim(), + budget_gwg: parseFloat(posForm.budget_gwg) || 0, + budget_anlagen: parseFloat(posForm.budget_anlagen) || 0, + budget_instandhaltung: parseFloat(posForm.budget_instandhaltung) || 0, + notizen: posForm.notizen.trim() || undefined, + }; + if (posDialog.existing) { + updatePosMut.mutate({ posId: posDialog.existing.id, data }); + } else { + createPosMut.mutate(data); + } + }; + + if (isLoading) return Laden...; + if (isError || !planung) return Haushaltsplan nicht gefunden; + + const positionen = planung.positionen || []; + const totalGwg = positionen.reduce((s, p) => s + Number(p.budget_gwg), 0); + const totalAnlagen = positionen.reduce((s, p) => s + Number(p.budget_anlagen), 0); + const totalInstandhaltung = positionen.reduce((s, p) => s + Number(p.budget_instandhaltung), 0); + + return ( + + + navigate('/haushaltsplan')}> + {planung.bezeichnung} + + + + {planung.haushaltsjahr_bezeichnung && ( + + Haushaltsjahr: {planung.haushaltsjahr_bezeichnung} + + )} + + {!planung.haushaltsjahr_id && canManage && ( + } onClick={() => createHjMut.mutate()} disabled={createHjMut.isPending}> + Haushaltsjahr erstellen + + }> + Diesem Plan ist noch kein Haushaltsjahr zugeordnet. Sie können aus diesem Plan ein Haushaltsjahr mit Konten erstellen. + + )} + + + Positionen ({positionen.length}) + {canManage && ( + + )} + + + + + + + Bezeichnung + Konto + GWG + Anlagen + Instandh. + Gesamt + {canManage && Aktionen} + + + + {positionen.length === 0 && ( + Keine Positionen + )} + {positionen.map((pos: Planposition) => { + const rowTotal = Number(pos.budget_gwg) + Number(pos.budget_anlagen) + Number(pos.budget_instandhaltung); + return ( + + {pos.bezeichnung} + {pos.konto_bezeichnung ? `${pos.konto_kontonummer} – ${pos.konto_bezeichnung}` : '–'} + {fmtEur(Number(pos.budget_gwg))} + {fmtEur(Number(pos.budget_anlagen))} + {fmtEur(Number(pos.budget_instandhaltung))} + {fmtEur(rowTotal)} + {canManage && ( + + openEditDialog(pos)}> + deletePosMut.mutate(pos.id)}> + + )} + + ); + })} + {positionen.length > 0 && ( + + Summe + {fmtEur(totalGwg)} + {fmtEur(totalAnlagen)} + {fmtEur(totalInstandhaltung)} + {fmtEur(totalGwg + totalAnlagen + totalInstandhaltung)} + {canManage && } + + )} + +
+
+ + {/* Position Add/Edit Dialog */} + setPosDialog({ open: false })} maxWidth="sm" fullWidth> + {posDialog.existing ? 'Position bearbeiten' : 'Neue Position'} + + + setPosForm(f => ({ ...f, bezeichnung: e.target.value }))} required /> + {konten.length > 0 && ( + + Konto (optional) + + + )} + setPosForm(f => ({ ...f, budget_gwg: e.target.value }))} + InputProps={{ startAdornment: EUR }} + /> + setPosForm(f => ({ ...f, budget_anlagen: e.target.value }))} + InputProps={{ startAdornment: EUR }} + /> + setPosForm(f => ({ ...f, budget_instandhaltung: e.target.value }))} + InputProps={{ startAdornment: EUR }} + /> + setPosForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} /> + + + + + + + +
+ ); +} diff --git a/frontend/src/services/buchhaltung.ts b/frontend/src/services/buchhaltung.ts index c00c2f0..6642945 100644 --- a/frontend/src/services/buchhaltung.ts +++ b/frontend/src/services/buchhaltung.ts @@ -11,7 +11,11 @@ import type { Freigabe, Kategorie, ErstattungFormData, ErstattungLinks, + TransferFormData, BuchhaltungAudit, + BuchhaltungEinstellungen, + BankkontoStatement, + Planung, PlanungDetail, Planposition, } from '../types/buchhaltung.types'; export const buchhaltungApi = { @@ -67,6 +71,20 @@ export const buchhaltungApi = { deleteBankkonto: async (id: number): Promise => { await api.delete(`/api/buchhaltung/bankkonten/${id}`); }, + getBankkontoStatement: async (id: number, params?: { von?: string; bis?: string }): Promise => { + const qs = new URLSearchParams(); + if (params?.von) qs.set('von', params.von); + if (params?.bis) qs.set('bis', params.bis); + const r = await api.get(`/api/buchhaltung/bankkonten/${id}/transaktionen?${qs.toString()}`); + const d = r.data; + return { + bankkonto: d.konto, + einnahmen: d.summary.gesamteinnahmen, + ausgaben: d.summary.gesamtausgaben, + saldo: d.summary.saldo, + rows: d.rows, + }; + }, // ── Konten ─────────────────────────────────────────────────────────────────── getKonten: async (haushaltsjahrId: number): Promise => { @@ -220,9 +238,60 @@ export const buchhaltungApi = { return r.data.data; }, + // ── Transfers ────────────────────────────────────────────────────────────── + createTransfer: async (data: TransferFormData): Promise<{ debit: Transaktion; credit: Transaktion }> => { + const r = await api.post('/api/buchhaltung/transfers', data); + return r.data.data; + }, + // ── Audit ───────────────────────────────────────────────────────────────── getAudit: async (transaktionId: number): Promise => { const r = await api.get(`/api/buchhaltung/audit/${transaktionId}`); return r.data.data; }, + + // ── Einstellungen ───────────────────────────────────────────────────────── + getEinstellungen: async (): Promise => { + const r = await api.get('/api/buchhaltung/einstellungen'); + return r.data.data; + }, + setEinstellungen: async (data: Record): Promise => { + await api.put('/api/buchhaltung/einstellungen', data); + }, + + // ── Planung ─────────────────────────────────────────────────────────────── + listPlanungen: async (): Promise => { + const r = await api.get('/api/buchhaltung/planung'); + return r.data.data; + }, + getPlanung: async (id: number): Promise => { + const r = await api.get(`/api/buchhaltung/planung/${id}`); + return r.data.data; + }, + createPlanung: async (data: { haushaltsjahr_id?: number; bezeichnung: string }): Promise => { + const r = await api.post('/api/buchhaltung/planung', data); + return r.data.data; + }, + updatePlanung: async (id: number, data: { bezeichnung?: string; status?: string }): Promise => { + const r = await api.put(`/api/buchhaltung/planung/${id}`, data); + return r.data.data; + }, + deletePlanung: async (id: number): Promise => { + await api.delete(`/api/buchhaltung/planung/${id}`); + }, + createPlanposition: async (planungId: number, data: { konto_id?: number | null; bezeichnung: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }): Promise => { + const r = await api.post(`/api/buchhaltung/planung/${planungId}/positionen`, data); + return r.data.data; + }, + updatePlanposition: async (id: number, data: Partial<{ konto_id: number | null; bezeichnung: string; budget_gwg: number; budget_anlagen: number; budget_instandhaltung: number; notizen: string; sort_order: number }>): Promise => { + const r = await api.put(`/api/buchhaltung/planung/positionen/${id}`, data); + return r.data.data; + }, + deletePlanposition: async (id: number): Promise => { + await api.delete(`/api/buchhaltung/planung/positionen/${id}`); + }, + createHaushaltsjahrFromPlan: async (planungId: number): Promise => { + const r = await api.post(`/api/buchhaltung/planung/${planungId}/create-haushaltsjahr`); + return r.data.data; + }, }; diff --git a/frontend/src/types/buchhaltung.types.ts b/frontend/src/types/buchhaltung.types.ts index ea35273..45dc135 100644 --- a/frontend/src/types/buchhaltung.types.ts +++ b/frontend/src/types/buchhaltung.types.ts @@ -1,6 +1,6 @@ // Lookup types export type KontoArt = 'einnahme' | 'ausgabe' | 'vermoegen' | 'verbindlichkeit'; -export type TransaktionTyp = 'einnahme' | 'ausgabe'; +export type TransaktionTyp = 'einnahme' | 'ausgabe' | 'transfer'; export type TransaktionStatus = 'entwurf' | 'gebucht' | 'freigegeben' | 'storniert'; export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt'; export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich'; @@ -32,6 +32,7 @@ export const TRANSAKTION_STATUS_COLORS: Record = { einnahme: 'Einnahme', ausgabe: 'Ausgabe', + transfer: 'Transfer', }; export const KONTO_ART_LABELS: Record = { @@ -154,6 +155,8 @@ export interface Transaktion { konto_bezeichnung?: string; konto_kontonummer?: number; bankkonto_bezeichnung?: string; + transfer_ziel_bankkonto_id?: number; + transfer_ziel_bezeichnung?: string; belege?: Beleg[]; } @@ -306,3 +309,71 @@ export interface ErstattungLinks { erstattung_transaktion_id: number | null; quell_transaktion_ids: number[]; } + +export interface TransferFormData { + haushaltsjahr_id: number; + bankkonto_id: number; + transfer_ziel_bankkonto_id: number; + betrag: number; + datum: string; + beschreibung?: string; + beleg_nr?: string; +} + +export interface BuchhaltungEinstellungen { + default_alert_threshold?: string; + [key: string]: unknown; +} + +export interface Planung { + id: number; + bezeichnung: string; + haushaltsjahr_id: number | null; + haushaltsjahr_bezeichnung?: string; + haushaltsjahr_jahr?: number; + beschreibung?: string; + status: PlanungStatus; + positionen_count: number; + total_gwg: number; + total_anlagen: number; + total_instandhaltung: number; + erstellt_von: string | null; + erstellt_am: string; +} + +export interface Planposition { + id: number; + planung_id: number; + konto_id: number | null; + konto_bezeichnung?: string; + konto_kontonummer?: number; + bezeichnung: string; + budget_gwg: number; + budget_anlagen: number; + budget_instandhaltung: number; + notizen: string | null; + sort_order: number; +} + +export interface PlanungDetail extends Planung { + positionen: Planposition[]; +} + +export interface BankkontoStatementRow { + id: number; + datum: string; + typ: TransaktionTyp; + beschreibung: string | null; + beleg_nr: string | null; + betrag: number; + laufender_saldo: number; + transfer_ziel_bezeichnung?: string; +} + +export interface BankkontoStatement { + bankkonto: Bankkonto; + einnahmen: number; + ausgaben: number; + saldo: number; + rows: BankkontoStatementRow[]; +}