feat(buchhaltung): budget types, erstattungen, recurring tab move, overview dividers, order completion guard

This commit is contained in:
Matthias Hochmeister
2026-03-30 14:07:04 +02:00
parent 13aa4be599
commit b21abce9e3
10 changed files with 615 additions and 140 deletions

View File

@@ -486,6 +486,33 @@ class BuchhaltungController {
}
}
// ── Erstattungen ────────────────────────────────────────────────────────────
async createErstattung(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createErstattung({
...req.body,
erstellt_von: req.user!.id,
});
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createErstattung', { error });
res.status(500).json({ success: false, message: 'Erstattung konnte nicht erstellt werden' });
}
}
async getErstattungLinks(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.getErstattungLinks(id);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getErstattungLinks', { error });
res.status(500).json({ success: false, message: 'Erstattungsverknüpfungen konnten nicht geladen werden' });
}
}
// ── Freigaben ────────────────────────────────────────────────────────────────
async requestFreigabe(req: Request, res: Response): Promise<void> {

View File

@@ -0,0 +1,13 @@
-- Add budget type support to buchhaltung_konten
ALTER TABLE buchhaltung_konten
ADD COLUMN IF NOT EXISTS budget_typ TEXT NOT NULL DEFAULT 'detailliert';
ALTER TABLE buchhaltung_konten
ADD COLUMN IF NOT EXISTS budget_gesamt NUMERIC(12,2) NOT NULL DEFAULT 0;
-- Erstattung (reimbursement) linking table
CREATE TABLE IF NOT EXISTS buchhaltung_erstattung_zuordnungen (
erstattung_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
ausgabe_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
PRIMARY KEY (erstattung_transaktion_id, ausgabe_transaktion_id)
);

View File

@@ -53,6 +53,10 @@ router.post('/wiederkehrend', authenticate, requirePermission('buchhaltung
router.patch('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateWiederkehrend.bind(buchhaltungController));
router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteWiederkehrend.bind(buchhaltungController));
// ── Erstattungen ──────────────────────────────────────────────────────────────
router.post('/erstattungen', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createErstattung.bind(buchhaltungController));
router.get('/transaktionen/:id/erstattung-links', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getErstattungLinks.bind(buchhaltungController));
// ── CSV Export ─────────────────────────────────────────────────────────────────
router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController));

View File

@@ -471,18 +471,28 @@ async function validateSubPotBudget(
}
async function createKonto(
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string },
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; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number },
userId: string
) {
try {
if (data.parent_id && (data.budget_gwg || data.budget_anlagen || data.budget_instandhaltung)) {
await validateSubPotBudget(data.parent_id, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0);
}
// Child konten inherit parent's budget_typ
let budgetTyp = data.budget_typ || 'detailliert';
if (data.parent_id) {
const parentRow = await pool.query(`SELECT budget_typ FROM buchhaltung_konten WHERE id = $1`, [data.parent_id]);
if (parentRow.rows[0]) {
budgetTyp = parentRow.rows[0].budget_typ;
}
}
const result = await pool.query(
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von, kategorie_id, budget_typ, budget_gesamt)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[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]
[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, data.kategorie_id ?? null, budgetTyp, data.budget_gesamt || 0]
);
return result.rows[0];
} catch (error: any) {
@@ -497,7 +507,7 @@ async function createKonto(
async function updateKonto(
id: number,
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: 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; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number }
) {
try {
// Budget validation for sub-pots
@@ -527,7 +537,27 @@ async function updateKonto(
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 ('kategorie_id' in data) { fields.push(`kategorie_id = $${idx++}`); values.push(data.kategorie_id ?? null); }
if (data.budget_typ !== undefined) { fields.push(`budget_typ = $${idx++}`); values.push(data.budget_typ); }
if (data.budget_gesamt !== undefined) { fields.push(`budget_gesamt = $${idx++}`); values.push(data.budget_gesamt); }
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
// Child konten must inherit parent's budget_typ
if (data.budget_typ !== undefined) {
const currentRow = await pool.query(`SELECT parent_id FROM buchhaltung_konten WHERE id = $1`, [id]);
const parentId = data.parent_id !== undefined ? data.parent_id : currentRow.rows[0]?.parent_id;
if (parentId) {
const parentRow = await pool.query(`SELECT budget_typ FROM buchhaltung_konten WHERE id = $1`, [parentId]);
if (parentRow.rows[0]) {
// Override budget_typ with parent's value
const btIdx = fields.findIndex(f => f.startsWith('budget_typ'));
if (btIdx !== -1) {
values[btIdx] = parentRow.rows[0].budget_typ;
}
}
}
}
values.push(id);
const result = await pool.query(
`UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
@@ -1231,6 +1261,94 @@ async function exportTransaktionenCsv(haushaltsjahrId: number): Promise<string>
}
}
// ---------------------------------------------------------------------------
// Erstattungen (Reimbursements)
// ---------------------------------------------------------------------------
interface Transaktion {
id: number;
[key: string]: unknown;
}
async function createErstattung(data: {
konto_id: number;
bankkonto_id: number;
betrag: number;
datum: string;
beschreibung?: string;
empfaenger_auftraggeber?: string;
verwendungszweck?: string;
ausgabe_ids: number[];
erstellt_von?: number;
}): Promise<Transaktion> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Look up haushaltsjahr_id from the konto
const kontoResult = await client.query(
`SELECT haushaltsjahr_id FROM buchhaltung_konten WHERE id = $1`,
[data.konto_id]
);
const haushaltsjahrId = kontoResult.rows[0]?.haushaltsjahr_id;
const txResult = await client.query(
`INSERT INTO buchhaltung_transaktionen (typ, konto_id, bankkonto_id, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, erstellt_von, haushaltsjahr_id, status)
VALUES ('einnahme', $1, $2, $3, $4, $5, $6, $7, $8, $9, 'entwurf')
RETURNING *`,
[data.konto_id, data.bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.empfaenger_auftraggeber || null, data.verwendungszweck || null, data.erstellt_von || null, haushaltsjahrId]
);
const tx = txResult.rows[0];
for (const ausgabeId of data.ausgabe_ids) {
await client.query(
`INSERT INTO buchhaltung_erstattung_zuordnungen (erstattung_transaktion_id, ausgabe_transaktion_id) VALUES ($1, $2)`,
[tx.id, ausgabeId]
);
}
await client.query('COMMIT');
return tx;
} catch (error) {
await client.query('ROLLBACK');
logger.error('BuchhaltungService.createErstattung failed', { error });
throw new Error('Erstattung konnte nicht erstellt werden');
} finally {
client.release();
}
}
async function getErstattungLinks(transaktionId: number): Promise<{
erstattung?: any;
ausgaben?: any[];
}> {
try {
// If this transaction is an Ausgabe, find its reimbursement
const erstattungResult = await pool.query(
`SELECT t.* FROM buchhaltung_transaktionen t
JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.erstattung_transaktion_id
WHERE ez.ausgabe_transaktion_id = $1`,
[transaktionId]
);
// If this transaction is an Erstattung, find its linked Ausgaben
const ausgabenResult = await pool.query(
`SELECT t.* FROM buchhaltung_transaktionen t
JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.ausgabe_transaktion_id
WHERE ez.erstattung_transaktion_id = $1`,
[transaktionId]
);
return {
erstattung: erstattungResult.rows[0] || undefined,
ausgaben: ausgabenResult.rows,
};
} catch (error) {
logger.error('BuchhaltungService.getErstattungLinks failed', { error, transaktionId });
throw new Error('Erstattungsverknüpfungen konnten nicht geladen werden');
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
@@ -1286,6 +1404,8 @@ const buchhaltungService = {
updateWiederkehrend,
deleteWiederkehrend,
exportTransaktionenCsv,
createErstattung,
getErstattungLinks,
};
export default buchhaltungService;

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef } from 'react';
import {
Alert,
Box,
Typography,
Paper,
@@ -205,6 +206,7 @@ export default function BestellungDetail() {
const canApprove = hasPermission('bestellungen:approve');
const canExport = hasPermission('bestellungen:export');
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
const allCostsEntered = positionen.length === 0 || positionen.every(p => p.einzelpreis != null && Number(p.einzelpreis) > 0);
// All statuses except current, for force override
const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', 'abgeschlossen'];
@@ -766,7 +768,11 @@ export default function BestellungDetail() {
{/* ── Status Action ── */}
{(canManageOrders || canCreate || canApprove) && (
<Box sx={{ mb: 3, display: 'flex', gap: 1, alignItems: 'center' }}>
<Box sx={{ mb: 3 }}>
{validTransitions.includes('abgeschlossen' as BestellungStatus) && !allCostsEntered && (
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen haben Kosten. Bitte Einzelpreise eintragen, bevor die Bestellung abgeschlossen wird.</Alert>
)}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{validTransitions
.filter((s) => {
// Approve/reject transitions from wartet_auf_genehmigung require canApprove
@@ -787,11 +793,13 @@ export default function BestellungDetail() {
? 'Ablehnen'
: `Status: ${BESTELLUNG_STATUS_LABELS[s]}`;
const color = isApprove ? 'success' : isReject ? 'error' : 'primary';
const isAbgeschlossen = s === 'abgeschlossen';
return (
<Button
key={s}
variant="contained"
color={color as 'success' | 'error' | 'primary'}
disabled={isAbgeschlossen && !allCostsEntered}
onClick={() => { setStatusForce(false); setStatusConfirmTarget(s); }}
>
{label}
@@ -831,6 +839,7 @@ export default function BestellungDetail() {
</>
)}
</Box>
</Box>
)}
{/* ── Delivery Progress ── */}

View File

@@ -7,6 +7,7 @@ import {
Button,
Card,
CardContent,
Checkbox,
Chip,
CircularProgress,
Dialog,
@@ -34,6 +35,8 @@ import {
TableSortLabel,
Tabs,
TextField,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography,
} from '@mui/material';
@@ -69,6 +72,8 @@ import type {
AusgabenTyp,
WiederkehrendBuchung, WiederkehrendFormData,
WiederkehrendIntervall,
BudgetTyp,
ErstattungFormData,
} from '../types/buchhaltung.types';
import {
TRANSAKTION_STATUS_LABELS,
@@ -83,6 +88,8 @@ function fmtEur(val: number) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
}
const dividerLeft = { borderLeft: '2px solid', borderColor: 'divider' } as const;
function fmtDate(val: string) {
return new Date(val).toLocaleDateString('de-DE');
}
@@ -199,7 +206,7 @@ function KontoDialog({
onSave: (data: KontoFormData) => void;
externalError?: string | null;
}) {
const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: 0, bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, parent_id: null, kategorie_id: null, notizen: '' };
const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: 0, bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, budget_typ: 'detailliert', budget_gesamt: 0, parent_id: null, kategorie_id: null, notizen: '' };
const [form, setForm] = useState<KontoFormData>(empty);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -234,7 +241,7 @@ function KontoDialog({
useEffect(() => {
if (existing) {
setForm({ haushaltsjahr_id: haushaltsjahrId, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_gwg: existing.budget_gwg, budget_anlagen: existing.budget_anlagen, budget_instandhaltung: existing.budget_instandhaltung, parent_id: existing.parent_id, kategorie_id: existing.kategorie_id ?? null, notizen: existing.notizen || '' });
setForm({ haushaltsjahr_id: haushaltsjahrId, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_gwg: existing.budget_gwg, budget_anlagen: existing.budget_anlagen, budget_instandhaltung: existing.budget_instandhaltung, budget_typ: existing.budget_typ || 'detailliert', budget_gesamt: existing.budget_gesamt || 0, parent_id: existing.parent_id, kategorie_id: existing.kategorie_id ?? null, notizen: existing.notizen || '' });
} else {
setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId });
}
@@ -290,6 +297,24 @@ function KontoDialog({
</Select>
</FormControl>
)}
{!form.parent_id && (
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>Budget-Typ</Typography>
<ToggleButtonGroup
value={form.budget_typ || 'detailliert'}
exclusive
size="small"
onChange={(_, val) => { if (val) setForm(f => ({ ...f, budget_typ: val as BudgetTyp })); }}
>
<ToggleButton value="detailliert">Detailliert</ToggleButton>
<ToggleButton value="einfach">Einfach</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{(form.budget_typ || 'detailliert') === 'einfach' ? (
<TextField label="Budget Gesamt (€)" type="number" value={form.budget_gesamt ?? 0} onChange={e => setForm(f => ({ ...f, budget_gesamt: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
) : (
<>
<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' }} />
{selectedParent && siblingBudgets && (
<Typography variant="caption" color="text.secondary" sx={{ mt: -1 }}>
@@ -308,6 +333,8 @@ function KontoDialog({
Eltern-Budget: {fmtEur(selectedParent.budget_instandhaltung)}, vergeben: {fmtEur(siblingBudgets.instandhaltung)}, verfügbar: {fmtEur(selectedParent.budget_instandhaltung - siblingBudgets.instandhaltung)}
</Typography>
)}
</>
)}
<TextField label="Notizen" value={form.notizen} onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
{saveError && <Alert severity="error" onClose={() => setSaveError(null)}>{saveError}</Alert>}
{!saveError && externalError && <Alert severity="error">{externalError}</Alert>}
@@ -385,7 +412,10 @@ function TransaktionDialog({
<MenuItem value="einnahme">Einnahme</MenuItem>
</Select>
</FormControl>
{form.typ === 'ausgabe' && (
{form.typ === 'ausgabe' && (() => {
const selectedKonto = konten.find(k => k.id === form.konto_id);
const isEinfach = selectedKonto && (selectedKonto.budget_typ || 'detailliert') === 'einfach';
return !isEinfach ? (
<FormControl fullWidth>
<InputLabel>Ausgaben-Typ</InputLabel>
<Select
@@ -399,7 +429,8 @@ function TransaktionDialog({
<MenuItem value="instandhaltung">Instandhaltung</MenuItem>
</Select>
</FormControl>
)}
) : null;
})()}
<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>
@@ -455,7 +486,8 @@ function buildTree(flat: KontoTreeNode[]): KontoTreeNode[] {
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 isEinfach = (konto.budget_typ || 'detailliert') === 'einfach';
const totalBudget = isEinfach ? Number(konto.budget_gesamt || 0) : 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;
@@ -472,15 +504,15 @@ function KontoRow({ konto, depth = 0, onNavigate }: { konto: KontoTreeNode; dept
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" sx={dividerLeft}>{isEinfach ? '' : fmtEur(konto.budget_gwg)}</TableCell>
<TableCell align="right">{isEinfach ? '' : fmtEur(konto.budget_anlagen)}</TableCell>
<TableCell align="right">{isEinfach ? '' : 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" sx={dividerLeft}>{isEinfach ? '' : fmtEur(konto.spent_gwg)}</TableCell>
<TableCell align="right">{isEinfach ? '' : fmtEur(konto.spent_anlagen)}</TableCell>
<TableCell align="right">{isEinfach ? '' : fmtEur(konto.spent_instandhaltung)}</TableCell>
<TableCell align="right"><strong>{fmtEur(totalSpent)}</strong></TableCell>
<TableCell align="right">{fmtEur(konto.einnahmen_betrag)}</TableCell>
<TableCell align="right" sx={dividerLeft}>{fmtEur(konto.einnahmen_betrag)}</TableCell>
<TableCell sx={{ width: 40, px: 0.5 }}>
{konto.children.length > 0 && (
<IconButton size="small" onClick={e => { e.stopPropagation(); setOpen(!open); }}>
@@ -605,15 +637,15 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
<TableHead>
<TableRow>
<TableCell>Konto</TableCell>
<TableCell align="right">Budget GWG</TableCell>
<TableCell align="right" sx={dividerLeft}>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" sx={dividerLeft}>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>
<TableCell align="right" sx={dividerLeft}>Einnahmen</TableCell>
<TableCell sx={{ width: 40 }} />
</TableRow>
</TableHead>
@@ -650,15 +682,15 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
rows.push(
<TableRow key={`cat-${key}`} sx={{ bgcolor: 'grey.100', '& td': { fontWeight: 600, fontSize: '0.8rem' } }}>
<TableCell>{katName}</TableCell>
<TableCell align="right">{fmtEur(catBudgetGwg)}</TableCell>
<TableCell align="right" sx={dividerLeft}>{fmtEur(catBudgetGwg)}</TableCell>
<TableCell align="right">{fmtEur(catBudgetAnl)}</TableCell>
<TableCell align="right">{fmtEur(catBudgetInst)}</TableCell>
<TableCell align="right">{fmtEur(catBudgetGwg + catBudgetAnl + catBudgetInst)}</TableCell>
<TableCell align="right">{fmtEur(catSpentGwg)}</TableCell>
<TableCell align="right" sx={dividerLeft}>{fmtEur(catSpentGwg)}</TableCell>
<TableCell align="right">{fmtEur(catSpentAnl)}</TableCell>
<TableCell align="right">{fmtEur(catSpentInst)}</TableCell>
<TableCell align="right">{fmtEur(catSpentGwg + catSpentAnl + catSpentInst)}</TableCell>
<TableCell align="right">{fmtEur(catEinnahmen)}</TableCell>
<TableCell align="right" sx={dividerLeft}>{fmtEur(catEinnahmen)}</TableCell>
<TableCell />
</TableRow>
);
@@ -672,15 +704,15 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
{tree.length > 0 && (
<TableRow sx={{ bgcolor: 'action.hover', '& td': { fontWeight: 700 } }}>
<TableCell>Gesamt</TableCell>
<TableCell align="right">{fmtEur(sumBudgetGwg)}</TableCell>
<TableCell align="right" sx={dividerLeft}>{fmtEur(sumBudgetGwg)}</TableCell>
<TableCell align="right">{fmtEur(sumBudgetAnlagen)}</TableCell>
<TableCell align="right">{fmtEur(sumBudgetInst)}</TableCell>
<TableCell align="right">{fmtEur(sumBudgetGesamt)}</TableCell>
<TableCell align="right">{fmtEur(sumSpentGwg)}</TableCell>
<TableCell align="right" sx={dividerLeft}>{fmtEur(sumSpentGwg)}</TableCell>
<TableCell align="right">{fmtEur(sumSpentAnlagen)}</TableCell>
<TableCell align="right">{fmtEur(sumSpentInst)}</TableCell>
<TableCell align="right">{fmtEur(sumSpentGesamt)}</TableCell>
<TableCell align="right">{fmtEur(sumEinnahmen)}</TableCell>
<TableCell align="right" sx={dividerLeft}>{fmtEur(sumEinnahmen)}</TableCell>
<TableCell />
</TableRow>
)}
@@ -696,6 +728,141 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
);
}
// ─── Erstattung Dialog ────────────────────────────────────────────────────────
function ErstattungDialog({
open,
onClose,
konten,
bankkonten,
transaktionen,
onSave,
}: {
open: boolean;
onClose: () => void;
konten: Konto[];
bankkonten: Bankkonto[];
transaktionen: Transaktion[];
onSave: (data: ErstattungFormData) => void;
}) {
const today = new Date().toISOString().slice(0, 10);
const [selectedKonto, setSelectedKonto] = useState<number | null>(null);
const [selectedBank, setSelectedBank] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [betrag, setBetrag] = useState(0);
const [datum, setDatum] = useState(today);
const [beschreibung, setBeschreibung] = useState('');
const [empfaenger, setEmpfaenger] = useState('');
useEffect(() => {
if (open) {
setSelectedKonto(null);
setSelectedBank(null);
setSelectedIds(new Set());
setBetrag(0);
setDatum(today);
setBeschreibung('');
setEmpfaenger('');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const ausgabeTransaktionen = transaktionen.filter(
t => t.typ === 'ausgabe' && t.status === 'gebucht' && (!selectedKonto || t.konto_id === selectedKonto)
);
const autoSum = [...selectedIds].reduce((sum, id) => {
const t = transaktionen.find(tx => tx.id === id);
return sum + (t ? Number(t.betrag) : 0);
}, 0);
useEffect(() => {
setBetrag(autoSum);
}, [autoSum]);
const toggleId = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Erstattung erfassen</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<FormControl fullWidth required>
<InputLabel>Konto</InputLabel>
<Select value={selectedKonto ?? ''} label="Konto" onChange={e => { setSelectedKonto(e.target.value ? Number(e.target.value) : null); setSelectedIds(new Set()); }}>
<MenuItem value=""><em>Alle Konten</em></MenuItem>
{konten.map(k => <MenuItem key={k.id} value={k.id}>{k.kontonummer} {k.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Bankkonto</InputLabel>
<Select value={selectedBank ?? ''} label="Bankkonto" onChange={e => setSelectedBank(e.target.value ? Number(e.target.value) : null)}>
<MenuItem value=""><em>Kein Bankkonto</em></MenuItem>
{bankkonten.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
<Typography variant="subtitle2">Ausgabe-Transaktionen auswählen</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell padding="checkbox" />
<TableCell>Datum</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell align="right">Betrag</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ausgabeTransaktionen.length === 0 && (
<TableRow><TableCell colSpan={4} align="center"><Typography color="text.secondary" variant="body2">Keine Ausgaben gefunden</Typography></TableCell></TableRow>
)}
{ausgabeTransaktionen.map(t => (
<TableRow key={t.id} hover onClick={() => toggleId(t.id)} sx={{ cursor: 'pointer' }}>
<TableCell padding="checkbox"><Checkbox checked={selectedIds.has(t.id)} size="small" /></TableCell>
<TableCell>{fmtDate(t.datum)}</TableCell>
<TableCell>{t.beschreibung || t.empfaenger_auftraggeber || ''}</TableCell>
<TableCell align="right">{fmtEur(Number(t.betrag))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TextField label="Erstattungsbetrag (€)" type="number" value={betrag} onChange={e => setBetrag(parseFloat(e.target.value) || 0)} inputProps={{ step: '0.01', min: '0' }} required />
<TextField label="Datum" type="date" value={datum} onChange={e => setDatum(e.target.value)} InputLabelProps={{ shrink: true }} required />
<TextField label="Beschreibung" value={beschreibung} onChange={e => setBeschreibung(e.target.value)} />
<TextField label="Empfänger" value={empfaenger} onChange={e => setEmpfaenger(e.target.value)} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="contained"
disabled={!selectedKonto || !betrag || !datum || selectedIds.size === 0}
onClick={() => onSave({
konto_id: selectedKonto!,
bankkonto_id: selectedBank,
betrag,
datum,
beschreibung: beschreibung || undefined,
empfaenger_auftraggeber: empfaenger || undefined,
quell_transaktion_ids: [...selectedIds],
})}
>
Erstattung erstellen
</Button>
</DialogActions>
</Dialog>
);
}
// ─── Tab 1: Transaktionen ─────────────────────────────────────────────────────
function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
@@ -709,6 +876,21 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
const [filters, setFilters] = useState<TransaktionFilters>({ haushaltsjahr_id: selectedJahrId || undefined });
const [createOpen, setCreateOpen] = useState(false);
const [filterAusgabenTyp, setFilterAusgabenTyp] = useState('');
const [txSubTab, setTxSubTab] = useState(0);
// ── Erstattung state ──
const [erstattungOpen, setErstattungOpen] = useState(false);
// ── Wiederkehrend state ──
const [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false });
const { data: kontenFlat = [] } = useQuery({
queryKey: ['buchhaltung-konten', selectedJahrId],
queryFn: () => buchhaltungApi.getKonten(selectedJahrId!),
enabled: selectedJahrId != null,
});
const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten });
const { data: wiederkehrend = [] } = useQuery({ queryKey: ['buchhaltung-wiederkehrend'], queryFn: buchhaltungApi.getWiederkehrend });
const { data: transaktionen = [], isLoading } = useQuery({
queryKey: ['buchhaltung-transaktionen', filters],
@@ -757,6 +939,32 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
onError: () => showError('Ablehnung fehlgeschlagen'),
});
// ── Wiederkehrend mutations ──
const canManage = hasPermission('buchhaltung:manage_accounts');
const createWiederkehrendMut = useMutation({
mutationFn: buchhaltungApi.createWiederkehrend,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung erstellt'); },
onError: () => showError('Erstellen fehlgeschlagen'),
});
const updateWiederkehrendMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<WiederkehrendFormData> }) => buchhaltungApi.updateWiederkehrend(id, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung aktualisiert'); },
onError: () => showError('Aktualisierung fehlgeschlagen'),
});
const deleteWiederkehrendMut = useMutation({
mutationFn: buchhaltungApi.deleteWiederkehrend,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); showSuccess('Wiederkehrende Buchung gelöscht'); },
onError: () => showError('Löschen fehlgeschlagen'),
});
// ── Erstattung mutation ──
const createErstattungMut = useMutation({
mutationFn: buchhaltungApi.createErstattung,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); setErstattungOpen(false); showSuccess('Erstattung erstellt'); },
onError: () => showError('Erstattung konnte nicht erstellt werden'),
});
const handleExportCsv = async () => {
if (!filters.haushaltsjahr_id) { showError('Bitte ein Haushaltsjahr auswählen'); return; }
try {
@@ -797,6 +1005,15 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
});
return (
<Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={txSubTab} onChange={(_, v) => setTxSubTab(v)}>
<Tab label="Transaktionen" />
<Tab label="Wiederkehrende Buchungen" />
</Tabs>
</Box>
{txSubTab === 0 && (
<Box>
{/* Filters */}
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
@@ -848,6 +1065,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
</IconButton>
</Tooltip>
)}
{hasPermission('buchhaltung:create') && (
<Button size="small" variant="outlined" onClick={() => setErstattungOpen(true)}>
Erstattung erfassen
</Button>
)}
</Box>
</Paper>
@@ -970,6 +1192,74 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
selectedJahrId={selectedJahrId}
onSave={data => createMut.mutate(data)}
/>
<ErstattungDialog
open={erstattungOpen}
onClose={() => setErstattungOpen(false)}
konten={kontenFlat}
bankkonten={bankkonten}
transaktionen={transaktionen}
onSave={data => createErstattungMut.mutate(data)}
/>
</Box>
)}
{txSubTab === 1 && (
<Box>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setWiederkehrendDialog({ open: true })}>Anlegen</Button>}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Typ</TableCell>
<TableCell align="right">Betrag</TableCell>
<TableCell>Intervall</TableCell>
<TableCell>Nächste Ausführung</TableCell>
<TableCell>Aktiv</TableCell>
{canManage && <TableCell>Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{wiederkehrend.length === 0 && <TableRow><TableCell colSpan={7} align="center"><Typography color="text.secondary">Keine wiederkehrenden Buchungen</Typography></TableCell></TableRow>}
{wiederkehrend.map((w: WiederkehrendBuchung) => (
<TableRow key={w.id} hover>
<TableCell>{w.bezeichnung}</TableCell>
<TableCell>
<Chip label={TRANSAKTION_TYP_LABELS[w.typ]} size="small" color={w.typ === 'einnahme' ? 'success' : 'error'} />
</TableCell>
<TableCell align="right" sx={{ color: w.typ === 'einnahme' ? 'success.main' : 'error.main', fontWeight: 600 }}>
{w.typ === 'ausgabe' ? '-' : '+'}{fmtEur(w.betrag)}
</TableCell>
<TableCell>{INTERVALL_LABELS[w.intervall]}</TableCell>
<TableCell>{fmtDate(w.naechste_ausfuehrung)}</TableCell>
<TableCell>{w.aktiv ? <Chip label="Aktiv" size="small" color="success" /> : <Chip label="Inaktiv" size="small" color="default" />}</TableCell>
{canManage && (
<TableCell>
<IconButton size="small" onClick={() => setWiederkehrendDialog({ open: true, existing: w })}><Edit fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteWiederkehrendMut.mutate(w.id)}><Delete fontSize="small" /></IconButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<WiederkehrendDialog
open={wiederkehrendDialog.open}
onClose={() => setWiederkehrendDialog({ open: false })}
konten={kontenFlat}
bankkonten={bankkonten}
existing={wiederkehrendDialog.existing}
onSave={data => wiederkehrendDialog.existing
? updateWiederkehrendMut.mutate({ id: wiederkehrendDialog.existing.id, data })
: createWiederkehrendMut.mutate(data)
}
/>
</Box>
)}
</Box>
);
}
@@ -1113,7 +1403,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
const [kontoSaveError, setKontoSaveError] = useState<string | null>(null);
const [bankDialog, setBankDialog] = useState<{ open: boolean; existing?: Bankkonto }>({ open: false });
const [jahrDialog, setJahrDialog] = useState<{ open: boolean; existing?: Haushaltsjahr }>({ open: false });
const [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false });
const { data: kontenFlat = [] } = useQuery({
queryKey: ['buchhaltung-konten', selectedJahrId],
@@ -1128,7 +1417,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
const kontenTree = buildTree(kontenTreeData);
const konten = kontenFlat;
const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten });
const { data: wiederkehrend = [] } = useQuery({ queryKey: ['buchhaltung-wiederkehrend'], queryFn: buchhaltungApi.getWiederkehrend });
const { data: kategorien = [] } = useQuery({
queryKey: ['buchhaltung-kategorien', selectedJahrId],
queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!),
@@ -1199,22 +1487,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
onError: (e: Error) => showError(e.message || 'Abschluss fehlgeschlagen'),
});
const createWiederkehrendMut = useMutation({
mutationFn: buchhaltungApi.createWiederkehrend,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung erstellt'); },
onError: () => showError('Erstellen fehlgeschlagen'),
});
const updateWiederkehrendMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<WiederkehrendFormData> }) => buchhaltungApi.updateWiederkehrend(id, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung aktualisiert'); },
onError: () => showError('Aktualisierung fehlgeschlagen'),
});
const deleteWiederkehrendMut = useMutation({
mutationFn: buchhaltungApi.deleteWiederkehrend,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); showSuccess('Wiederkehrende Buchung gelöscht'); },
onError: () => showError('Löschen fehlgeschlagen'),
});
return (
<Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
@@ -1222,7 +1494,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
<Tab label="Konten" />
<Tab label="Bankkonten" />
<Tab label="Haushaltsjahre" />
<Tab label="Wiederkehrend" />
</Tabs>
</Box>
@@ -1433,64 +1704,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
/>
</Box>
)}
{/* Sub-Tab 3: Wiederkehrend */}
{subTab === 3 && (
<Box>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setWiederkehrendDialog({ open: true })}>Anlegen</Button>}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Typ</TableCell>
<TableCell align="right">Betrag</TableCell>
<TableCell>Intervall</TableCell>
<TableCell>Nächste Ausführung</TableCell>
<TableCell>Aktiv</TableCell>
{canManage && <TableCell>Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{wiederkehrend.length === 0 && <TableRow><TableCell colSpan={7} align="center"><Typography color="text.secondary">Keine wiederkehrenden Buchungen</Typography></TableCell></TableRow>}
{wiederkehrend.map((w: WiederkehrendBuchung) => (
<TableRow key={w.id} hover>
<TableCell>{w.bezeichnung}</TableCell>
<TableCell>
<Chip label={TRANSAKTION_TYP_LABELS[w.typ]} size="small" color={w.typ === 'einnahme' ? 'success' : 'error'} />
</TableCell>
<TableCell align="right" sx={{ color: w.typ === 'einnahme' ? 'success.main' : 'error.main', fontWeight: 600 }}>
{w.typ === 'ausgabe' ? '-' : '+'}{fmtEur(w.betrag)}
</TableCell>
<TableCell>{INTERVALL_LABELS[w.intervall]}</TableCell>
<TableCell>{fmtDate(w.naechste_ausfuehrung)}</TableCell>
<TableCell>{w.aktiv ? <Chip label="Aktiv" size="small" color="success" /> : <Chip label="Inaktiv" size="small" color="default" />}</TableCell>
{canManage && (
<TableCell>
<IconButton size="small" onClick={() => setWiederkehrendDialog({ open: true, existing: w })}><Edit fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteWiederkehrendMut.mutate(w.id)}><Delete fontSize="small" /></IconButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<WiederkehrendDialog
open={wiederkehrendDialog.open}
onClose={() => setWiederkehrendDialog({ open: false })}
konten={konten}
bankkonten={bankkonten}
existing={wiederkehrendDialog.existing}
onSave={data => wiederkehrendDialog.existing
? updateWiederkehrendMut.mutate({ id: wiederkehrendDialog.existing.id, data })
: createWiederkehrendMut.mutate(data)
}
/>
</Box>
)}
</Box>
);
}

View File

@@ -52,9 +52,13 @@ export default function BuchhaltungKontoDetail() {
if (isError || !data) return <DashboardLayout><Alert severity="error">Konto nicht gefunden.</Alert></DashboardLayout>;
const { konto, children, transaktionen } = data;
const isEinfach = (konto.budget_typ || 'detailliert') === 'einfach';
const totalEinnahmen = transaktionen
.filter(t => t.typ === 'einnahme' && t.status === 'gebucht')
.reduce((sum, t) => sum + Number(t.betrag), 0);
const totalAusgaben = transaktionen
.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht')
.reduce((sum, t) => sum + Number(t.betrag), 0);
return (
<DashboardLayout>
@@ -69,6 +73,22 @@ export default function BuchhaltungKontoDetail() {
</Box>
<Grid container spacing={2} sx={{ mb: 3 }}>
{isEinfach ? (
<>
<Grid item xs={12} sm={6} md={4}>
<BudgetCard label="Budget Gesamt" budget={konto.budget_gesamt || 0} spent={totalAusgaben} />
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">Einnahmen</Typography>
<Typography variant="h6" color="success.main">{fmtEur(totalEinnahmen)}</Typography>
</CardContent>
</Card>
</Grid>
</>
) : (
<>
<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)
@@ -92,6 +112,8 @@ export default function BuchhaltungKontoDetail() {
</CardContent>
</Card>
</Grid>
</>
)}
</Grid>
{children.length > 0 && (

View File

@@ -5,12 +5,13 @@ import {
Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select,
FormControl, InputLabel, Alert, Dialog, DialogTitle,
DialogContent, DialogActions, Skeleton, Divider, LinearProgress, Grid,
ToggleButton, ToggleButtonGroup,
} from '@mui/material';
import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { buchhaltungApi } from '../services/buchhaltung';
import { useNotification } from '../contexts/NotificationContext';
import type { KontoFormData } from '../types/buchhaltung.types';
import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types';
const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
@@ -86,6 +87,8 @@ export default function BuchhaltungKontoManage() {
budget_gwg: k.budget_gwg,
budget_anlagen: k.budget_anlagen,
budget_instandhaltung: k.budget_instandhaltung,
budget_typ: k.budget_typ || 'detailliert',
budget_gesamt: k.budget_gesamt || 0,
parent_id: k.parent_id ?? undefined,
kategorie_id: k.kategorie_id ?? undefined,
notizen: k.notizen ?? '',
@@ -157,6 +160,8 @@ export default function BuchhaltungKontoManage() {
budget_gwg: k.budget_gwg,
budget_anlagen: k.budget_anlagen,
budget_instandhaltung: k.budget_instandhaltung,
budget_typ: k.budget_typ || 'detailliert',
budget_gesamt: k.budget_gesamt || 0,
parent_id: k.parent_id ?? undefined,
kategorie_id: k.kategorie_id ?? undefined,
notizen: k.notizen ?? '',
@@ -231,14 +236,43 @@ export default function BuchhaltungKontoManage() {
<Divider sx={{ mb: 1.5 }} />
{isEditing ? (
<Stack spacing={2}>
{!konto.parent_id && (
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>Budget-Typ</Typography>
<ToggleButtonGroup
value={form.budget_typ || 'detailliert'}
exclusive
size="small"
onChange={(_, val) => { if (val) setForm(f => ({ ...f, budget_typ: val as BudgetTyp })); }}
>
<ToggleButton value="detailliert">Detailliert</ToggleButton>
<ToggleButton value="einfach">Einfach</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{(form.budget_typ || 'detailliert') === 'einfach' ? (
<TextField label="Budget Gesamt (€)" type="number" value={form.budget_gesamt ?? 0} onChange={e => setForm(f => ({ ...f, budget_gesamt: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small" />
) : (
<>
<TextField label="Budget GWG (€)" type="number" value={form.budget_gwg ?? 0} onChange={e => setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small"
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_gwg)}, vergeben: ${fmtEur(siblingBudgets.gwg)}, verfügbar: ${fmtEur(parentKonto.budget_gwg - siblingBudgets.gwg)}` : undefined} />
<TextField label="Budget Anlagen (€)" type="number" value={form.budget_anlagen ?? 0} onChange={e => setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small"
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_anlagen)}, vergeben: ${fmtEur(siblingBudgets.anlagen)}, verfügbar: ${fmtEur(parentKonto.budget_anlagen - siblingBudgets.anlagen)}` : undefined} />
<TextField label="Budget Instandhaltung (€)" type="number" value={form.budget_instandhaltung ?? 0} onChange={e => setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small"
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_instandhaltung)}, vergeben: ${fmtEur(siblingBudgets.instandhaltung)}, verfügbar: ${fmtEur(parentKonto.budget_instandhaltung - siblingBudgets.instandhaltung)}` : undefined} />
</>
)}
{saveError && <Alert severity="error" onClose={() => setSaveError(null)}>{saveError}</Alert>}
</Stack>
) : (
<>
{(konto.budget_typ || 'detailliert') === 'einfach' ? (
<>
<BudgetBar label="Gesamt" budget={konto.budget_gesamt || 0} spent={spentGwg + spentAnlagen + spentInst} />
<Divider sx={{ my: 1 }} />
<FieldRow label="Budget Gesamt" value={fmtEur(konto.budget_gesamt || 0)} />
<FieldRow label="Ausgaben Gesamt" value={fmtEur(spentGwg + spentAnlagen + spentInst)} />
</>
) : (
<>
<BudgetBar label="GWG" budget={konto.budget_gwg} spent={spentGwg} />
@@ -249,6 +283,8 @@ export default function BuchhaltungKontoManage() {
<FieldRow label="Ausgaben Gesamt" value={fmtEur(spentGwg + spentAnlagen + spentInst)} />
</>
)}
</>
)}
</Paper>
</Stack>

View File

@@ -10,6 +10,7 @@ import type {
WiederkehrendBuchung, WiederkehrendFormData,
Freigabe,
Kategorie,
ErstattungFormData, ErstattungLinks,
} from '../types/buchhaltung.types';
export const buchhaltungApi = {
@@ -196,4 +197,14 @@ export const buchhaltungApi = {
const r = await api.patch(`/api/buchhaltung/freigaben/${id}/ablehnen`, { kommentar });
return r.data.data;
},
// ── Erstattungen ─────────────────────────────────────────────────────────────
createErstattung: async (data: ErstattungFormData): Promise<Transaktion> => {
const r = await api.post('/api/buchhaltung/erstattungen', data);
return r.data.data;
},
getErstattungLinks: async (transaktionId: number): Promise<ErstattungLinks> => {
const r = await api.get(`/api/buchhaltung/transaktionen/${transaktionId}/erstattung-links`);
return r.data.data;
},
};

View File

@@ -6,6 +6,7 @@ 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 type BudgetTyp = 'detailliert' | 'einfach';
export const AUSGABEN_TYP_LABELS: Record<AusgabenTyp, string> = {
gwg: 'GWG',
@@ -98,6 +99,8 @@ export interface Konto {
budget_gwg: number;
budget_anlagen: number;
budget_instandhaltung: number;
budget_typ: BudgetTyp;
budget_gesamt: number;
notizen: string | null;
aktiv: boolean;
erstellt_von: string | null;
@@ -235,6 +238,8 @@ export interface KontoFormData {
budget_gwg: number;
budget_anlagen: number;
budget_instandhaltung: number;
budget_typ?: BudgetTyp;
budget_gesamt?: number;
parent_id?: number | null;
kategorie_id?: number | null;
notizen?: string;
@@ -286,3 +291,18 @@ export interface KontoDetailResponse {
children: KontoTreeNode[];
transaktionen: Transaktion[];
}
export interface ErstattungFormData {
konto_id: number;
bankkonto_id: number | null;
betrag: number;
datum: string;
beschreibung?: string;
empfaenger_auftraggeber?: string;
quell_transaktion_ids: number[];
}
export interface ErstattungLinks {
erstattung_transaktion_id: number | null;
quell_transaktion_ids: number[];
}