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:
@@ -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>
|
||||
);
|
||||
|
||||
173
frontend/src/pages/BuchhaltungKontoDetail.tsx
Normal file
173
frontend/src/pages/BuchhaltungKontoDetail.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Box, Typography, Button, Grid, Card, CardContent,
|
||||
Table, TableHead, TableBody, TableRow, TableCell,
|
||||
LinearProgress, Chip, Alert, Skeleton, TableContainer, Paper,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack } from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types';
|
||||
import type { AusgabenTyp } from '../types/buchhaltung.types';
|
||||
|
||||
function BudgetCard({ label, budget, spent }: { label: string; budget: number; spent: number }) {
|
||||
const utilization = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
|
||||
const over = spent > budget && budget > 0;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="h6">{spent.toFixed(2).replace('.', ',')} €</Typography>
|
||||
{budget > 0 && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">Budget: {budget.toFixed(2).replace('.', ',')} €</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={utilization}
|
||||
color={over ? 'error' : utilization > 80 ? 'warning' : 'primary'}
|
||||
sx={{ mt: 1, height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuchhaltungKontoDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const kontoId = Number(id);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['kontoDetail', kontoId],
|
||||
queryFn: () => buchhaltungApi.getKontoDetail(kontoId),
|
||||
enabled: !!kontoId,
|
||||
});
|
||||
|
||||
if (isLoading) return <DashboardLayout><Skeleton variant="rectangular" height={400} /></DashboardLayout>;
|
||||
if (isError || !data) return <DashboardLayout><Alert severity="error">Konto nicht gefunden.</Alert></DashboardLayout>;
|
||||
|
||||
const { konto, children, transaktionen } = data;
|
||||
const totalEinnahmen = transaktionen
|
||||
.filter(t => t.typ === 'einnahme' && t.status === 'gebucht')
|
||||
.reduce((sum, t) => sum + Number(t.betrag), 0);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung')}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Typography variant="h5" sx={{ ml: 1 }}>
|
||||
{konto.kontonummer} — {konto.bezeichnung}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<BudgetCard label="GWG" budget={konto.budget_gwg} spent={
|
||||
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'gwg').reduce((s, t) => s + Number(t.betrag), 0)
|
||||
} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<BudgetCard label="Anlagen" budget={konto.budget_anlagen} spent={
|
||||
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'anlagen').reduce((s, t) => s + Number(t.betrag), 0)
|
||||
} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<BudgetCard label="Instandhaltung" budget={konto.budget_instandhaltung} spent={
|
||||
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'instandhaltung').reduce((s, t) => s + Number(t.betrag), 0)
|
||||
} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary">Einnahmen</Typography>
|
||||
<Typography variant="h6" color="success.main">{totalEinnahmen.toFixed(2).replace('.', ',')} €</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{children.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Unterkonten</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Konto</TableCell>
|
||||
<TableCell align="right">Budget GWG</TableCell>
|
||||
<TableCell align="right">Budget Anlagen</TableCell>
|
||||
<TableCell align="right">Budget Instandh.</TableCell>
|
||||
<TableCell align="right">Budget Gesamt</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{children.map(child => (
|
||||
<TableRow
|
||||
key={child.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/buchhaltung/konto/${child.id}`)}
|
||||
>
|
||||
<TableCell>{child.kontonummer} — {child.bezeichnung}</TableCell>
|
||||
<TableCell align="right">{Number(child.budget_gwg).toFixed(2)} €</TableCell>
|
||||
<TableCell align="right">{Number(child.budget_anlagen).toFixed(2)} €</TableCell>
|
||||
<TableCell align="right">{Number(child.budget_instandhaltung).toFixed(2)} €</TableCell>
|
||||
<TableCell align="right">
|
||||
{(Number(child.budget_gwg) + Number(child.budget_anlagen) + Number(child.budget_instandhaltung)).toFixed(2)} €
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Transaktionen</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Datum</TableCell>
|
||||
<TableCell>Beschreibung</TableCell>
|
||||
<TableCell>Typ</TableCell>
|
||||
<TableCell>Ausgaben-Typ</TableCell>
|
||||
<TableCell align="right">Betrag</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{transaktionen.length === 0 && (
|
||||
<TableRow><TableCell colSpan={6} align="center">Keine Transaktionen</TableCell></TableRow>
|
||||
)}
|
||||
{transaktionen.map(t => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>{new Date(t.datum).toLocaleDateString('de-DE')}</TableCell>
|
||||
<TableCell>{t.beschreibung}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={t.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe'}
|
||||
color={t.typ === 'einnahme' ? 'success' : 'error'} />
|
||||
</TableCell>
|
||||
<TableCell>{t.ausgaben_typ ? AUSGABEN_TYP_LABELS[t.ausgaben_typ as AusgabenTyp] : '—'}</TableCell>
|
||||
<TableCell align="right"
|
||||
sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main' }}>
|
||||
{t.typ === 'einnahme' ? '+' : '-'}{Number(t.betrag).toFixed(2).replace('.', ',')} €
|
||||
</TableCell>
|
||||
<TableCell>{t.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user