feat(buchhaltung): add transfers, bank statements, Haushaltsplan, and PDF export

This commit is contained in:
Matthias Hochmeister
2026-03-30 17:05:18 +02:00
parent 2eb59e9ff1
commit 5acfd7cc4f
14 changed files with 1911 additions and 10 deletions

View File

@@ -201,6 +201,23 @@ class BuchhaltungController {
}
}
async getBankkontoStatement(req: Request, res: Response): Promise<void> {
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<void> {
@@ -411,6 +428,18 @@ class BuchhaltungController {
}
}
// ── Transfers ─────────────────────────────────────────────────────────────────
async createTransfer(req: Request, res: Response): Promise<void> {
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<void> {
@@ -566,6 +595,128 @@ class BuchhaltungController {
}
}
// ── Planung ─────────────────────────────────────────────────────────────────
async listPlanungen(_req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {

View File

@@ -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;

View File

@@ -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));

View File

@@ -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 });

View File

@@ -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<number, number>(); // 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;

View File

@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/buchhaltung/bankkonto/:id"
element={
<ProtectedRoute>
<BuchhaltungBankkontoDetail />
</ProtectedRoute>
}
/>
<Route
path="/haushaltsplan"
element={
<ProtectedRoute>
<Haushaltsplan />
</ProtectedRoute>
}
/>
<Route
path="/haushaltsplan/:id"
element={
<ProtectedRoute>
<HaushaltsplanDetail />
</ProtectedRoute>
}
/>
<Route
path="/checklisten/ausfuehrung/:id"
element={

View File

@@ -152,6 +152,7 @@ const baseNavigationItems: NavigationItem[] = [
{ text: 'Übersicht', path: '/buchhaltung?tab=0' },
{ text: 'Transaktionen', path: '/buchhaltung?tab=1' },
{ text: 'Konten', path: '/buchhaltung?tab=2' },
{ text: 'Haushaltspläne', path: '/haushaltsplan' },
],
permission: 'buchhaltung:view',
},

View File

@@ -1356,6 +1356,11 @@ export default function BestellungDetail() {
Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs.
</Typography>
)}
{statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && (
<Alert severity="warning" sx={{ mt: 2 }}>
Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern.
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>Abbrechen</Button>

View File

@@ -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 ? <Box role="tabpanel" sx={{ pt: 3 }}>{children}</Box> : null;
}
// ─── PDF Export ─────────────────────────────────────────────────────────────────
let _pdfSettingsCache: PdfSettings | null = null;
let _pdfSettingsCacheTime = 0;
async function fetchPdfSettings(): Promise<PdfSettings> {
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 => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}{hj.abgeschlossen ? ' (abgeschlossen)' : ''}</MenuItem>)}
</Select>
</FormControl>
{hasPermission('buchhaltung:export') && selectedJahrId && (
<Button
variant="outlined"
startIcon={<PdfIcon />}
onClick={async () => {
const hj = haushaltsjahre.find(h => h.id === selectedJahrId);
if (!hj) return;
try {
await generateBuchhaltungPdf(hj.bezeichnung, totalEinnahmen, totalAusgaben, saldo, treeData, transaktionenForPdf);
} catch {
showError('PDF-Export fehlgeschlagen');
}
}}
>
PDF exportieren
</Button>
)}
</Box>
{isLoading && <CircularProgress />}
@@ -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<TransferFormData>({
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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Transfer zwischen Bankkonten</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<FormControl fullWidth required>
<InputLabel>Haushaltsjahr</InputLabel>
<Select value={form.haushaltsjahr_id || ''} label="Haushaltsjahr" onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: Number(e.target.value) }))}>
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel>Quell-Bankkonto</InputLabel>
<Select value={form.bankkonto_id || ''} label="Quell-Bankkonto" onChange={e => setForm(f => ({ ...f, bankkonto_id: Number(e.target.value), transfer_ziel_bankkonto_id: f.transfer_ziel_bankkonto_id === Number(e.target.value) ? 0 : f.transfer_ziel_bankkonto_id }))}>
{bankkonten.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel>Ziel-Bankkonto</InputLabel>
<Select value={form.transfer_ziel_bankkonto_id || ''} label="Ziel-Bankkonto" onChange={e => setForm(f => ({ ...f, transfer_ziel_bankkonto_id: Number(e.target.value) }))}>
{zielOptions.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>)}
</Select>
</FormControl>
<TextField label="Betrag (EUR)" type="number" value={form.betrag} onChange={e => setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required />
<TextField label="Datum" type="date" value={form.datum} onChange={e => setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required />
<TextField label="Beschreibung" value={form.beschreibung} onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))} />
<TextField label="Beleg-Nr." value={form.beleg_nr} onChange={e => setForm(f => ({ ...f, beleg_nr: e.target.value }))} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.haushaltsjahr_id || !form.bankkonto_id || !form.transfer_ziel_bankkonto_id || !form.betrag || !form.datum}
onClick={() => onSave(form)}
>
Transfer erstellen
</Button>
</DialogActions>
</Dialog>
);
}
// ─── 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 }: {
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>Typ</InputLabel>
<Select size="small" value={filters.typ ?? ''} label="Typ"
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as TransaktionTyp) || undefined }))}>
<MenuItem value=""><em>Alle</em></MenuItem>
<MenuItem value="einnahme">Einnahme</MenuItem>
<MenuItem value="ausgabe">Ausgabe</MenuItem>
<MenuItem value="transfer">Transfer</MenuItem>
</Select>
</FormControl>
<TextField size="small" label="Suche" value={filters.search ?? ''}
@@ -1067,6 +1329,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
Erstattung erfassen
</Button>
)}
{hasPermission('buchhaltung:create') && (
<Button size="small" variant="outlined" startIcon={<SwapHoriz />} onClick={() => setTransferOpen(true)}>
Transfer
</Button>
)}
</Box>
</Paper>
@@ -1106,7 +1373,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
<TableCell>{fmtDate(t.datum)}</TableCell>
<TableCell>
<Chip label={TRANSAKTION_TYP_LABELS[t.typ]} size="small" color={t.typ === 'einnahme' ? 'success' : 'error'} />
<Chip
label={t.typ === 'transfer' ? `Transfer${t.transfer_ziel_bezeichnung ? ' \u2192 ' + t.transfer_ziel_bezeichnung : ''}` : TRANSAKTION_TYP_LABELS[t.typ]}
size="small"
color={t.typ === 'einnahme' ? 'success' : t.typ === 'transfer' ? 'info' : 'error'}
/>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
@@ -1117,8 +1388,8 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
</Box>
</TableCell>
<TableCell>{t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung}` : ''}</TableCell>
<TableCell align="right" sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main', fontWeight: 600 }}>
{t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)}
<TableCell align="right" sx={{ color: t.typ === 'einnahme' ? 'success.main' : t.typ === 'transfer' ? 'info.main' : 'error.main', fontWeight: 600 }}>
{t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)}
</TableCell>
<TableCell>
<Chip label={TRANSAKTION_STATUS_LABELS[t.status]} size="small" color={TRANSAKTION_STATUS_COLORS[t.status]} />
@@ -1198,6 +1469,15 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
transaktionen={transaktionen}
onSave={data => createErstattungMut.mutate(data)}
/>
<TransferDialog
open={transferOpen}
onClose={() => setTransferOpen(false)}
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
bankkonten={bankkonten}
onSave={data => createTransferMut.mutate(data)}
/>
</Box>
)}
@@ -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<string>('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 }: {
<Tab label="Konten" />
<Tab label="Bankkonten" />
<Tab label="Haushaltsjahre" />
{hasPermission('buchhaltung:manage_settings') && <Tab label="Einstellungen" />}
</Tabs>
</Box>
@@ -1620,7 +1940,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
<TableBody>
{bankkonten.length === 0 && <TableRow><TableCell colSpan={5} align="center"><Typography color="text.secondary">Keine Bankkonten</Typography></TableCell></TableRow>}
{bankkonten.map((bk: Bankkonto) => (
<TableRow key={bk.id} hover>
<TableRow key={bk.id} hover onClick={() => navigate(`/buchhaltung/bankkonto/${bk.id}`)} sx={{ cursor: 'pointer' }}>
<TableCell>{bk.bezeichnung}</TableCell>
<TableCell>{bk.iban || ''}</TableCell>
<TableCell>{bk.institut || ''}</TableCell>
@@ -1701,6 +2021,97 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
/>
</Box>
)}
{/* Sub-Tab 3: Einstellungen */}
{subTab === 3 && hasPermission('buchhaltung:manage_settings') && (
<Box>
{/* Konto-Typen CRUD */}
<Typography variant="h6" sx={{ mb: 2 }}>Konto-Typen</Typography>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setKontoTypForm({ bezeichnung: '', art: 'ausgabe', sort_order: 0 }); setKontoTypDialog({ open: true }); }}>Konto-Typ anlegen</Button>
</Box>
<TableContainer component={Paper} sx={{ mb: 4 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Art</TableCell>
<TableCell align="right">Sortierung</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kontoTypen.length === 0 && <TableRow><TableCell colSpan={4} align="center"><Typography color="text.secondary">Keine Konto-Typen</Typography></TableCell></TableRow>}
{kontoTypen.map((kt: KontoTyp) => (
<TableRow key={kt.id} hover>
<TableCell>{kt.bezeichnung}</TableCell>
<TableCell>{KONTO_ART_LABELS[kt.art] || kt.art}</TableCell>
<TableCell align="right">{kt.sort_order}</TableCell>
<TableCell>
<IconButton size="small" onClick={() => { setKontoTypForm({ bezeichnung: kt.bezeichnung, art: kt.art, sort_order: kt.sort_order }); setKontoTypDialog({ open: true, existing: kt }); }}><Edit fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteKontoTypMut.mutate(kt.id)}><Delete fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* Alert-Schwellwert */}
<Typography variant="h6" sx={{ mb: 2 }}>Budget-Warnung</Typography>
<Paper sx={{ p: 3 }}>
<Stack direction="row" spacing={2} alignItems="center">
<TextField
label="Standard-Schwellwert"
type="number"
value={alertThreshold}
onChange={e => setAlertThreshold(e.target.value)}
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
helperText="Budget-Warnung wird ausgelöst, wenn die Auslastung diesen Wert erreicht"
sx={{ width: 280 }}
/>
<Button
variant="contained"
startIcon={<Save />}
onClick={() => saveEinstellungenMut.mutate({ default_alert_threshold: alertThreshold })}
disabled={saveEinstellungenMut.isPending}
>
Speichern
</Button>
</Stack>
</Paper>
{/* Konto-Typ Dialog */}
<Dialog open={kontoTypDialog.open} onClose={() => setKontoTypDialog({ open: false })} maxWidth="sm" fullWidth>
<DialogTitle>{kontoTypDialog.existing ? 'Konto-Typ bearbeiten' : 'Neuer Konto-Typ'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Bezeichnung" value={kontoTypForm.bezeichnung} onChange={e => setKontoTypForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
<FormControl fullWidth>
<InputLabel>Art</InputLabel>
<Select value={kontoTypForm.art} label="Art" onChange={e => setKontoTypForm(f => ({ ...f, art: e.target.value }))}>
<MenuItem value="einnahme">Einnahmen</MenuItem>
<MenuItem value="ausgabe">Ausgaben</MenuItem>
<MenuItem value="vermoegen">Vermögen</MenuItem>
<MenuItem value="verbindlichkeit">Verbindlichkeiten</MenuItem>
</Select>
</FormControl>
<TextField label="Sortierung" type="number" value={kontoTypForm.sort_order} onChange={e => setKontoTypForm(f => ({ ...f, sort_order: parseInt(e.target.value, 10) || 0 }))} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setKontoTypDialog({ open: false })}>Abbrechen</Button>
<Button variant="contained" onClick={() => {
if (kontoTypDialog.existing) {
updateKontoTypMut.mutate({ id: kontoTypDialog.existing.id, data: kontoTypForm });
} else {
createKontoTypMut.mutate(kontoTypForm);
}
}}>Speichern</Button>
</DialogActions>
</Dialog>
</Box>
)}
</Box>
);
}

View File

@@ -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 (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Tooltip title="Zurueck">
<IconButton onClick={() => navigate('/buchhaltung?tab=2')}>
<ArrowBack />
</IconButton>
</Tooltip>
<Typography variant="h4" sx={{ flexGrow: 1 }}>
{data?.bankkonto?.bezeichnung ?? 'Bankkonto'}
{data?.bankkonto?.iban && (
<Typography component="span" variant="body1" color="text.secondary" sx={{ ml: 2 }}>
{data.bankkonto.iban}
</Typography>
)}
</Typography>
</Box>
{/* Date range filter */}
<Paper variant="outlined" sx={{ p: 1.5, mb: 3 }}>
<Stack direction="row" spacing={2} alignItems="center">
<TextField
size="small"
label="Von"
type="date"
value={von}
onChange={e => setVon(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
size="small"
label="Bis"
type="date"
value={bis}
onChange={e => setBis(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button variant="contained" size="small" onClick={handleApply}>
Anwenden
</Button>
</Stack>
</Paper>
{isLoading && <CircularProgress />}
{isError && <Typography color="error">Fehler beim Laden der Kontodaten.</Typography>}
{data && !isLoading && (
<>
{/* Summary cards */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mb: 3 }}>
<Card>
<CardContent>
<Typography color="text.secondary" variant="body2">Einnahmen</Typography>
<Typography variant="h5" color="success.main">{fmtEur(data.einnahmen)}</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography color="text.secondary" variant="body2">Ausgaben</Typography>
<Typography variant="h5" color="error.main">{fmtEur(data.ausgaben)}</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography color="text.secondary" variant="body2">Saldo</Typography>
<Typography variant="h5" color={data.saldo >= 0 ? 'success.main' : 'error.main'}>{fmtEur(data.saldo)}</Typography>
</CardContent>
</Card>
</Box>
{/* Statement table */}
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Datum</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Beleg-Nr.</TableCell>
<TableCell align="right">Betrag</TableCell>
<TableCell align="right">Laufender Saldo</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography color="text.secondary">Keine Transaktionen im Zeitraum</Typography>
</TableCell>
</TableRow>
)}
{data.rows.map((row: BankkontoStatementRow) => {
const isEinnahme = row.typ === 'einnahme';
const isTransfer = row.typ === 'transfer';
return (
<TableRow key={row.id} hover>
<TableCell>{fmtDate(row.datum)}</TableCell>
<TableCell>
<Chip
label={isTransfer ? `Transfer${row.transfer_ziel_bezeichnung ? ' \u2192 ' + row.transfer_ziel_bezeichnung : ''}` : TRANSAKTION_TYP_LABELS[row.typ]}
size="small"
color={isEinnahme ? 'success' : isTransfer ? 'info' : 'error'}
/>
</TableCell>
<TableCell>{row.beschreibung || '\u2013'}</TableCell>
<TableCell>{row.beleg_nr || '\u2013'}</TableCell>
<TableCell
align="right"
sx={{
color: isEinnahme ? 'success.main' : isTransfer ? 'info.main' : 'error.main',
fontWeight: 600,
}}
>
{row.typ === 'ausgabe' ? '-' : row.typ === 'einnahme' ? '+' : ''}{fmtEur(row.betrag)}
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>
{fmtEur(row.laufender_saldo)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</>
)}
</DashboardLayout>
);
}

View File

@@ -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<PlanungStatus, string> = {
entwurf: 'Entwurf',
aktiv: 'Aktiv',
abgeschlossen: 'Abgeschlossen',
};
const STATUS_COLORS: Record<PlanungStatus, 'default' | 'success' | 'info'> = {
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 (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>Haushaltspläne</Typography>
{canManage && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setForm({ bezeichnung: '', haushaltsjahr_id: '' }); setDialogOpen(true); }}>
Neuer Haushaltsplan
</Button>
)}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Haushaltsjahr</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Positionen</TableCell>
<TableCell align="right">Gesamt (GWG + Anl. + Inst.)</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!isLoading && planungen.length === 0 && (
<TableRow><TableCell colSpan={7} align="center"><Typography color="text.secondary">Keine Haushaltspläne vorhanden</Typography></TableCell></TableRow>
)}
{planungen.map((p: Planung) => {
const total = Number(p.total_gwg) + Number(p.total_anlagen) + Number(p.total_instandhaltung);
return (
<TableRow key={p.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/haushaltsplan/${p.id}`)}>
<TableCell>{p.bezeichnung}</TableCell>
<TableCell>{p.haushaltsjahr_bezeichnung || ''}</TableCell>
<TableCell><Chip label={STATUS_LABELS[p.status] || p.status} size="small" color={STATUS_COLORS[p.status] || 'default'} /></TableCell>
<TableCell align="right">{p.positionen_count}</TableCell>
<TableCell align="right">{fmtEur(total)}</TableCell>
<TableCell>{fmtDate(p.erstellt_am)}</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<IconButton size="small" onClick={() => navigate(`/haushaltsplan/${p.id}`)}><Visibility fontSize="small" /></IconButton>
{canManage && <IconButton size="small" color="error" onClick={() => deleteMut.mutate(p.id)}><Delete fontSize="small" /></IconButton>}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neuer Haushaltsplan</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
<FormControl fullWidth>
<InputLabel>Haushaltsjahr (optional)</InputLabel>
<Select value={form.haushaltsjahr_id} label="Haushaltsjahr (optional)" onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: e.target.value as string }))}>
<MenuItem value="">Kein Haushaltsjahr</MenuItem>
{haushaltsjahre.map((hj: Haushaltsjahr) => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.bezeichnung.trim()}
onClick={() => createMut.mutate({
bezeichnung: form.bezeichnung.trim(),
haushaltsjahr_id: form.haushaltsjahr_id ? Number(form.haushaltsjahr_id) : undefined,
})}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}

View File

@@ -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<PlanungStatus, string> = {
entwurf: 'Entwurf',
aktiv: 'Aktiv',
abgeschlossen: 'Abgeschlossen',
};
const STATUS_COLORS: Record<PlanungStatus, 'default' | 'success' | 'info'> = {
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<typeof buchhaltungApi.createPlanposition>[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<typeof buchhaltungApi.updatePlanposition>[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 <DashboardLayout><Typography>Laden...</Typography></DashboardLayout>;
if (isError || !planung) return <DashboardLayout><Alert severity="error">Haushaltsplan nicht gefunden</Alert></DashboardLayout>;
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 (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/haushaltsplan')}><ArrowBack /></IconButton>
<Typography variant="h4" sx={{ flexGrow: 1 }}>{planung.bezeichnung}</Typography>
<Chip label={STATUS_LABELS[planung.status] || planung.status} color={STATUS_COLORS[planung.status] || 'default'} />
</Box>
{planung.haushaltsjahr_bezeichnung && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Haushaltsjahr: {planung.haushaltsjahr_bezeichnung}
</Typography>
)}
{!planung.haushaltsjahr_id && canManage && (
<Alert severity="info" sx={{ mb: 2 }} action={
<Button color="inherit" size="small" startIcon={<PlaylistAdd />} onClick={() => createHjMut.mutate()} disabled={createHjMut.isPending}>
Haushaltsjahr erstellen
</Button>
}>
Diesem Plan ist noch kein Haushaltsjahr zugeordnet. Sie können aus diesem Plan ein Haushaltsjahr mit Konten erstellen.
</Alert>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Positionen ({positionen.length})</Typography>
{canManage && (
<Button variant="contained" startIcon={<AddIcon />} onClick={openAddDialog}>Position hinzufügen</Button>
)}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Konto</TableCell>
<TableCell align="right">GWG</TableCell>
<TableCell align="right">Anlagen</TableCell>
<TableCell align="right">Instandh.</TableCell>
<TableCell align="right">Gesamt</TableCell>
{canManage && <TableCell>Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{positionen.length === 0 && (
<TableRow><TableCell colSpan={canManage ? 7 : 6} align="center"><Typography color="text.secondary">Keine Positionen</Typography></TableCell></TableRow>
)}
{positionen.map((pos: Planposition) => {
const rowTotal = Number(pos.budget_gwg) + Number(pos.budget_anlagen) + Number(pos.budget_instandhaltung);
return (
<TableRow key={pos.id} hover>
<TableCell>{pos.bezeichnung}</TableCell>
<TableCell>{pos.konto_bezeichnung ? `${pos.konto_kontonummer} ${pos.konto_bezeichnung}` : ''}</TableCell>
<TableCell align="right">{fmtEur(Number(pos.budget_gwg))}</TableCell>
<TableCell align="right">{fmtEur(Number(pos.budget_anlagen))}</TableCell>
<TableCell align="right">{fmtEur(Number(pos.budget_instandhaltung))}</TableCell>
<TableCell align="right">{fmtEur(rowTotal)}</TableCell>
{canManage && (
<TableCell>
<IconButton size="small" onClick={() => openEditDialog(pos)}><Edit fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deletePosMut.mutate(pos.id)}><Delete fontSize="small" /></IconButton>
</TableCell>
)}
</TableRow>
);
})}
{positionen.length > 0 && (
<TableRow sx={{ '& td': { fontWeight: 'bold' } }}>
<TableCell colSpan={2}>Summe</TableCell>
<TableCell align="right">{fmtEur(totalGwg)}</TableCell>
<TableCell align="right">{fmtEur(totalAnlagen)}</TableCell>
<TableCell align="right">{fmtEur(totalInstandhaltung)}</TableCell>
<TableCell align="right">{fmtEur(totalGwg + totalAnlagen + totalInstandhaltung)}</TableCell>
{canManage && <TableCell />}
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Position Add/Edit Dialog */}
<Dialog open={posDialog.open} onClose={() => setPosDialog({ open: false })} maxWidth="sm" fullWidth>
<DialogTitle>{posDialog.existing ? 'Position bearbeiten' : 'Neue Position'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Bezeichnung" value={posForm.bezeichnung} onChange={e => setPosForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
{konten.length > 0 && (
<FormControl fullWidth>
<InputLabel>Konto (optional)</InputLabel>
<Select value={posForm.konto_id} label="Konto (optional)" onChange={e => setPosForm(f => ({ ...f, konto_id: e.target.value as string }))}>
<MenuItem value="">Kein Konto</MenuItem>
{konten.map((k: Konto) => <MenuItem key={k.id} value={k.id}>{k.kontonummer} {k.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
)}
<TextField
label="Budget GWG"
type="number"
value={posForm.budget_gwg}
onChange={e => setPosForm(f => ({ ...f, budget_gwg: e.target.value }))}
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
/>
<TextField
label="Budget Anlagen"
type="number"
value={posForm.budget_anlagen}
onChange={e => setPosForm(f => ({ ...f, budget_anlagen: e.target.value }))}
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
/>
<TextField
label="Budget Instandhaltung"
type="number"
value={posForm.budget_instandhaltung}
onChange={e => setPosForm(f => ({ ...f, budget_instandhaltung: e.target.value }))}
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
/>
<TextField label="Notizen" value={posForm.notizen} onChange={e => setPosForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setPosDialog({ open: false })}>Abbrechen</Button>
<Button variant="contained" disabled={!posForm.bezeichnung.trim()} onClick={handlePosSave}>Speichern</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}

View File

@@ -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<void> => {
await api.delete(`/api/buchhaltung/bankkonten/${id}`);
},
getBankkontoStatement: async (id: number, params?: { von?: string; bis?: string }): Promise<BankkontoStatement> => {
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<Konto[]> => {
@@ -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<BuchhaltungAudit[]> => {
const r = await api.get(`/api/buchhaltung/audit/${transaktionId}`);
return r.data.data;
},
// ── Einstellungen ─────────────────────────────────────────────────────────
getEinstellungen: async (): Promise<BuchhaltungEinstellungen> => {
const r = await api.get('/api/buchhaltung/einstellungen');
return r.data.data;
},
setEinstellungen: async (data: Record<string, unknown>): Promise<void> => {
await api.put('/api/buchhaltung/einstellungen', data);
},
// ── Planung ───────────────────────────────────────────────────────────────
listPlanungen: async (): Promise<Planung[]> => {
const r = await api.get('/api/buchhaltung/planung');
return r.data.data;
},
getPlanung: async (id: number): Promise<PlanungDetail> => {
const r = await api.get(`/api/buchhaltung/planung/${id}`);
return r.data.data;
},
createPlanung: async (data: { haushaltsjahr_id?: number; bezeichnung: string }): Promise<Planung> => {
const r = await api.post('/api/buchhaltung/planung', data);
return r.data.data;
},
updatePlanung: async (id: number, data: { bezeichnung?: string; status?: string }): Promise<Planung> => {
const r = await api.put(`/api/buchhaltung/planung/${id}`, data);
return r.data.data;
},
deletePlanung: async (id: number): Promise<void> => {
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<Planposition> => {
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<Planposition> => {
const r = await api.put(`/api/buchhaltung/planung/positionen/${id}`, data);
return r.data.data;
},
deletePlanposition: async (id: number): Promise<void> => {
await api.delete(`/api/buchhaltung/planung/positionen/${id}`);
},
createHaushaltsjahrFromPlan: async (planungId: number): Promise<Haushaltsjahr> => {
const r = await api.post(`/api/buchhaltung/planung/${planungId}/create-haushaltsjahr`);
return r.data.data;
},
};

View File

@@ -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<TransaktionStatus, 'default' | 'w
export const TRANSAKTION_TYP_LABELS: Record<TransaktionTyp, string> = {
einnahme: 'Einnahme',
ausgabe: 'Ausgabe',
transfer: 'Transfer',
};
export const KONTO_ART_LABELS: Record<KontoArt, string> = {
@@ -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[];
}