feat: add account hierarchy, budget types (GWG/Anlagen/Instandhaltung), and Buchhaltung UI overhaul with collapsible tree, pending badge, and konto detail page
This commit is contained in:
@@ -127,6 +127,42 @@ class BuchhaltungController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getKontenTree(req: Request, res: Response): Promise<void> {
|
||||||
|
const haushaltsjahrId = req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined;
|
||||||
|
if (!haushaltsjahrId || isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
|
||||||
|
try {
|
||||||
|
const tree = await buchhaltungService.getKontenTree(haushaltsjahrId);
|
||||||
|
res.json(tree);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungController.getKontenTree', { error });
|
||||||
|
res.status(500).json({ error: 'Fehler beim Laden des Kontenbaums' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKontoDetail(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const detail = await buchhaltungService.getKontoDetail(id);
|
||||||
|
if (!detail) { res.status(404).json({ error: 'Konto nicht gefunden' }); return; }
|
||||||
|
res.json(detail);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungController.getKontoDetail', { error });
|
||||||
|
res.status(500).json({ error: 'Fehler beim Laden des Kontos' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingCount(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const haushaltsjahrId = req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined;
|
||||||
|
const count = await buchhaltungService.getPendingCount(haushaltsjahrId);
|
||||||
|
res.json({ count });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungController.getPendingCount', { error });
|
||||||
|
res.status(500).json({ error: 'Fehler' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createKonto(req: Request, res: Response): Promise<void> {
|
async createKonto(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const data = await buchhaltungService.createKonto(req.body, req.user!.id);
|
const data = await buchhaltungService.createKonto(req.body, req.user!.id);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- 1. Add parent_id for account hierarchy
|
||||||
|
ALTER TABLE buchhaltung_konten ADD COLUMN parent_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX idx_buch_konten_parent ON buchhaltung_konten(parent_id);
|
||||||
|
|
||||||
|
-- 2. Replace budget_betrag with three type-specific budget columns
|
||||||
|
ALTER TABLE buchhaltung_konten ADD COLUMN budget_gwg NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE buchhaltung_konten ADD COLUMN budget_anlagen NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE buchhaltung_konten ADD COLUMN budget_instandhaltung NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||||
|
-- Migrate existing budget to GWG as default
|
||||||
|
UPDATE buchhaltung_konten SET budget_gwg = COALESCE(budget_betrag, 0);
|
||||||
|
ALTER TABLE buchhaltung_konten DROP COLUMN budget_betrag;
|
||||||
|
|
||||||
|
-- 3. Add ausgaben_typ to transactions (nullable: einnahmen have no type)
|
||||||
|
ALTER TABLE buchhaltung_transaktionen ADD COLUMN ausgaben_typ TEXT CHECK (ausgaben_typ IN ('gwg', 'anlagen', 'instandhaltung'));
|
||||||
|
|
||||||
|
-- 4. Add wiederkehrend_id to track auto-generated transactions
|
||||||
|
ALTER TABLE buchhaltung_transaktionen ADD COLUMN wiederkehrend_id INT REFERENCES buchhaltung_wiederkehrend(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX idx_buch_trans_wiederkehrend ON buchhaltung_transaktionen(wiederkehrend_id);
|
||||||
|
|
||||||
|
-- 5. Update planpositionen to have type-specific budgets
|
||||||
|
ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_gwg NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_anlagen NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_instandhaltung NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||||
|
UPDATE buchhaltung_planpositionen SET budget_gwg = COALESCE(plan_betrag, 0);
|
||||||
|
ALTER TABLE buchhaltung_planpositionen DROP COLUMN plan_betrag;
|
||||||
@@ -8,6 +8,7 @@ const router = Router();
|
|||||||
|
|
||||||
// ── Stats ─────────────────────────────────────────────────────────────────────
|
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController));
|
router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController));
|
||||||
|
router.get('/stats/pending', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPendingCount.bind(buchhaltungController));
|
||||||
|
|
||||||
// ── Haushaltsjahre ─────────────────────────────────────────────────────────────
|
// ── Haushaltsjahre ─────────────────────────────────────────────────────────────
|
||||||
router.get('/haushaltsjahre', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listHaushaltsjahre.bind(buchhaltungController));
|
router.get('/haushaltsjahre', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listHaushaltsjahre.bind(buchhaltungController));
|
||||||
@@ -25,8 +26,10 @@ router.patch('/bankkonten/:id', authenticate, requirePermission('buchhaltung:man
|
|||||||
router.delete('/bankkonten/:id',authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteBankkonto.bind(buchhaltungController));
|
router.delete('/bankkonten/:id',authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteBankkonto.bind(buchhaltungController));
|
||||||
|
|
||||||
// ── Konten ────────────────────────────────────────────────────────────────────
|
// ── Konten ────────────────────────────────────────────────────────────────────
|
||||||
|
router.get('/konten/tree', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontenTree.bind(buchhaltungController));
|
||||||
router.get('/konten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKonten.bind(buchhaltungController));
|
router.get('/konten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKonten.bind(buchhaltungController));
|
||||||
router.post('/konten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createKonto.bind(buchhaltungController));
|
router.post('/konten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createKonto.bind(buchhaltungController));
|
||||||
|
router.get('/konten/:id/detail', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoDetail.bind(buchhaltungController));
|
||||||
router.patch('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateKonto.bind(buchhaltungController));
|
router.patch('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateKonto.bind(buchhaltungController));
|
||||||
router.delete('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteKonto.bind(buchhaltungController));
|
router.delete('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteKonto.bind(buchhaltungController));
|
||||||
router.get('/konten/:id/budget', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoBudget.bind(buchhaltungController));
|
router.get('/konten/:id/budget', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoBudget.bind(buchhaltungController));
|
||||||
|
|||||||
@@ -245,9 +245,11 @@ async function deactivateBankkonto(id: number) {
|
|||||||
async function getAllKonten(haushaltsjahrId: number) {
|
async function getAllKonten(haushaltsjahrId: number) {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art
|
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
||||||
|
k.parent_id, pk.bezeichnung AS parent_bezeichnung
|
||||||
FROM buchhaltung_konten k
|
FROM buchhaltung_konten k
|
||||||
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
||||||
|
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
||||||
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
|
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
|
||||||
ORDER BY k.kontonummer`,
|
ORDER BY k.kontonummer`,
|
||||||
[haushaltsjahrId]
|
[haushaltsjahrId]
|
||||||
@@ -262,9 +264,11 @@ async function getAllKonten(haushaltsjahrId: number) {
|
|||||||
async function getKontoById(id: number) {
|
async function getKontoById(id: number) {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art
|
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
||||||
|
pk.bezeichnung AS parent_bezeichnung
|
||||||
FROM buchhaltung_konten k
|
FROM buchhaltung_konten k
|
||||||
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
||||||
|
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
||||||
WHERE k.id = $1`,
|
WHERE k.id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
@@ -275,16 +279,100 @@ async function getKontoById(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getKontenTree(haushaltsjahrId: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT k.*,
|
||||||
|
kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
||||||
|
pk.bezeichnung AS parent_bezeichnung,
|
||||||
|
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='gwg' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_gwg,
|
||||||
|
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='anlagen' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_anlagen,
|
||||||
|
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='instandhaltung' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_instandhaltung,
|
||||||
|
COALESCE(SUM(CASE WHEN t.typ='einnahme' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS einnahmen_betrag
|
||||||
|
FROM buchhaltung_konten k
|
||||||
|
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
||||||
|
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
||||||
|
LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1
|
||||||
|
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
|
||||||
|
GROUP BY k.id, kt.bezeichnung, kt.art, pk.bezeichnung
|
||||||
|
ORDER BY k.kontonummer`,
|
||||||
|
[haushaltsjahrId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungService.getKontenTree failed', { error, haushaltsjahrId });
|
||||||
|
throw new Error('Kontenbaum konnte nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getKontoDetail(kontoId: number) {
|
||||||
|
try {
|
||||||
|
const kontoResult = await pool.query(
|
||||||
|
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
||||||
|
pk.bezeichnung AS parent_bezeichnung
|
||||||
|
FROM buchhaltung_konten k
|
||||||
|
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
||||||
|
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
||||||
|
WHERE k.id = $1`,
|
||||||
|
[kontoId]
|
||||||
|
);
|
||||||
|
if (!kontoResult.rows[0]) return null;
|
||||||
|
|
||||||
|
const childrenResult = await pool.query(
|
||||||
|
`SELECT * FROM buchhaltung_konten WHERE parent_id = $1 AND aktiv = TRUE ORDER BY kontonummer`,
|
||||||
|
[kontoId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const transaktionenResult = await pool.query(
|
||||||
|
`SELECT t.*,
|
||||||
|
k.bezeichnung as konto_bezeichnung,
|
||||||
|
k.kontonummer as konto_kontonummer,
|
||||||
|
bk.bezeichnung as bankkonto_bezeichnung
|
||||||
|
FROM buchhaltung_transaktionen t
|
||||||
|
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
|
||||||
|
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id
|
||||||
|
WHERE t.konto_id = $1
|
||||||
|
ORDER BY t.datum DESC, t.id DESC`,
|
||||||
|
[kontoId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
konto: kontoResult.rows[0],
|
||||||
|
children: childrenResult.rows,
|
||||||
|
transaktionen: transaktionenResult.rows,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungService.getKontoDetail failed', { error, kontoId });
|
||||||
|
throw new Error('Kontodetails konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPendingCount(haushaltsjahrId?: number): Promise<number> {
|
||||||
|
try {
|
||||||
|
let query = `SELECT COUNT(*) FROM buchhaltung_transaktionen WHERE status = 'entwurf'`;
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (haushaltsjahrId) {
|
||||||
|
query += ` AND haushaltsjahr_id = $1`;
|
||||||
|
params.push(haushaltsjahrId);
|
||||||
|
}
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
return parseInt(result.rows[0].count, 10);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungService.getPendingCount failed', { error });
|
||||||
|
throw new Error('Anzahl offener Entwürfe konnte nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createKonto(
|
async function createKonto(
|
||||||
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; budget_betrag?: number; notizen?: string },
|
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string },
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, budget_betrag, notizen, erstellt_von)
|
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.budget_betrag || 0, data.notizen || null, userId]
|
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.parent_id || null, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, userId]
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -295,17 +383,20 @@ async function createKonto(
|
|||||||
|
|
||||||
async function updateKonto(
|
async function updateKonto(
|
||||||
id: number,
|
id: number,
|
||||||
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; budget_betrag?: number; notizen?: string }
|
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); }
|
if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); }
|
||||||
if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); }
|
if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); }
|
||||||
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
|
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
|
||||||
if (data.budget_betrag !== undefined){ fields.push(`budget_betrag = $${idx++}`); values.push(data.budget_betrag); }
|
if (data.parent_id !== undefined) { fields.push(`parent_id = $${idx++}`); values.push(data.parent_id || null); }
|
||||||
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
|
if (data.budget_gwg !== undefined) { fields.push(`budget_gwg = $${idx++}`); values.push(data.budget_gwg); }
|
||||||
|
if (data.budget_anlagen !== undefined) { fields.push(`budget_anlagen = $${idx++}`); values.push(data.budget_anlagen); }
|
||||||
|
if (data.budget_instandhaltung !== undefined) { fields.push(`budget_instandhaltung = $${idx++}`); values.push(data.budget_instandhaltung); }
|
||||||
|
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
|
||||||
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||||
values.push(id);
|
values.push(id);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -348,7 +439,7 @@ async function getBudgetUtilisation(id: number) {
|
|||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
const gebucht = parseFloat(row.gebucht_betrag);
|
const gebucht = parseFloat(row.gebucht_betrag);
|
||||||
const ausstehend = parseFloat(row.ausstehend_betrag);
|
const ausstehend = parseFloat(row.ausstehend_betrag);
|
||||||
const budget = parseFloat(row.budget_betrag);
|
const budget = parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung);
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
gebucht_betrag: gebucht,
|
gebucht_betrag: gebucht,
|
||||||
@@ -451,14 +542,15 @@ async function createTransaktion(
|
|||||||
empfaenger_auftraggeber?: string;
|
empfaenger_auftraggeber?: string;
|
||||||
verwendungszweck?: string;
|
verwendungszweck?: string;
|
||||||
beleg_nr?: string;
|
beleg_nr?: string;
|
||||||
|
ausgaben_typ?: string | null;
|
||||||
},
|
},
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO buchhaltung_transaktionen
|
`INSERT INTO buchhaltung_transaktionen
|
||||||
(haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, erstellt_von)
|
(haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, ausgaben_typ, erstellt_von)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.haushaltsjahr_id,
|
data.haushaltsjahr_id,
|
||||||
@@ -471,6 +563,7 @@ async function createTransaktion(
|
|||||||
data.empfaenger_auftraggeber || null,
|
data.empfaenger_auftraggeber || null,
|
||||||
data.verwendungszweck || null,
|
data.verwendungszweck || null,
|
||||||
data.beleg_nr || null,
|
data.beleg_nr || null,
|
||||||
|
data.typ === 'ausgabe' ? (data.ausgaben_typ || null) : null,
|
||||||
userId,
|
userId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -798,9 +891,9 @@ async function getOverview(haushaltsjahrId: number) {
|
|||||||
...row,
|
...row,
|
||||||
gebucht_betrag: parseFloat(row.gebucht_betrag),
|
gebucht_betrag: parseFloat(row.gebucht_betrag),
|
||||||
ausstehend_betrag: parseFloat(row.ausstehend_betrag),
|
ausstehend_betrag: parseFloat(row.ausstehend_betrag),
|
||||||
verfuegbar_betrag: parseFloat(row.budget_betrag) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag),
|
verfuegbar_betrag: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag),
|
||||||
auslastung_prozent: parseFloat(row.budget_betrag) > 0
|
auslastung_prozent: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) > 0
|
||||||
? Math.round((parseFloat(row.gebucht_betrag) / parseFloat(row.budget_betrag)) * 100)
|
? Math.round((parseFloat(row.gebucht_betrag) / (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung))) * 100)
|
||||||
: 0,
|
: 0,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -1019,6 +1112,9 @@ const buchhaltungService = {
|
|||||||
deactivateBankkonto,
|
deactivateBankkonto,
|
||||||
getAllKonten,
|
getAllKonten,
|
||||||
getKontoById,
|
getKontoById,
|
||||||
|
getKontenTree,
|
||||||
|
getKontoDetail,
|
||||||
|
getPendingCount,
|
||||||
createKonto,
|
createKonto,
|
||||||
updateKonto,
|
updateKonto,
|
||||||
deleteKonto,
|
deleteKonto,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikel
|
|||||||
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
||||||
import Checklisten from './pages/Checklisten';
|
import Checklisten from './pages/Checklisten';
|
||||||
import Buchhaltung from './pages/Buchhaltung';
|
import Buchhaltung from './pages/Buchhaltung';
|
||||||
|
import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail';
|
||||||
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
|
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
|
||||||
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
|
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
|
||||||
import Issues from './pages/Issues';
|
import Issues from './pages/Issues';
|
||||||
@@ -379,6 +380,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/buchhaltung/konto/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BuchhaltungKontoDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/checklisten/ausfuehrung/:id"
|
path="/checklisten/ausfuehrung/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -40,13 +44,16 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
|
ExpandLess as ExpandLessIcon,
|
||||||
|
ExpandMore as ExpandMoreIcon,
|
||||||
|
FilterList as FilterListIcon,
|
||||||
HowToReg,
|
HowToReg,
|
||||||
Lock,
|
Lock,
|
||||||
ThumbDown,
|
ThumbDown,
|
||||||
ThumbUp,
|
ThumbUp,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { buchhaltungApi } from '../services/buchhaltung';
|
import { buchhaltungApi } from '../services/buchhaltung';
|
||||||
import { bestellungApi } from '../services/bestellung';
|
import { bestellungApi } from '../services/bestellung';
|
||||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
@@ -56,8 +63,10 @@ import type {
|
|||||||
Haushaltsjahr, HaushaltsjahrFormData,
|
Haushaltsjahr, HaushaltsjahrFormData,
|
||||||
Bankkonto, BankkontoFormData,
|
Bankkonto, BankkontoFormData,
|
||||||
Konto, KontoFormData,
|
Konto, KontoFormData,
|
||||||
|
KontoTreeNode,
|
||||||
Transaktion, TransaktionFormData, TransaktionFilters,
|
Transaktion, TransaktionFormData, TransaktionFilters,
|
||||||
TransaktionStatus,
|
TransaktionStatus,
|
||||||
|
AusgabenTyp,
|
||||||
WiederkehrendBuchung, WiederkehrendFormData,
|
WiederkehrendBuchung, WiederkehrendFormData,
|
||||||
WiederkehrendIntervall,
|
WiederkehrendIntervall,
|
||||||
} from '../types/buchhaltung.types';
|
} from '../types/buchhaltung.types';
|
||||||
@@ -78,6 +87,10 @@ function fmtDate(val: string) {
|
|||||||
return new Date(val).toLocaleDateString('de-DE');
|
return new Date(val).toLocaleDateString('de-DE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TabPanel({ children, value, index }: { children: React.ReactNode; value: number; index: number }) {
|
||||||
|
return value === index ? <Box role="tabpanel">{children}</Box> : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Sub-components ────────────────────────────────────────────────────────────
|
// ─── Sub-components ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function HaushaltsjahrDialog({
|
function HaushaltsjahrDialog({
|
||||||
@@ -172,21 +185,23 @@ function KontoDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
haushaltsjahrId,
|
haushaltsjahrId,
|
||||||
existing,
|
existing,
|
||||||
|
konten,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
haushaltsjahrId: number;
|
haushaltsjahrId: number;
|
||||||
existing?: Konto;
|
existing?: Konto;
|
||||||
|
konten: Konto[];
|
||||||
onSave: (data: KontoFormData) => void;
|
onSave: (data: KontoFormData) => void;
|
||||||
}) {
|
}) {
|
||||||
const { data: kontoTypen = [] } = useQuery({ queryKey: ['konto-typen'], queryFn: buchhaltungApi.getKontoTypen });
|
const { data: kontoTypen = [] } = useQuery({ queryKey: ['konto-typen'], queryFn: buchhaltungApi.getKontoTypen });
|
||||||
const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: '', bezeichnung: '', budget_betrag: 0, notizen: '' };
|
const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: '', bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, parent_id: null, notizen: '' };
|
||||||
const [form, setForm] = useState<KontoFormData>(empty);
|
const [form, setForm] = useState<KontoFormData>(empty);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setForm({ haushaltsjahr_id: haushaltsjahrId, konto_typ_id: existing.konto_typ_id ?? undefined, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_betrag: existing.budget_betrag, notizen: existing.notizen || '' });
|
setForm({ haushaltsjahr_id: haushaltsjahrId, konto_typ_id: existing.konto_typ_id ?? undefined, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_gwg: existing.budget_gwg, budget_anlagen: existing.budget_anlagen, budget_instandhaltung: existing.budget_instandhaltung, parent_id: existing.parent_id, notizen: existing.notizen || '' });
|
||||||
} else {
|
} else {
|
||||||
setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId });
|
setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId });
|
||||||
}
|
}
|
||||||
@@ -207,7 +222,25 @@ function KontoDialog({
|
|||||||
{kontoTypen.map(kt => <MenuItem key={kt.id} value={kt.id}>{kt.bezeichnung}</MenuItem>)}
|
{kontoTypen.map(kt => <MenuItem key={kt.id} value={kt.id}>{kt.bezeichnung}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<TextField label="Budget (€)" type="number" value={form.budget_betrag} onChange={e => setForm(f => ({ ...f, budget_betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01' }} />
|
<FormControl fullWidth margin="dense">
|
||||||
|
<InputLabel>Elternkonto (optional)</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.parent_id ?? ''}
|
||||||
|
onChange={e => setForm(f => ({ ...f, parent_id: e.target.value ? Number(e.target.value) : null }))}
|
||||||
|
label="Elternkonto (optional)"
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Kein Elternkonto</em></MenuItem>
|
||||||
|
{konten
|
||||||
|
.filter(k => k.id !== existing?.id)
|
||||||
|
.map(k => (
|
||||||
|
<MenuItem key={k.id} value={k.id}>{k.kontonummer} — {k.bezeichnung}</MenuItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField label="GWG Budget (€)" type="number" value={form.budget_gwg} onChange={e => setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
||||||
|
<TextField label="Anlagen Budget (€)" type="number" value={form.budget_anlagen} onChange={e => setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
||||||
|
<TextField label="Instandhaltung Budget (€)" type="number" value={form.budget_instandhaltung} onChange={e => setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
||||||
<TextField label="Notizen" value={form.notizen} onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
|
<TextField label="Notizen" value={form.notizen} onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -245,6 +278,7 @@ function TransaktionDialog({
|
|||||||
verwendungszweck: '',
|
verwendungszweck: '',
|
||||||
beleg_nr: '',
|
beleg_nr: '',
|
||||||
bestellung_id: null,
|
bestellung_id: null,
|
||||||
|
ausgaben_typ: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: konten = [] } = useQuery({
|
const { data: konten = [] } = useQuery({
|
||||||
@@ -282,6 +316,21 @@ function TransaktionDialog({
|
|||||||
<MenuItem value="einnahme">Einnahme</MenuItem>
|
<MenuItem value="einnahme">Einnahme</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
{form.typ === 'ausgabe' && (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Ausgaben-Typ</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.ausgaben_typ || ''}
|
||||||
|
onChange={e => setForm(f => ({ ...f, ausgaben_typ: (e.target.value as AusgabenTyp) || null }))}
|
||||||
|
label="Ausgaben-Typ"
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Kein Typ</em></MenuItem>
|
||||||
|
<MenuItem value="gwg">GWG</MenuItem>
|
||||||
|
<MenuItem value="anlagen">Anlagen</MenuItem>
|
||||||
|
<MenuItem value="instandhaltung">Instandhaltung</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
<TextField label="Betrag (€)" 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="Betrag (€)" 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="Datum" type="date" value={form.datum} onChange={e => setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required />
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
@@ -319,6 +368,69 @@ function TransaktionDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tree helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildTree(flat: KontoTreeNode[]): KontoTreeNode[] {
|
||||||
|
const map = new Map<number, KontoTreeNode>();
|
||||||
|
flat.forEach(k => map.set(k.id, { ...k, children: [] }));
|
||||||
|
const roots: KontoTreeNode[] = [];
|
||||||
|
flat.forEach(k => {
|
||||||
|
if (k.parent_id && map.has(k.parent_id)) {
|
||||||
|
map.get(k.parent_id)!.children.push(map.get(k.id)!);
|
||||||
|
} else {
|
||||||
|
roots.push(map.get(k.id)!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KontoRow({ konto, depth = 0, onNavigate }: { konto: KontoTreeNode; depth?: number; onNavigate: (id: number) => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const totalBudget = konto.budget_gwg + konto.budget_anlagen + konto.budget_instandhaltung;
|
||||||
|
const totalSpent = konto.spent_gwg + konto.spent_anlagen + konto.spent_instandhaltung;
|
||||||
|
const utilization = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ pl: 2 + depth * 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{konto.children.length > 0 && (
|
||||||
|
<IconButton size="small" onClick={() => setOpen(!open)}>
|
||||||
|
{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||||||
|
onClick={() => onNavigate(konto.id)}
|
||||||
|
>
|
||||||
|
{konto.kontonummer} — {konto.bezeichnung}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{totalBudget > 0 && (
|
||||||
|
<LinearProgress variant="determinate" value={Math.min(utilization, 100)}
|
||||||
|
color={utilization > 100 ? 'error' : utilization > 80 ? 'warning' : 'primary'}
|
||||||
|
sx={{ mt: 0.5, height: 4, borderRadius: 2 }} />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(konto.budget_gwg)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(konto.budget_anlagen)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(konto.budget_instandhaltung)}</TableCell>
|
||||||
|
<TableCell align="right"><strong>{fmtEur(totalBudget)}</strong></TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(konto.spent_gwg)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(konto.spent_anlagen)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(konto.spent_instandhaltung)}</TableCell>
|
||||||
|
<TableCell align="right"><strong>{fmtEur(totalSpent)}</strong></TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(konto.einnahmen_betrag)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{open && konto.children.map(child => (
|
||||||
|
<KontoRow key={child.id} konto={child} depth={depth + 1} onNavigate={onNavigate} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tab 0: Übersicht ─────────────────────────────────────────────────────────
|
// ─── Tab 0: Übersicht ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||||
@@ -326,12 +438,19 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
selectedJahrId: number | null;
|
selectedJahrId: number | null;
|
||||||
onJahrChange: (id: number) => void;
|
onJahrChange: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { data: stats, isLoading } = useQuery({
|
const navigate = useNavigate();
|
||||||
queryKey: ['buchhaltung-stats', selectedJahrId],
|
const { data: treeData = [], isLoading } = useQuery({
|
||||||
queryFn: () => buchhaltungApi.getStats(selectedJahrId!),
|
queryKey: ['kontenTree', selectedJahrId],
|
||||||
|
queryFn: () => buchhaltungApi.getKontenTree(selectedJahrId!),
|
||||||
enabled: selectedJahrId != null,
|
enabled: selectedJahrId != null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tree = buildTree(treeData);
|
||||||
|
|
||||||
|
const totalEinnahmen = treeData.reduce((s, k) => s + Number(k.einnahmen_betrag), 0);
|
||||||
|
const totalAusgaben = treeData.reduce((s, k) => s + Number(k.spent_gwg) + Number(k.spent_anlagen) + Number(k.spent_instandhaltung), 0);
|
||||||
|
const saldo = totalEinnahmen - totalAusgaben;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
@@ -344,53 +463,56 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{isLoading && <CircularProgress />}
|
{isLoading && <CircularProgress />}
|
||||||
{stats && (
|
{!isLoading && selectedJahrId && (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mb: 3 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mb: 3 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography color="text.secondary" variant="body2">Einnahmen</Typography>
|
<Typography color="text.secondary" variant="body2">Einnahmen</Typography>
|
||||||
<Typography variant="h5" color="success.main">{fmtEur(stats.total_einnahmen)}</Typography>
|
<Typography variant="h5" color="success.main">{fmtEur(totalEinnahmen)}</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography color="text.secondary" variant="body2">Ausgaben</Typography>
|
<Typography color="text.secondary" variant="body2">Ausgaben</Typography>
|
||||||
<Typography variant="h5" color="error.main">{fmtEur(stats.total_ausgaben)}</Typography>
|
<Typography variant="h5" color="error.main">{fmtEur(totalAusgaben)}</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography color="text.secondary" variant="body2">Saldo</Typography>
|
<Typography color="text.secondary" variant="body2">Saldo</Typography>
|
||||||
<Typography variant="h5" color={stats.saldo >= 0 ? 'success.main' : 'error.main'}>{fmtEur(stats.saldo)}</Typography>
|
<Typography variant="h5" color={saldo >= 0 ? 'success.main' : 'error.main'}>{fmtEur(saldo)}</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h6" gutterBottom>Konten</Typography>
|
<Typography variant="h6" gutterBottom>Konten</Typography>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2 }}>
|
<TableContainer component={Paper}>
|
||||||
{stats.konten_budget.map(k => (
|
<Table size="small">
|
||||||
<Card key={k.id}>
|
<TableHead>
|
||||||
<CardContent>
|
<TableRow>
|
||||||
<Typography variant="subtitle1" fontWeight={600}>{k.kontonummer} – {k.bezeichnung}</Typography>
|
<TableCell>Konto</TableCell>
|
||||||
<Typography variant="body2" color="text.secondary" gutterBottom>{k.konto_typ_bezeichnung || '–'}</Typography>
|
<TableCell align="right">Budget GWG</TableCell>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
<TableCell align="right">Budget Anlagen</TableCell>
|
||||||
<Typography variant="body2">Gebucht: {fmtEur(k.gebucht_betrag)}</Typography>
|
<TableCell align="right">Budget Instandh.</TableCell>
|
||||||
<Typography variant="body2">Budget: {fmtEur(k.budget_betrag)}</Typography>
|
<TableCell align="right">Budget Gesamt</TableCell>
|
||||||
</Box>
|
<TableCell align="right">Ausgaben GWG</TableCell>
|
||||||
<LinearProgress
|
<TableCell align="right">Ausgaben Anlagen</TableCell>
|
||||||
variant="determinate"
|
<TableCell align="right">Ausgaben Instandh.</TableCell>
|
||||||
value={Math.min(k.auslastung_prozent, 100)}
|
<TableCell align="right">Ausgaben Gesamt</TableCell>
|
||||||
color={k.auslastung_prozent >= 90 ? 'error' : k.auslastung_prozent >= 75 ? 'warning' : 'primary'}
|
<TableCell align="right">Einnahmen</TableCell>
|
||||||
sx={{ height: 8, borderRadius: 4 }}
|
</TableRow>
|
||||||
/>
|
</TableHead>
|
||||||
<Typography variant="caption" color={k.auslastung_prozent >= 90 ? 'error' : 'text.secondary'}>
|
<TableBody>
|
||||||
{k.auslastung_prozent}% · Verfügbar: {fmtEur(k.verfuegbar_betrag)}
|
{tree.length === 0 && (
|
||||||
</Typography>
|
<TableRow><TableCell colSpan={10} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
{tree.map(k => (
|
||||||
))}
|
<KontoRow key={k.id} konto={k} onNavigate={(id) => navigate(`/buchhaltung/konto/${id}`)} />
|
||||||
</Box>
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!selectedJahrId && !isLoading && (
|
{!selectedJahrId && !isLoading && (
|
||||||
@@ -412,6 +534,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
const { hasPermission } = usePermissionContext();
|
const { hasPermission } = usePermissionContext();
|
||||||
const [filters, setFilters] = useState<TransaktionFilters>({ haushaltsjahr_id: selectedJahrId || undefined });
|
const [filters, setFilters] = useState<TransaktionFilters>({ haushaltsjahr_id: selectedJahrId || undefined });
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [filterAusgabenTyp, setFilterAusgabenTyp] = useState('');
|
||||||
|
|
||||||
const { data: transaktionen = [], isLoading } = useQuery({
|
const { data: transaktionen = [], isLoading } = useQuery({
|
||||||
queryKey: ['buchhaltung-transaktionen', filters],
|
queryKey: ['buchhaltung-transaktionen', filters],
|
||||||
@@ -479,45 +602,82 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
setFilters(f => ({ ...f, haushaltsjahr_id: selectedJahrId || undefined }));
|
setFilters(f => ({ ...f, haushaltsjahr_id: selectedJahrId || undefined }));
|
||||||
}, [selectedJahrId]);
|
}, [selectedJahrId]);
|
||||||
|
|
||||||
|
const activeFilterCount = [
|
||||||
|
filters.status,
|
||||||
|
filters.typ,
|
||||||
|
filters.search,
|
||||||
|
filterAusgabenTyp,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
|
const filteredTransaktionen = filterAusgabenTyp
|
||||||
|
? transaktionen.filter(t => t.ausgaben_typ === filterAusgabenTyp)
|
||||||
|
: transaktionen;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Accordion sx={{ mb: 2 }}>
|
||||||
<FormControl sx={{ minWidth: 200 }}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
<InputLabel>Haushaltsjahr</InputLabel>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Select size="small" value={filters.haushaltsjahr_id ?? ''} label="Haushaltsjahr"
|
<FilterListIcon fontSize="small" />
|
||||||
onChange={e => { const v = Number(e.target.value); setFilters(f => ({ ...f, haushaltsjahr_id: v || undefined })); onJahrChange(v); }}>
|
<Typography>Filter</Typography>
|
||||||
<MenuItem value=""><em>Alle</em></MenuItem>
|
{activeFilterCount > 0 && (
|
||||||
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
<Badge badgeContent={activeFilterCount} color="primary" />
|
||||||
</Select>
|
)}
|
||||||
</FormControl>
|
</Box>
|
||||||
<FormControl sx={{ minWidth: 140 }}>
|
</AccordionSummary>
|
||||||
<InputLabel>Status</InputLabel>
|
<AccordionDetails>
|
||||||
<Select size="small" value={filters.status ?? ''} label="Status"
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
onChange={e => setFilters(f => ({ ...f, status: (e.target.value as TransaktionStatus) || undefined }))}>
|
<FormControl sx={{ minWidth: 200 }}>
|
||||||
<MenuItem value=""><em>Alle</em></MenuItem>
|
<InputLabel>Haushaltsjahr</InputLabel>
|
||||||
{(Object.entries(TRANSAKTION_STATUS_LABELS) as [TransaktionStatus, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
|
<Select size="small" value={filters.haushaltsjahr_id ?? ''} label="Haushaltsjahr"
|
||||||
</Select>
|
onChange={e => { const v = Number(e.target.value); setFilters(f => ({ ...f, haushaltsjahr_id: v || undefined })); onJahrChange(v); }}>
|
||||||
</FormControl>
|
<MenuItem value=""><em>Alle</em></MenuItem>
|
||||||
<FormControl sx={{ minWidth: 130 }}>
|
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
||||||
<InputLabel>Typ</InputLabel>
|
</Select>
|
||||||
<Select size="small" value={filters.typ ?? ''} label="Typ"
|
</FormControl>
|
||||||
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
|
<FormControl sx={{ minWidth: 140 }}>
|
||||||
<MenuItem value=""><em>Alle</em></MenuItem>
|
<InputLabel>Status</InputLabel>
|
||||||
<MenuItem value="einnahme">Einnahme</MenuItem>
|
<Select size="small" value={filters.status ?? ''} label="Status"
|
||||||
<MenuItem value="ausgabe">Ausgabe</MenuItem>
|
onChange={e => setFilters(f => ({ ...f, status: (e.target.value as TransaktionStatus) || undefined }))}>
|
||||||
</Select>
|
<MenuItem value=""><em>Alle</em></MenuItem>
|
||||||
</FormControl>
|
{(Object.entries(TRANSAKTION_STATUS_LABELS) as [TransaktionStatus, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
|
||||||
<TextField size="small" label="Suche" value={filters.search ?? ''}
|
</Select>
|
||||||
onChange={e => setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} />
|
</FormControl>
|
||||||
{hasPermission('buchhaltung:export') && (
|
<FormControl sx={{ minWidth: 130 }}>
|
||||||
<Tooltip title="CSV exportieren">
|
<InputLabel>Typ</InputLabel>
|
||||||
<IconButton size="small" onClick={handleExportCsv} disabled={!filters.haushaltsjahr_id}>
|
<Select size="small" value={filters.typ ?? ''} label="Typ"
|
||||||
<Download fontSize="small" />
|
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
|
||||||
</IconButton>
|
<MenuItem value=""><em>Alle</em></MenuItem>
|
||||||
</Tooltip>
|
<MenuItem value="einnahme">Einnahme</MenuItem>
|
||||||
)}
|
<MenuItem value="ausgabe">Ausgabe</MenuItem>
|
||||||
</Box>
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField size="small" label="Suche" value={filters.search ?? ''}
|
||||||
|
onChange={e => setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} />
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Ausgaben-Typ</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filterAusgabenTyp}
|
||||||
|
onChange={e => setFilterAusgabenTyp(e.target.value)}
|
||||||
|
label="Ausgaben-Typ"
|
||||||
|
>
|
||||||
|
<MenuItem value="">Alle</MenuItem>
|
||||||
|
<MenuItem value="gwg">GWG</MenuItem>
|
||||||
|
<MenuItem value="anlagen">Anlagen</MenuItem>
|
||||||
|
<MenuItem value="instandhaltung">Instandhaltung</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{hasPermission('buchhaltung:export') && (
|
||||||
|
<Tooltip title="CSV exportieren">
|
||||||
|
<IconButton size="small" onClick={handleExportCsv} disabled={!filters.haushaltsjahr_id}>
|
||||||
|
<Download fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
{isLoading ? <CircularProgress /> : (
|
{isLoading ? <CircularProgress /> : (
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
@@ -535,10 +695,10 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{transaktionen.length === 0 && (
|
{filteredTransaktionen.length === 0 && (
|
||||||
<TableRow><TableCell colSpan={8} align="center"><Typography color="text.secondary">Keine Transaktionen</Typography></TableCell></TableRow>
|
<TableRow><TableCell colSpan={8} align="center"><Typography color="text.secondary">Keine Transaktionen</Typography></TableCell></TableRow>
|
||||||
)}
|
)}
|
||||||
{transaktionen.map((t: Transaktion) => (
|
{filteredTransaktionen.map((t: Transaktion) => (
|
||||||
<TableRow key={t.id} hover>
|
<TableRow key={t.id} hover>
|
||||||
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
|
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
|
||||||
<TableCell>{fmtDate(t.datum)}</TableCell>
|
<TableCell>{fmtDate(t.datum)}</TableCell>
|
||||||
@@ -849,18 +1009,26 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
<TableCell>Kontonummer</TableCell>
|
<TableCell>Kontonummer</TableCell>
|
||||||
<TableCell>Bezeichnung</TableCell>
|
<TableCell>Bezeichnung</TableCell>
|
||||||
<TableCell>Typ</TableCell>
|
<TableCell>Typ</TableCell>
|
||||||
<TableCell align="right">Budget</TableCell>
|
<TableCell>Elternkonto</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>}
|
{canManage && <TableCell>Aktionen</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{konten.length === 0 && <TableRow><TableCell colSpan={5} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>}
|
{konten.length === 0 && <TableRow><TableCell colSpan={canManage ? 9 : 8} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>}
|
||||||
{konten.map((k: Konto) => (
|
{konten.map((k: Konto) => (
|
||||||
<TableRow key={k.id} hover>
|
<TableRow key={k.id} hover>
|
||||||
<TableCell>{k.kontonummer}</TableCell>
|
<TableCell>{k.kontonummer}</TableCell>
|
||||||
<TableCell>{k.bezeichnung}</TableCell>
|
<TableCell>{k.bezeichnung}</TableCell>
|
||||||
<TableCell>{k.konto_typ_bezeichnung || '–'}</TableCell>
|
<TableCell>{k.konto_typ_bezeichnung || '–'}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(k.budget_betrag)}</TableCell>
|
<TableCell>{k.parent_bezeichnung || '–'}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(k.budget_gwg)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(k.budget_anlagen)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(k.budget_instandhaltung)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(k.budget_gwg + k.budget_anlagen + k.budget_instandhaltung)}</TableCell>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<IconButton size="small" onClick={() => setKontoDialog({ open: true, existing: k })}><Edit fontSize="small" /></IconButton>
|
<IconButton size="small" onClick={() => setKontoDialog({ open: true, existing: k })}><Edit fontSize="small" /></IconButton>
|
||||||
@@ -877,6 +1045,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
onClose={() => setKontoDialog({ open: false })}
|
onClose={() => setKontoDialog({ open: false })}
|
||||||
haushaltsjahrId={selectedJahrId}
|
haushaltsjahrId={selectedJahrId}
|
||||||
existing={kontoDialog.existing}
|
existing={kontoDialog.existing}
|
||||||
|
konten={konten}
|
||||||
onSave={data => kontoDialog.existing
|
onSave={data => kontoDialog.existing
|
||||||
? updateKontoMut.mutate({ id: kontoDialog.existing.id, data })
|
? updateKontoMut.mutate({ id: kontoDialog.existing.id, data })
|
||||||
: createKontoMut.mutate(data)
|
: createKontoMut.mutate(data)
|
||||||
@@ -1051,11 +1220,11 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Buchhaltung() {
|
export default function Buchhaltung() {
|
||||||
const location = useLocation();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const tabValue = parseInt(searchParams.get('tab') || '0', 10);
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
const tabFromUrl = parseInt(searchParams.get('tab') || '0', 10);
|
setSearchParams({ tab: String(newValue) });
|
||||||
const [tab, setTab] = useState(isNaN(tabFromUrl) ? 0 : tabFromUrl);
|
};
|
||||||
const [selectedJahrId, setSelectedJahrId] = useState<number | null>(null);
|
const [selectedJahrId, setSelectedJahrId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: haushaltsjahre = [] } = useQuery({
|
const { data: haushaltsjahre = [] } = useQuery({
|
||||||
@@ -1063,16 +1232,18 @@ export default function Buchhaltung() {
|
|||||||
queryFn: buchhaltungApi.getHaushaltsjahre,
|
queryFn: buchhaltungApi.getHaushaltsjahre,
|
||||||
onSuccess: (data: Haushaltsjahr[]) => {
|
onSuccess: (data: Haushaltsjahr[]) => {
|
||||||
if (data.length > 0 && !selectedJahrId) {
|
if (data.length > 0 && !selectedJahrId) {
|
||||||
const active = data.find(hj => !hj.abgeschlossen) || data[0];
|
const openYear = data.find(hj => !hj.abgeschlossen) || data[0];
|
||||||
setSelectedJahrId(active.id);
|
setSelectedJahrId(openYear.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleTabChange = (_: React.SyntheticEvent, newVal: number) => {
|
const { data: pendingCount } = useQuery({
|
||||||
setTab(newVal);
|
queryKey: ['buchhaltungPending', selectedJahrId],
|
||||||
navigate(`/buchhaltung?tab=${newVal}`, { replace: true });
|
queryFn: () => buchhaltungApi.getPendingCount(selectedJahrId || undefined),
|
||||||
};
|
enabled: !!selectedJahrId,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@@ -1081,33 +1252,33 @@ export default function Buchhaltung() {
|
|||||||
<Typography variant="h4" fontWeight={700}>Buchhaltung</Typography>
|
<Typography variant="h4" fontWeight={700}>Buchhaltung</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 3 }}>
|
<Tabs value={tabValue} onChange={handleTabChange} variant="scrollable" scrollButtons="auto" sx={{ mb: 3 }}>
|
||||||
<Tab label="Übersicht" />
|
<Tab label="Übersicht" />
|
||||||
<Tab label="Transaktionen" />
|
<Tab label={<Badge badgeContent={pendingCount || 0} color="warning" invisible={!pendingCount}><span>Transaktionen</span></Badge>} />
|
||||||
<Tab label="Konten" />
|
<Tab label="Konten" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{tab === 0 && (
|
<TabPanel value={tabValue} index={0}>
|
||||||
<UebersichtTab
|
<UebersichtTab
|
||||||
haushaltsjahre={haushaltsjahre}
|
haushaltsjahre={haushaltsjahre}
|
||||||
selectedJahrId={selectedJahrId}
|
selectedJahrId={selectedJahrId}
|
||||||
onJahrChange={setSelectedJahrId}
|
onJahrChange={setSelectedJahrId}
|
||||||
/>
|
/>
|
||||||
)}
|
</TabPanel>
|
||||||
{tab === 1 && (
|
<TabPanel value={tabValue} index={1}>
|
||||||
<TransaktionenTab
|
<TransaktionenTab
|
||||||
haushaltsjahre={haushaltsjahre}
|
haushaltsjahre={haushaltsjahre}
|
||||||
selectedJahrId={selectedJahrId}
|
selectedJahrId={selectedJahrId}
|
||||||
onJahrChange={setSelectedJahrId}
|
onJahrChange={setSelectedJahrId}
|
||||||
/>
|
/>
|
||||||
)}
|
</TabPanel>
|
||||||
{tab === 2 && (
|
<TabPanel value={tabValue} index={2}>
|
||||||
<KontenTab
|
<KontenTab
|
||||||
haushaltsjahre={haushaltsjahre}
|
haushaltsjahre={haushaltsjahre}
|
||||||
selectedJahrId={selectedJahrId}
|
selectedJahrId={selectedJahrId}
|
||||||
onJahrChange={setSelectedJahrId}
|
onJahrChange={setSelectedJahrId}
|
||||||
/>
|
/>
|
||||||
)}
|
</TabPanel>
|
||||||
</Box>
|
</Box>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
173
frontend/src/pages/BuchhaltungKontoDetail.tsx
Normal file
173
frontend/src/pages/BuchhaltungKontoDetail.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Box, Typography, Button, Grid, Card, CardContent,
|
||||||
|
Table, TableHead, TableBody, TableRow, TableCell,
|
||||||
|
LinearProgress, Chip, Alert, Skeleton, TableContainer, Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ArrowBack } from '@mui/icons-material';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { buchhaltungApi } from '../services/buchhaltung';
|
||||||
|
import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types';
|
||||||
|
import type { AusgabenTyp } from '../types/buchhaltung.types';
|
||||||
|
|
||||||
|
function BudgetCard({ label, budget, spent }: { label: string; budget: number; spent: number }) {
|
||||||
|
const utilization = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
|
||||||
|
const over = spent > budget && budget > 0;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">{label}</Typography>
|
||||||
|
<Typography variant="h6">{spent.toFixed(2).replace('.', ',')} €</Typography>
|
||||||
|
{budget > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary">Budget: {budget.toFixed(2).replace('.', ',')} €</Typography>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={utilization}
|
||||||
|
color={over ? 'error' : utilization > 80 ? 'warning' : 'primary'}
|
||||||
|
sx={{ mt: 1, height: 6, borderRadius: 3 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuchhaltungKontoDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const kontoId = Number(id);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['kontoDetail', kontoId],
|
||||||
|
queryFn: () => buchhaltungApi.getKontoDetail(kontoId),
|
||||||
|
enabled: !!kontoId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <DashboardLayout><Skeleton variant="rectangular" height={400} /></DashboardLayout>;
|
||||||
|
if (isError || !data) return <DashboardLayout><Alert severity="error">Konto nicht gefunden.</Alert></DashboardLayout>;
|
||||||
|
|
||||||
|
const { konto, children, transaktionen } = data;
|
||||||
|
const totalEinnahmen = transaktionen
|
||||||
|
.filter(t => t.typ === 'einnahme' && t.status === 'gebucht')
|
||||||
|
.reduce((sum, t) => sum + Number(t.betrag), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung')}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h5" sx={{ ml: 1 }}>
|
||||||
|
{konto.kontonummer} — {konto.bezeichnung}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<BudgetCard label="GWG" budget={konto.budget_gwg} spent={
|
||||||
|
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'gwg').reduce((s, t) => s + Number(t.betrag), 0)
|
||||||
|
} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<BudgetCard label="Anlagen" budget={konto.budget_anlagen} spent={
|
||||||
|
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'anlagen').reduce((s, t) => s + Number(t.betrag), 0)
|
||||||
|
} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<BudgetCard label="Instandhaltung" budget={konto.budget_instandhaltung} spent={
|
||||||
|
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'instandhaltung').reduce((s, t) => s + Number(t.betrag), 0)
|
||||||
|
} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">Einnahmen</Typography>
|
||||||
|
<Typography variant="h6" color="success.main">{totalEinnahmen.toFixed(2).replace('.', ',')} €</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{children.length > 0 && (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>Unterkonten</Typography>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Konto</TableCell>
|
||||||
|
<TableCell align="right">Budget GWG</TableCell>
|
||||||
|
<TableCell align="right">Budget Anlagen</TableCell>
|
||||||
|
<TableCell align="right">Budget Instandh.</TableCell>
|
||||||
|
<TableCell align="right">Budget Gesamt</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{children.map(child => (
|
||||||
|
<TableRow
|
||||||
|
key={child.id}
|
||||||
|
hover
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/buchhaltung/konto/${child.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell>{child.kontonummer} — {child.bezeichnung}</TableCell>
|
||||||
|
<TableCell align="right">{Number(child.budget_gwg).toFixed(2)} €</TableCell>
|
||||||
|
<TableCell align="right">{Number(child.budget_anlagen).toFixed(2)} €</TableCell>
|
||||||
|
<TableCell align="right">{Number(child.budget_instandhaltung).toFixed(2)} €</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{(Number(child.budget_gwg) + Number(child.budget_anlagen) + Number(child.budget_instandhaltung)).toFixed(2)} €
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>Transaktionen</Typography>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Datum</TableCell>
|
||||||
|
<TableCell>Beschreibung</TableCell>
|
||||||
|
<TableCell>Typ</TableCell>
|
||||||
|
<TableCell>Ausgaben-Typ</TableCell>
|
||||||
|
<TableCell align="right">Betrag</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{transaktionen.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={6} align="center">Keine Transaktionen</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{transaktionen.map(t => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell>{new Date(t.datum).toLocaleDateString('de-DE')}</TableCell>
|
||||||
|
<TableCell>{t.beschreibung}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={t.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe'}
|
||||||
|
color={t.typ === 'einnahme' ? 'success' : 'error'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{t.ausgaben_typ ? AUSGABEN_TYP_LABELS[t.ausgaben_typ as AusgabenTyp] : '—'}</TableCell>
|
||||||
|
<TableCell align="right"
|
||||||
|
sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main' }}>
|
||||||
|
{t.typ === 'einnahme' ? '+' : '-'}{Number(t.betrag).toFixed(2).replace('.', ',')} €
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{t.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
Haushaltsjahr, HaushaltsjahrFormData,
|
Haushaltsjahr, HaushaltsjahrFormData,
|
||||||
Bankkonto, BankkontoFormData,
|
Bankkonto, BankkontoFormData,
|
||||||
Konto, KontoFormData, KontoBudgetInfo,
|
Konto, KontoFormData, KontoBudgetInfo,
|
||||||
KontoTyp,
|
KontoTyp, KontoTreeNode, KontoDetailResponse,
|
||||||
Transaktion, TransaktionFormData, TransaktionFilters,
|
Transaktion, TransaktionFormData, TransaktionFilters,
|
||||||
Beleg,
|
Beleg,
|
||||||
BuchhaltungStats,
|
BuchhaltungStats,
|
||||||
@@ -74,12 +74,25 @@ export const buchhaltungApi = {
|
|||||||
const r = await api.get(`/api/buchhaltung/konten/${id}/budget`);
|
const r = await api.get(`/api/buchhaltung/konten/${id}/budget`);
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
getKontenTree: async (haushaltsjahrId: number): Promise<KontoTreeNode[]> => {
|
||||||
|
const r = await api.get(`/api/buchhaltung/konten/tree?haushaltsjahr_id=${haushaltsjahrId}`);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
getKontoDetail: async (id: number): Promise<KontoDetailResponse> => {
|
||||||
|
const r = await api.get(`/api/buchhaltung/konten/${id}/detail`);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Stats ────────────────────────────────────────────────────────────────────
|
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||||
getStats: async (haushaltsjahrId: number): Promise<BuchhaltungStats> => {
|
getStats: async (haushaltsjahrId: number): Promise<BuchhaltungStats> => {
|
||||||
const r = await api.get(`/api/buchhaltung/stats?haushaltsjahr_id=${haushaltsjahrId}`);
|
const r = await api.get(`/api/buchhaltung/stats?haushaltsjahr_id=${haushaltsjahrId}`);
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
getPendingCount: async (haushaltsjahrId?: number): Promise<number> => {
|
||||||
|
const params = haushaltsjahrId ? `?haushaltsjahr_id=${haushaltsjahrId}` : '';
|
||||||
|
const r = await api.get(`/api/buchhaltung/stats/pending${params}`);
|
||||||
|
return r.data.count;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Transaktionen ─────────────────────────────────────────────────────────────
|
// ── Transaktionen ─────────────────────────────────────────────────────────────
|
||||||
getTransaktionen: async (filters?: TransaktionFilters): Promise<Transaktion[]> => {
|
getTransaktionen: async (filters?: TransaktionFilters): Promise<Transaktion[]> => {
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ export type TransaktionStatus = 'entwurf' | 'gebucht' | 'freigegeben' | 'stornie
|
|||||||
export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt';
|
export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt';
|
||||||
export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
|
export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
|
||||||
export type PlanungStatus = 'entwurf' | 'aktiv' | 'abgeschlossen';
|
export type PlanungStatus = 'entwurf' | 'aktiv' | 'abgeschlossen';
|
||||||
|
export type AusgabenTyp = 'gwg' | 'anlagen' | 'instandhaltung';
|
||||||
|
|
||||||
|
export const AUSGABEN_TYP_LABELS: Record<AusgabenTyp, string> = {
|
||||||
|
gwg: 'GWG',
|
||||||
|
anlagen: 'Anlagen',
|
||||||
|
instandhaltung: 'Instandhaltung',
|
||||||
|
};
|
||||||
|
|
||||||
// Label maps
|
// Label maps
|
||||||
export const TRANSAKTION_STATUS_LABELS: Record<TransaktionStatus, string> = {
|
export const TRANSAKTION_STATUS_LABELS: Record<TransaktionStatus, string> = {
|
||||||
@@ -79,7 +86,10 @@ export interface Konto {
|
|||||||
konto_typ_id: number | null;
|
konto_typ_id: number | null;
|
||||||
kontonummer: string;
|
kontonummer: string;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
budget_betrag: number;
|
parent_id: number | null;
|
||||||
|
budget_gwg: number;
|
||||||
|
budget_anlagen: number;
|
||||||
|
budget_instandhaltung: number;
|
||||||
notizen: string | null;
|
notizen: string | null;
|
||||||
aktiv: boolean;
|
aktiv: boolean;
|
||||||
erstellt_von: string | null;
|
erstellt_von: string | null;
|
||||||
@@ -88,6 +98,15 @@ export interface Konto {
|
|||||||
// Joined fields
|
// Joined fields
|
||||||
konto_typ_bezeichnung?: string;
|
konto_typ_bezeichnung?: string;
|
||||||
konto_typ_art?: KontoArt;
|
konto_typ_art?: KontoArt;
|
||||||
|
parent_bezeichnung?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KontoTreeNode extends Konto {
|
||||||
|
spent_gwg: number;
|
||||||
|
spent_anlagen: number;
|
||||||
|
spent_instandhaltung: number;
|
||||||
|
einnahmen_betrag: number;
|
||||||
|
children: KontoTreeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KontoBudgetInfo extends Konto {
|
export interface KontoBudgetInfo extends Konto {
|
||||||
@@ -113,6 +132,8 @@ export interface Transaktion {
|
|||||||
beleg_nr: string | null;
|
beleg_nr: string | null;
|
||||||
status: TransaktionStatus;
|
status: TransaktionStatus;
|
||||||
bestellung_id: number | null;
|
bestellung_id: number | null;
|
||||||
|
ausgaben_typ: AusgabenTyp | null;
|
||||||
|
wiederkehrend_id: number | null;
|
||||||
erstellt_von: string | null;
|
erstellt_von: string | null;
|
||||||
gebucht_von: string | null;
|
gebucht_von: string | null;
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
@@ -200,7 +221,10 @@ export interface KontoFormData {
|
|||||||
konto_typ_id?: number;
|
konto_typ_id?: number;
|
||||||
kontonummer: string;
|
kontonummer: string;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
budget_betrag?: number;
|
budget_gwg: number;
|
||||||
|
budget_anlagen: number;
|
||||||
|
budget_instandhaltung: number;
|
||||||
|
parent_id?: number | null;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +240,7 @@ export interface TransaktionFormData {
|
|||||||
verwendungszweck?: string;
|
verwendungszweck?: string;
|
||||||
beleg_nr?: string;
|
beleg_nr?: string;
|
||||||
bestellung_id?: number | null;
|
bestellung_id?: number | null;
|
||||||
|
ausgaben_typ?: AusgabenTyp | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter type for transaction list
|
// Filter type for transaction list
|
||||||
@@ -241,3 +266,9 @@ export interface WiederkehrendFormData {
|
|||||||
naechste_ausfuehrung: string;
|
naechste_ausfuehrung: string;
|
||||||
aktiv?: boolean;
|
aktiv?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KontoDetailResponse {
|
||||||
|
konto: Konto;
|
||||||
|
children: KontoTreeNode[];
|
||||||
|
transaktionen: Transaktion[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user