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

@@ -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}
@@ -830,6 +838,7 @@ export default function BestellungDetail() {
</Menu>
</>
)}
</Box>
</Box>
)}

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 {
@@ -798,6 +1006,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 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
@@ -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,29 +73,47 @@ export default function BuchhaltungKontoDetail() {
</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">{fmtEur(totalEinnahmen)}</Typography>
</CardContent>
</Card>
</Grid>
{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)
} />
</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">{fmtEur(totalEinnahmen)}</Typography>
</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,22 +236,53 @@ export default function BuchhaltungKontoManage() {
<Divider sx={{ mb: 1.5 }} />
{isEditing ? (
<Stack spacing={2}>
<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} />
{!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>
) : (
<>
<BudgetBar label="GWG" budget={konto.budget_gwg} spent={spentGwg} />
<BudgetBar label="Anlagen" budget={konto.budget_anlagen} spent={spentAnlagen} />
<BudgetBar label="Instandhaltung" budget={konto.budget_instandhaltung} spent={spentInst} />
<Divider sx={{ my: 1 }} />
<FieldRow label="Budget Gesamt" value={fmtEur(konto.budget_gwg + konto.budget_anlagen + konto.budget_instandhaltung)} />
<FieldRow label="Ausgaben Gesamt" value={fmtEur(spentGwg + spentAnlagen + spentInst)} />
{(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} />
<BudgetBar label="Anlagen" budget={konto.budget_anlagen} spent={spentAnlagen} />
<BudgetBar label="Instandhaltung" budget={konto.budget_instandhaltung} spent={spentInst} />
<Divider sx={{ my: 1 }} />
<FieldRow label="Budget Gesamt" value={fmtEur(konto.budget_gwg + konto.budget_anlagen + konto.budget_instandhaltung)} />
<FieldRow label="Ausgaben Gesamt" value={fmtEur(spentGwg + spentAnlagen + spentInst)} />
</>
)}
</>
)}
</Paper>