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:
Matthias Hochmeister
2026-03-30 09:49:28 +02:00
parent bc39963746
commit 0c5432b50e
9 changed files with 673 additions and 116 deletions

View File

@@ -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> {
try {
const data = await buchhaltungService.createKonto(req.body, req.user!.id);

View File

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

View File

@@ -8,6 +8,7 @@ const router = Router();
// ── Stats ─────────────────────────────────────────────────────────────────────
router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController));
router.get('/stats/pending', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPendingCount.bind(buchhaltungController));
// ── Haushaltsjahre ─────────────────────────────────────────────────────────────
router.get('/haushaltsjahre', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listHaushaltsjahre.bind(buchhaltungController));
@@ -25,8 +26,10 @@ router.patch('/bankkonten/:id', authenticate, requirePermission('buchhaltung:man
router.delete('/bankkonten/:id',authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteBankkonto.bind(buchhaltungController));
// ── Konten ────────────────────────────────────────────────────────────────────
router.get('/konten/tree', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontenTree.bind(buchhaltungController));
router.get('/konten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKonten.bind(buchhaltungController));
router.post('/konten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createKonto.bind(buchhaltungController));
router.get('/konten/:id/detail', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoDetail.bind(buchhaltungController));
router.patch('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateKonto.bind(buchhaltungController));
router.delete('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteKonto.bind(buchhaltungController));
router.get('/konten/:id/budget', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoBudget.bind(buchhaltungController));

View File

@@ -245,9 +245,11 @@ async function deactivateBankkonto(id: number) {
async function getAllKonten(haushaltsjahrId: number) {
try {
const result = await pool.query(
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
k.parent_id, pk.bezeichnung AS parent_bezeichnung
FROM buchhaltung_konten k
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
ORDER BY k.kontonummer`,
[haushaltsjahrId]
@@ -262,9 +264,11 @@ async function getAllKonten(haushaltsjahrId: number) {
async function getKontoById(id: number) {
try {
const result = await pool.query(
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
pk.bezeichnung AS parent_bezeichnung
FROM buchhaltung_konten k
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
WHERE k.id = $1`,
[id]
);
@@ -275,16 +279,100 @@ async function getKontoById(id: number) {
}
}
async function getKontenTree(haushaltsjahrId: number) {
try {
const result = await pool.query(
`SELECT k.*,
kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
pk.bezeichnung AS parent_bezeichnung,
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='gwg' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_gwg,
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='anlagen' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_anlagen,
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='instandhaltung' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_instandhaltung,
COALESCE(SUM(CASE WHEN t.typ='einnahme' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS einnahmen_betrag
FROM buchhaltung_konten k
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
GROUP BY k.id, kt.bezeichnung, kt.art, pk.bezeichnung
ORDER BY k.kontonummer`,
[haushaltsjahrId]
);
return result.rows;
} catch (error) {
logger.error('BuchhaltungService.getKontenTree failed', { error, haushaltsjahrId });
throw new Error('Kontenbaum konnte nicht geladen werden');
}
}
async function getKontoDetail(kontoId: number) {
try {
const kontoResult = await pool.query(
`SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
pk.bezeichnung AS parent_bezeichnung
FROM buchhaltung_konten k
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
WHERE k.id = $1`,
[kontoId]
);
if (!kontoResult.rows[0]) return null;
const childrenResult = await pool.query(
`SELECT * FROM buchhaltung_konten WHERE parent_id = $1 AND aktiv = TRUE ORDER BY kontonummer`,
[kontoId]
);
const transaktionenResult = await pool.query(
`SELECT t.*,
k.bezeichnung as konto_bezeichnung,
k.kontonummer as konto_kontonummer,
bk.bezeichnung as bankkonto_bezeichnung
FROM buchhaltung_transaktionen t
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id
WHERE t.konto_id = $1
ORDER BY t.datum DESC, t.id DESC`,
[kontoId]
);
return {
konto: kontoResult.rows[0],
children: childrenResult.rows,
transaktionen: transaktionenResult.rows,
};
} catch (error) {
logger.error('BuchhaltungService.getKontoDetail failed', { error, kontoId });
throw new Error('Kontodetails konnten nicht geladen werden');
}
}
async function getPendingCount(haushaltsjahrId?: number): Promise<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(
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; budget_betrag?: number; notizen?: string },
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string },
userId: string
) {
try {
const result = await pool.query(
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, budget_betrag, notizen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.budget_betrag || 0, data.notizen || null, userId]
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.parent_id || null, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, userId]
);
return result.rows[0];
} catch (error) {
@@ -295,7 +383,7 @@ async function createKonto(
async function updateKonto(
id: number,
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; budget_betrag?: number; notizen?: string }
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string }
) {
try {
const fields: string[] = [];
@@ -304,7 +392,10 @@ async function updateKonto(
if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); }
if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); }
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
if (data.budget_betrag !== undefined){ fields.push(`budget_betrag = $${idx++}`); values.push(data.budget_betrag); }
if (data.parent_id !== undefined) { fields.push(`parent_id = $${idx++}`); values.push(data.parent_id || null); }
if (data.budget_gwg !== undefined) { fields.push(`budget_gwg = $${idx++}`); values.push(data.budget_gwg); }
if (data.budget_anlagen !== undefined) { fields.push(`budget_anlagen = $${idx++}`); values.push(data.budget_anlagen); }
if (data.budget_instandhaltung !== undefined) { fields.push(`budget_instandhaltung = $${idx++}`); values.push(data.budget_instandhaltung); }
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
values.push(id);
@@ -348,7 +439,7 @@ async function getBudgetUtilisation(id: number) {
const row = result.rows[0];
const gebucht = parseFloat(row.gebucht_betrag);
const ausstehend = parseFloat(row.ausstehend_betrag);
const budget = parseFloat(row.budget_betrag);
const budget = parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung);
return {
...row,
gebucht_betrag: gebucht,
@@ -451,14 +542,15 @@ async function createTransaktion(
empfaenger_auftraggeber?: string;
verwendungszweck?: string;
beleg_nr?: string;
ausgaben_typ?: string | null;
},
userId: string
) {
try {
const result = await pool.query(
`INSERT INTO buchhaltung_transaktionen
(haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
(haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, ausgaben_typ, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *`,
[
data.haushaltsjahr_id,
@@ -471,6 +563,7 @@ async function createTransaktion(
data.empfaenger_auftraggeber || null,
data.verwendungszweck || null,
data.beleg_nr || null,
data.typ === 'ausgabe' ? (data.ausgaben_typ || null) : null,
userId,
]
);
@@ -798,9 +891,9 @@ async function getOverview(haushaltsjahrId: number) {
...row,
gebucht_betrag: parseFloat(row.gebucht_betrag),
ausstehend_betrag: parseFloat(row.ausstehend_betrag),
verfuegbar_betrag: parseFloat(row.budget_betrag) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag),
auslastung_prozent: parseFloat(row.budget_betrag) > 0
? Math.round((parseFloat(row.gebucht_betrag) / parseFloat(row.budget_betrag)) * 100)
verfuegbar_betrag: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag),
auslastung_prozent: (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung)) > 0
? Math.round((parseFloat(row.gebucht_betrag) / (parseFloat(row.budget_gwg) + parseFloat(row.budget_anlagen) + parseFloat(row.budget_instandhaltung))) * 100)
: 0,
})),
};
@@ -1019,6 +1112,9 @@ const buchhaltungService = {
deactivateBankkonto,
getAllKonten,
getKontoById,
getKontenTree,
getKontoDetail,
getPendingCount,
createKonto,
updateKonto,
deleteKonto,

View File

@@ -39,6 +39,7 @@ import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikel
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
import Checklisten from './pages/Checklisten';
import Buchhaltung from './pages/Buchhaltung';
import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail';
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
import Issues from './pages/Issues';
@@ -379,6 +380,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/buchhaltung/konto/:id"
element={
<ProtectedRoute>
<BuchhaltungKontoDetail />
</ProtectedRoute>
}
/>
<Route
path="/checklisten/ausfuehrung/:id"
element={

View File

@@ -1,6 +1,10 @@
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Badge,
Box,
Button,
Card,
@@ -40,13 +44,16 @@ import {
Delete,
Download,
Edit,
ExpandLess as ExpandLessIcon,
ExpandMore as ExpandMoreIcon,
FilterList as FilterListIcon,
HowToReg,
Lock,
ThumbDown,
ThumbUp,
} from '@mui/icons-material';
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 { bestellungApi } from '../services/bestellung';
import ChatAwareFab from '../components/shared/ChatAwareFab';
@@ -56,8 +63,10 @@ import type {
Haushaltsjahr, HaushaltsjahrFormData,
Bankkonto, BankkontoFormData,
Konto, KontoFormData,
KontoTreeNode,
Transaktion, TransaktionFormData, TransaktionFilters,
TransaktionStatus,
AusgabenTyp,
WiederkehrendBuchung, WiederkehrendFormData,
WiederkehrendIntervall,
} from '../types/buchhaltung.types';
@@ -78,6 +87,10 @@ function fmtDate(val: string) {
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 ────────────────────────────────────────────────────────────
function HaushaltsjahrDialog({
@@ -172,21 +185,23 @@ function KontoDialog({
onClose,
haushaltsjahrId,
existing,
konten,
onSave,
}: {
open: boolean;
onClose: () => void;
haushaltsjahrId: number;
existing?: Konto;
konten: Konto[];
onSave: (data: KontoFormData) => void;
}) {
const { data: kontoTypen = [] } = useQuery({ queryKey: ['konto-typen'], queryFn: buchhaltungApi.getKontoTypen });
const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: '', bezeichnung: '', budget_betrag: 0, notizen: '' };
const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: '', bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, parent_id: null, notizen: '' };
const [form, setForm] = useState<KontoFormData>(empty);
useEffect(() => {
if (existing) {
setForm({ haushaltsjahr_id: haushaltsjahrId, konto_typ_id: existing.konto_typ_id ?? undefined, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_betrag: existing.budget_betrag, notizen: existing.notizen || '' });
setForm({ haushaltsjahr_id: haushaltsjahrId, konto_typ_id: existing.konto_typ_id ?? undefined, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_gwg: existing.budget_gwg, budget_anlagen: existing.budget_anlagen, budget_instandhaltung: existing.budget_instandhaltung, parent_id: existing.parent_id, notizen: existing.notizen || '' });
} else {
setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId });
}
@@ -207,7 +222,25 @@ function KontoDialog({
{kontoTypen.map(kt => <MenuItem key={kt.id} value={kt.id}>{kt.bezeichnung}</MenuItem>)}
</Select>
</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} />
</Stack>
</DialogContent>
@@ -245,6 +278,7 @@ function TransaktionDialog({
verwendungszweck: '',
beleg_nr: '',
bestellung_id: null,
ausgaben_typ: null,
});
const { data: konten = [] } = useQuery({
@@ -282,6 +316,21 @@ function TransaktionDialog({
<MenuItem value="einnahme">Einnahme</MenuItem>
</Select>
</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="Datum" type="date" value={form.datum} onChange={e => setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required />
<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 ─────────────────────────────────────────────────────────
function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
@@ -326,12 +438,19 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
selectedJahrId: number | null;
onJahrChange: (id: number) => void;
}) {
const { data: stats, isLoading } = useQuery({
queryKey: ['buchhaltung-stats', selectedJahrId],
queryFn: () => buchhaltungApi.getStats(selectedJahrId!),
const navigate = useNavigate();
const { data: treeData = [], isLoading } = useQuery({
queryKey: ['kontenTree', selectedJahrId],
queryFn: () => buchhaltungApi.getKontenTree(selectedJahrId!),
enabled: selectedJahrId != null,
});
const tree = buildTree(treeData);
const totalEinnahmen = treeData.reduce((s, k) => s + Number(k.einnahmen_betrag), 0);
const totalAusgaben = treeData.reduce((s, k) => s + Number(k.spent_gwg) + Number(k.spent_anlagen) + Number(k.spent_instandhaltung), 0);
const saldo = totalEinnahmen - totalAusgaben;
return (
<Box>
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
@@ -344,53 +463,56 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
</Box>
{isLoading && <CircularProgress />}
{stats && (
{!isLoading && selectedJahrId && (
<>
<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(stats.total_einnahmen)}</Typography>
<Typography variant="h5" color="success.main">{fmtEur(totalEinnahmen)}</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<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>
</Card>
<Card>
<CardContent>
<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>
</Card>
</Box>
<Typography variant="h6" gutterBottom>Konten</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2 }}>
{stats.konten_budget.map(k => (
<Card key={k.id}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600}>{k.kontonummer} {k.bezeichnung}</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>{k.konto_typ_bezeichnung || ''}</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2">Gebucht: {fmtEur(k.gebucht_betrag)}</Typography>
<Typography variant="body2">Budget: {fmtEur(k.budget_betrag)}</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(k.auslastung_prozent, 100)}
color={k.auslastung_prozent >= 90 ? 'error' : k.auslastung_prozent >= 75 ? 'warning' : 'primary'}
sx={{ height: 8, borderRadius: 4 }}
/>
<Typography variant="caption" color={k.auslastung_prozent >= 90 ? 'error' : 'text.secondary'}>
{k.auslastung_prozent}% · Verfügbar: {fmtEur(k.verfuegbar_betrag)}
</Typography>
</CardContent>
</Card>
<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>
<TableCell align="right">Ausgaben GWG</TableCell>
<TableCell align="right">Ausgaben Anlagen</TableCell>
<TableCell align="right">Ausgaben Instandh.</TableCell>
<TableCell align="right">Ausgaben Gesamt</TableCell>
<TableCell align="right">Einnahmen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tree.length === 0 && (
<TableRow><TableCell colSpan={10} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>
)}
{tree.map(k => (
<KontoRow key={k.id} konto={k} onNavigate={(id) => navigate(`/buchhaltung/konto/${id}`)} />
))}
</Box>
</TableBody>
</Table>
</TableContainer>
</>
)}
{!selectedJahrId && !isLoading && (
@@ -412,6 +534,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
const { hasPermission } = usePermissionContext();
const [filters, setFilters] = useState<TransaktionFilters>({ haushaltsjahr_id: selectedJahrId || undefined });
const [createOpen, setCreateOpen] = useState(false);
const [filterAusgabenTyp, setFilterAusgabenTyp] = useState('');
const { data: transaktionen = [], isLoading } = useQuery({
queryKey: ['buchhaltung-transaktionen', filters],
@@ -479,10 +602,32 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
setFilters(f => ({ ...f, haushaltsjahr_id: selectedJahrId || undefined }));
}, [selectedJahrId]);
const activeFilterCount = [
filters.status,
filters.typ,
filters.search,
filterAusgabenTyp,
].filter(Boolean).length;
const filteredTransaktionen = filterAusgabenTyp
? transaktionen.filter(t => t.ausgaben_typ === filterAusgabenTyp)
: transaktionen;
return (
<Box>
{/* Filters */}
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterListIcon fontSize="small" />
<Typography>Filter</Typography>
{activeFilterCount > 0 && (
<Badge badgeContent={activeFilterCount} color="primary" />
)}
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>Haushaltsjahr</InputLabel>
<Select size="small" value={filters.haushaltsjahr_id ?? ''} label="Haushaltsjahr"
@@ -510,6 +655,19 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
</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}>
@@ -518,6 +676,8 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
</Tooltip>
)}
</Box>
</AccordionDetails>
</Accordion>
{isLoading ? <CircularProgress /> : (
<TableContainer component={Paper}>
@@ -535,10 +695,10 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
</TableRow>
</TableHead>
<TableBody>
{transaktionen.length === 0 && (
{filteredTransaktionen.length === 0 && (
<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>
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
<TableCell>{fmtDate(t.datum)}</TableCell>
@@ -849,18 +1009,26 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
<TableCell>Kontonummer</TableCell>
<TableCell>Bezeichnung</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>}
</TableRow>
</TableHead>
<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) => (
<TableRow key={k.id} hover>
<TableCell>{k.kontonummer}</TableCell>
<TableCell>{k.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 && (
<TableCell>
<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 })}
haushaltsjahrId={selectedJahrId}
existing={kontoDialog.existing}
konten={konten}
onSave={data => kontoDialog.existing
? updateKontoMut.mutate({ id: kontoDialog.existing.id, data })
: createKontoMut.mutate(data)
@@ -1051,11 +1220,11 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function Buchhaltung() {
const location = useLocation();
const navigate = useNavigate();
const searchParams = new URLSearchParams(location.search);
const tabFromUrl = parseInt(searchParams.get('tab') || '0', 10);
const [tab, setTab] = useState(isNaN(tabFromUrl) ? 0 : tabFromUrl);
const [searchParams, setSearchParams] = useSearchParams();
const tabValue = parseInt(searchParams.get('tab') || '0', 10);
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
setSearchParams({ tab: String(newValue) });
};
const [selectedJahrId, setSelectedJahrId] = useState<number | null>(null);
const { data: haushaltsjahre = [] } = useQuery({
@@ -1063,16 +1232,18 @@ export default function Buchhaltung() {
queryFn: buchhaltungApi.getHaushaltsjahre,
onSuccess: (data: Haushaltsjahr[]) => {
if (data.length > 0 && !selectedJahrId) {
const active = data.find(hj => !hj.abgeschlossen) || data[0];
setSelectedJahrId(active.id);
const openYear = data.find(hj => !hj.abgeschlossen) || data[0];
setSelectedJahrId(openYear.id);
}
},
});
const handleTabChange = (_: React.SyntheticEvent, newVal: number) => {
setTab(newVal);
navigate(`/buchhaltung?tab=${newVal}`, { replace: true });
};
const { data: pendingCount } = useQuery({
queryKey: ['buchhaltungPending', selectedJahrId],
queryFn: () => buchhaltungApi.getPendingCount(selectedJahrId || undefined),
enabled: !!selectedJahrId,
refetchInterval: 30000,
});
return (
<DashboardLayout>
@@ -1081,33 +1252,33 @@ export default function Buchhaltung() {
<Typography variant="h4" fontWeight={700}>Buchhaltung</Typography>
</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="Transaktionen" />
<Tab label={<Badge badgeContent={pendingCount || 0} color="warning" invisible={!pendingCount}><span>Transaktionen</span></Badge>} />
<Tab label="Konten" />
</Tabs>
{tab === 0 && (
<TabPanel value={tabValue} index={0}>
<UebersichtTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
)}
{tab === 1 && (
</TabPanel>
<TabPanel value={tabValue} index={1}>
<TransaktionenTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
)}
{tab === 2 && (
</TabPanel>
<TabPanel value={tabValue} index={2}>
<KontenTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
)}
</TabPanel>
</Box>
</DashboardLayout>
);

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

View File

@@ -3,7 +3,7 @@ import type {
Haushaltsjahr, HaushaltsjahrFormData,
Bankkonto, BankkontoFormData,
Konto, KontoFormData, KontoBudgetInfo,
KontoTyp,
KontoTyp, KontoTreeNode, KontoDetailResponse,
Transaktion, TransaktionFormData, TransaktionFilters,
Beleg,
BuchhaltungStats,
@@ -74,12 +74,25 @@ export const buchhaltungApi = {
const r = await api.get(`/api/buchhaltung/konten/${id}/budget`);
return r.data.data;
},
getKontenTree: async (haushaltsjahrId: number): Promise<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 ────────────────────────────────────────────────────────────────────
getStats: async (haushaltsjahrId: number): Promise<BuchhaltungStats> => {
const r = await api.get(`/api/buchhaltung/stats?haushaltsjahr_id=${haushaltsjahrId}`);
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 ─────────────────────────────────────────────────────────────
getTransaktionen: async (filters?: TransaktionFilters): Promise<Transaktion[]> => {

View File

@@ -5,6 +5,13 @@ export type TransaktionStatus = 'entwurf' | 'gebucht' | 'freigegeben' | 'stornie
export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt';
export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
export type PlanungStatus = 'entwurf' | 'aktiv' | 'abgeschlossen';
export type AusgabenTyp = 'gwg' | 'anlagen' | 'instandhaltung';
export const AUSGABEN_TYP_LABELS: Record<AusgabenTyp, string> = {
gwg: 'GWG',
anlagen: 'Anlagen',
instandhaltung: 'Instandhaltung',
};
// Label maps
export const TRANSAKTION_STATUS_LABELS: Record<TransaktionStatus, string> = {
@@ -79,7 +86,10 @@ export interface Konto {
konto_typ_id: number | null;
kontonummer: string;
bezeichnung: string;
budget_betrag: number;
parent_id: number | null;
budget_gwg: number;
budget_anlagen: number;
budget_instandhaltung: number;
notizen: string | null;
aktiv: boolean;
erstellt_von: string | null;
@@ -88,6 +98,15 @@ export interface Konto {
// Joined fields
konto_typ_bezeichnung?: string;
konto_typ_art?: KontoArt;
parent_bezeichnung?: string;
}
export interface KontoTreeNode extends Konto {
spent_gwg: number;
spent_anlagen: number;
spent_instandhaltung: number;
einnahmen_betrag: number;
children: KontoTreeNode[];
}
export interface KontoBudgetInfo extends Konto {
@@ -113,6 +132,8 @@ export interface Transaktion {
beleg_nr: string | null;
status: TransaktionStatus;
bestellung_id: number | null;
ausgaben_typ: AusgabenTyp | null;
wiederkehrend_id: number | null;
erstellt_von: string | null;
gebucht_von: string | null;
erstellt_am: string;
@@ -200,7 +221,10 @@ export interface KontoFormData {
konto_typ_id?: number;
kontonummer: string;
bezeichnung: string;
budget_betrag?: number;
budget_gwg: number;
budget_anlagen: number;
budget_instandhaltung: number;
parent_id?: number | null;
notizen?: string;
}
@@ -216,6 +240,7 @@ export interface TransaktionFormData {
verwendungszweck?: string;
beleg_nr?: string;
bestellung_id?: number | null;
ausgaben_typ?: AusgabenTyp | null;
}
// Filter type for transaction list
@@ -241,3 +266,9 @@ export interface WiederkehrendFormData {
naechste_ausfuehrung: string;
aktiv?: boolean;
}
export interface KontoDetailResponse {
konto: Konto;
children: KontoTreeNode[];
transaktionen: Transaktion[];
}