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

@@ -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>
))}
</Box>
<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}`)} />
))}
</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,45 +602,82 @@ 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' }}>
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>Haushaltsjahr</InputLabel>
<Select size="small" value={filters.haushaltsjahr_id ?? ''} label="Haushaltsjahr"
onChange={e => { const v = Number(e.target.value); setFilters(f => ({ ...f, haushaltsjahr_id: v || undefined })); onJahrChange(v); }}>
<MenuItem value=""><em>Alle</em></MenuItem>
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 140 }}>
<InputLabel>Status</InputLabel>
<Select size="small" value={filters.status ?? ''} label="Status"
onChange={e => setFilters(f => ({ ...f, status: (e.target.value as TransaktionStatus) || undefined }))}>
<MenuItem value=""><em>Alle</em></MenuItem>
{(Object.entries(TRANSAKTION_STATUS_LABELS) as [TransaktionStatus, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 130 }}>
<InputLabel>Typ</InputLabel>
<Select size="small" value={filters.typ ?? ''} label="Typ"
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
<MenuItem value=""><em>Alle</em></MenuItem>
<MenuItem value="einnahme">Einnahme</MenuItem>
<MenuItem value="ausgabe">Ausgabe</MenuItem>
</Select>
</FormControl>
<TextField size="small" label="Suche" value={filters.search ?? ''}
onChange={e => setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} />
{hasPermission('buchhaltung:export') && (
<Tooltip title="CSV exportieren">
<IconButton size="small" onClick={handleExportCsv} disabled={!filters.haushaltsjahr_id}>
<Download fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
<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"
onChange={e => { const v = Number(e.target.value); setFilters(f => ({ ...f, haushaltsjahr_id: v || undefined })); onJahrChange(v); }}>
<MenuItem value=""><em>Alle</em></MenuItem>
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 140 }}>
<InputLabel>Status</InputLabel>
<Select size="small" value={filters.status ?? ''} label="Status"
onChange={e => setFilters(f => ({ ...f, status: (e.target.value as TransaktionStatus) || undefined }))}>
<MenuItem value=""><em>Alle</em></MenuItem>
{(Object.entries(TRANSAKTION_STATUS_LABELS) as [TransaktionStatus, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 130 }}>
<InputLabel>Typ</InputLabel>
<Select size="small" value={filters.typ ?? ''} label="Typ"
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
<MenuItem value=""><em>Alle</em></MenuItem>
<MenuItem value="einnahme">Einnahme</MenuItem>
<MenuItem value="ausgabe">Ausgabe</MenuItem>
</Select>
</FormControl>
<TextField size="small" label="Suche" value={filters.search ?? ''}
onChange={e => setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} />
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Ausgaben-Typ</InputLabel>
<Select
value={filterAusgabenTyp}
onChange={e => setFilterAusgabenTyp(e.target.value)}
label="Ausgaben-Typ"
>
<MenuItem value="">Alle</MenuItem>
<MenuItem value="gwg">GWG</MenuItem>
<MenuItem value="anlagen">Anlagen</MenuItem>
<MenuItem value="instandhaltung">Instandhaltung</MenuItem>
</Select>
</FormControl>
{hasPermission('buchhaltung:export') && (
<Tooltip title="CSV exportieren">
<IconButton size="small" onClick={handleExportCsv} disabled={!filters.haushaltsjahr_id}>
<Download fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</AccordionDetails>
</Accordion>
{isLoading ? <CircularProgress /> : (
<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>
);
}