feat(buchhaltung): move collapse arrows to row end, always-visible filters, summary row, sortable transactions, account manage page

This commit is contained in:
Matthias Hochmeister
2026-03-30 11:59:05 +02:00
parent 4e42d4077a
commit 86cb175aeb
4 changed files with 380 additions and 160 deletions

View File

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

View File

@@ -1,9 +1,6 @@
import React, { useState, useEffect } from 'react';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Badge,
Box,
Button,
@@ -33,6 +30,7 @@ import {
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Tabs,
TextField,
Tooltip,
@@ -47,7 +45,6 @@ import {
Edit,
ExpandLess as ExpandLessIcon,
ExpandMore as ExpandMoreIcon,
FilterList as FilterListIcon,
HowToReg,
Lock,
ThumbDown,
@@ -89,7 +86,7 @@ function fmtDate(val: string) {
}
function TabPanel({ children, value, index }: { children: React.ReactNode; value: number; index: number }) {
return value === index ? <Box role="tabpanel">{children}</Box> : null;
return value === index ? <Box role="tabpanel" sx={{ pt: 3 }}>{children}</Box> : null;
}
// ─── Sub-components ────────────────────────────────────────────────────────────
@@ -415,22 +412,11 @@ function KontoRow({ konto, depth = 0, onNavigate }: { konto: KontoTreeNode; dept
return (
<>
<TableRow>
<TableRow onClick={() => onNavigate(konto.id)} sx={{ cursor: 'pointer' }}>
<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>
<Typography variant="body2">
{konto.kontonummer} {konto.bezeichnung}
</Typography>
{totalBudget > 0 && (
<LinearProgress variant="determinate" value={Math.min(utilization, 100)}
color={utilization > 100 ? 'error' : utilization > 80 ? 'warning' : 'primary'}
@@ -446,6 +432,13 @@ function KontoRow({ konto, depth = 0, onNavigate }: { konto: KontoTreeNode; dept
<TableCell align="right">{fmtEur(konto.spent_instandhaltung)}</TableCell>
<TableCell align="right"><strong>{fmtEur(totalSpent)}</strong></TableCell>
<TableCell align="right">{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); }}>
{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
)}
</TableCell>
</TableRow>
{open && konto.children.map(child => (
<KontoRow key={child.id} konto={child} depth={depth + 1} onNavigate={onNavigate} />
@@ -454,44 +447,34 @@ function KontoRow({ konto, depth = 0, onNavigate }: { konto: KontoTreeNode; dept
);
}
function KontoManageRow({ konto, depth = 0, canManage, onEdit, onDelete }: {
function KontoManageRow({ konto, depth = 0, onNavigate }: {
konto: KontoTreeNode;
depth?: number;
canManage: boolean;
onEdit: (k: Konto) => void;
onDelete: (id: number) => void;
onNavigate: (id: number) => void;
}) {
const [open, setOpen] = useState(false);
const totalBudget = Number(konto.budget_gwg) + Number(konto.budget_anlagen) + Number(konto.budget_instandhaltung);
return (
<>
<TableRow hover>
<TableRow hover onClick={() => onNavigate(konto.id)} sx={{ cursor: 'pointer' }}>
<TableCell sx={{ pl: 2 + depth * 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{konto.children.length > 0 ? (
<IconButton size="small" onClick={() => setOpen(!open)}>
{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
) : (
<Box sx={{ width: 28 }} />
)}
<Typography variant="body2">{konto.kontonummer} {konto.bezeichnung}</Typography>
</Box>
<Typography variant="body2">{konto.kontonummer} {konto.bezeichnung}</Typography>
</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>
{canManage && (
<TableCell>
<IconButton size="small" onClick={() => onEdit(konto as unknown as Konto)}><Edit fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => onDelete(konto.id)}><Delete fontSize="small" /></IconButton>
</TableCell>
)}
<TableCell sx={{ width: 40, px: 0.5 }}>
{konto.children.length > 0 && (
<IconButton size="small" onClick={e => { e.stopPropagation(); setOpen(!open); }}>
{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
)}
</TableCell>
</TableRow>
{open && konto.children.map(child => (
<KontoManageRow key={child.id} konto={child} depth={depth + 1} canManage={canManage} onEdit={onEdit} onDelete={onDelete} />
<KontoManageRow key={child.id} konto={child} depth={depth + 1} onNavigate={onNavigate} />
))}
</>
);
@@ -513,8 +496,18 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
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 sumBudgetGwg = treeData.reduce((s, k) => s + Number(k.budget_gwg), 0);
const sumBudgetAnlagen = treeData.reduce((s, k) => s + Number(k.budget_anlagen), 0);
const sumBudgetInst = treeData.reduce((s, k) => s + Number(k.budget_instandhaltung), 0);
const sumBudgetGesamt = sumBudgetGwg + sumBudgetAnlagen + sumBudgetInst;
const sumSpentGwg = treeData.reduce((s, k) => s + Number(k.spent_gwg), 0);
const sumSpentAnlagen = treeData.reduce((s, k) => s + Number(k.spent_anlagen), 0);
const sumSpentInst = treeData.reduce((s, k) => s + Number(k.spent_instandhaltung), 0);
const sumSpentGesamt = sumSpentGwg + sumSpentAnlagen + sumSpentInst;
const sumEinnahmen = treeData.reduce((s, k) => s + Number(k.einnahmen_betrag), 0);
const totalEinnahmen = sumEinnahmen;
const totalAusgaben = sumSpentGesamt;
const saldo = totalEinnahmen - totalAusgaben;
return (
@@ -567,15 +560,31 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
<TableCell align="right">Ausgaben Instandh.</TableCell>
<TableCell align="right">Ausgaben Gesamt</TableCell>
<TableCell align="right">Einnahmen</TableCell>
<TableCell sx={{ width: 40 }} />
</TableRow>
</TableHead>
<TableBody>
{tree.length === 0 && (
<TableRow><TableCell colSpan={10} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>
<TableRow><TableCell colSpan={11} 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}`)} />
))}
{tree.length > 0 && (
<TableRow sx={{ bgcolor: 'action.hover', '& td': { fontWeight: 700 } }}>
<TableCell>Gesamt</TableCell>
<TableCell align="right">{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">{fmtEur(sumSpentAnlagen)}</TableCell>
<TableCell align="right">{fmtEur(sumSpentInst)}</TableCell>
<TableCell align="right">{fmtEur(sumSpentGesamt)}</TableCell>
<TableCell align="right">{fmtEur(sumEinnahmen)}</TableCell>
<TableCell />
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
@@ -668,103 +677,113 @@ 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;
const [sortCol, setSortCol] = useState<'nr' | 'datum' | 'betrag'>('nr');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const handleSort = (col: 'nr' | 'datum' | 'betrag') => {
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortCol(col); setSortDir('asc'); }
};
const sortedTransaktionen = [...filteredTransaktionen].sort((a, b) => {
let cmp = 0;
if (sortCol === 'nr') cmp = (a.laufende_nummer ?? 0) - (b.laufende_nummer ?? 0);
else if (sortCol === 'datum') cmp = new Date(a.datum).getTime() - new Date(b.datum).getTime();
else if (sortCol === 'betrag') cmp = Number(a.betrag) - Number(b.betrag);
return sortDir === 'asc' ? cmp : -cmp;
});
return (
<Box>
{/* Filters */}
<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>
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
<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>
</Paper>
{isLoading ? <CircularProgress /> : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Nr.</TableCell>
<TableCell>Datum</TableCell>
<TableCell sortDirection={sortCol === 'nr' ? sortDir : false}>
<TableSortLabel active={sortCol === 'nr'} direction={sortCol === 'nr' ? sortDir : 'asc'} onClick={() => handleSort('nr')}>
Nr.
</TableSortLabel>
</TableCell>
<TableCell sortDirection={sortCol === 'datum' ? sortDir : false}>
<TableSortLabel active={sortCol === 'datum'} direction={sortCol === 'datum' ? sortDir : 'asc'} onClick={() => handleSort('datum')}>
Datum
</TableSortLabel>
</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Konto</TableCell>
<TableCell align="right">Betrag</TableCell>
<TableCell align="right" sortDirection={sortCol === 'betrag' ? sortDir : false}>
<TableSortLabel active={sortCol === 'betrag'} direction={sortCol === 'betrag' ? sortDir : 'asc'} onClick={() => handleSort('betrag')}>
Betrag
</TableSortLabel>
</TableCell>
<TableCell>Status</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredTransaktionen.length === 0 && (
{sortedTransaktionen.length === 0 && (
<TableRow><TableCell colSpan={8} align="center"><Typography color="text.secondary">Keine Transaktionen</Typography></TableCell></TableRow>
)}
{filteredTransaktionen.map((t: Transaktion) => (
{sortedTransaktionen.map((t: Transaktion) => (
<TableRow key={t.id} hover>
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
<TableCell>{fmtDate(t.datum)}</TableCell>
@@ -965,6 +984,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
onJahrChange: (id: number) => void;
}) {
const qc = useQueryClient();
const navigate = useNavigate();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const [subTab, setSubTab] = useState(0);
@@ -1000,12 +1020,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); setKontoDialog({ open: false }); showSuccess('Konto aktualisiert'); },
onError: () => showError('Konto konnte nicht aktualisiert werden'),
});
const deleteKontoMut = useMutation({
mutationFn: buchhaltungApi.deleteKonto,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); showSuccess('Konto gelöscht'); },
onError: () => showError('Löschen fehlgeschlagen'),
});
const createBankMut = useMutation({
mutationFn: buchhaltungApi.createBankkonto,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['bankkonten'] }); setBankDialog({ open: false }); showSuccess('Bankkonto erstellt'); },
@@ -1084,18 +1098,16 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
<TableCell align="right">Anlagen</TableCell>
<TableCell align="right">Instandh.</TableCell>
<TableCell align="right">Gesamt</TableCell>
{canManage && <TableCell>Aktionen</TableCell>}
<TableCell sx={{ width: 40 }} />
</TableRow>
</TableHead>
<TableBody>
{kontenTree.length === 0 && <TableRow><TableCell colSpan={canManage ? 6 : 5} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>}
{kontenTree.length === 0 && <TableRow><TableCell colSpan={6} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>}
{kontenTree.map(k => (
<KontoManageRow
key={k.id}
konto={k}
canManage={canManage}
onEdit={existing => setKontoDialog({ open: true, existing: existing as unknown as Konto })}
onDelete={id => deleteKontoMut.mutate(id)}
onNavigate={(id) => navigate('/buchhaltung/konto/' + id + '/verwalten')}
/>
))}
</TableBody>
@@ -1309,39 +1321,37 @@ export default function Buchhaltung() {
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2 }}>
<Typography variant="h4" fontWeight={700}>Buchhaltung</Typography>
</Box>
<Tabs value={tabValue} onChange={handleTabChange} variant="scrollable" scrollButtons="auto" sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>Buchhaltung</Typography>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tabValue} onChange={handleTabChange} variant="scrollable" scrollButtons="auto">
<Tab label="Übersicht" />
<Tab label={<Badge badgeContent={pendingCount || 0} color="warning" invisible={!pendingCount}><span>Transaktionen</span></Badge>} />
<Tab label="Konten" />
</Tabs>
<TabPanel value={tabValue} index={0}>
<UebersichtTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<TransaktionenTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
</TabPanel>
<TabPanel value={tabValue} index={2}>
<KontenTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
</TabPanel>
</Box>
<TabPanel value={tabValue} index={0}>
<UebersichtTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<TransaktionenTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
</TabPanel>
<TabPanel value={tabValue} index={2}>
<KontenTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
</TabPanel>
</DashboardLayout>
);
}

View File

@@ -18,10 +18,10 @@ function BudgetCard({ label, budget, spent }: { label: string; budget: number; s
<Card>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">{label}</Typography>
<Typography variant="h6">{spent.toFixed(2).replace('.', ',')} </Typography>
<Typography variant="h6">{Number(spent).toFixed(2).replace('.', ',')} </Typography>
{budget > 0 && (
<>
<Typography variant="body2" color="text.secondary">Budget: {budget.toFixed(2).replace('.', ',')} </Typography>
<Typography variant="body2" color="text.secondary">Budget: {Number(budget).toFixed(2).replace('.', ',')} </Typography>
<LinearProgress
variant="determinate"
value={utilization}

View File

@@ -0,0 +1,201 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select,
FormControl, InputLabel, CircularProgress, Alert, Dialog, DialogTitle,
DialogContent, DialogActions, Skeleton,
} from '@mui/material';
import { ArrowBack, Delete, Save } 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';
export default function BuchhaltungKontoManage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const { showSuccess, showError } = useNotification();
const kontoId = Number(id);
const [form, setForm] = useState<Partial<KontoFormData>>({});
const [deleteOpen, setDeleteOpen] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: ['kontoDetail', kontoId],
queryFn: () => buchhaltungApi.getKontoDetail(kontoId),
enabled: !!kontoId,
});
// Load sibling konten for parent selector (exclude self)
const { data: alleKonten = [] } = useQuery({
queryKey: ['buchhaltung-konten', data?.konto.haushaltsjahr_id],
queryFn: () => buchhaltungApi.getKonten(data!.konto.haushaltsjahr_id),
enabled: !!data?.konto.haushaltsjahr_id,
});
useEffect(() => {
if (data?.konto) {
const k = data.konto;
setForm({
haushaltsjahr_id: k.haushaltsjahr_id,
kontonummer: k.kontonummer,
bezeichnung: k.bezeichnung,
budget_gwg: k.budget_gwg,
budget_anlagen: k.budget_anlagen,
budget_instandhaltung: k.budget_instandhaltung,
parent_id: k.parent_id ?? undefined,
notizen: k.notizen ?? '',
});
}
}, [data]);
const updateMut = useMutation({
mutationFn: (d: Partial<KontoFormData>) => buchhaltungApi.updateKonto(kontoId, d),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['kontoDetail', kontoId] });
qc.invalidateQueries({ queryKey: ['kontenTree'] });
qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] });
showSuccess('Konto gespeichert');
},
onError: () => showError('Speichern fehlgeschlagen'),
});
const deleteMut = useMutation({
mutationFn: () => buchhaltungApi.deleteKonto(kontoId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['kontenTree'] });
qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] });
showSuccess('Konto gelöscht');
navigate('/buchhaltung?tab=2');
},
onError: () => showError('Löschen fehlgeschlagen'),
});
if (isLoading) return <DashboardLayout><Box sx={{ p: 3 }}><Skeleton variant="rectangular" height={400} /></Box></DashboardLayout>;
if (isError || !data) return <DashboardLayout><Box sx={{ p: 3 }}><Alert severity="error">Konto nicht gefunden.</Alert></Box></DashboardLayout>;
const konto = data.konto;
const otherKonten = alleKonten.filter(k => k.id !== kontoId);
const handleSave = () => {
if (!form.bezeichnung) return;
updateMut.mutate(form);
};
return (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/buchhaltung?tab=2')}>
Zurück
</Button>
<Typography variant="h5" sx={{ flexGrow: 1, ml: 1 }}>
{konto.kontonummer} {konto.bezeichnung}
</Typography>
<Button
variant="outlined"
color="error"
startIcon={<Delete />}
onClick={() => setDeleteOpen(true)}
>
Löschen
</Button>
<Button
variant="contained"
startIcon={<Save />}
onClick={handleSave}
disabled={updateMut.isPending || !form.bezeichnung}
>
Speichern
</Button>
</Box>
<Paper sx={{ p: 3, maxWidth: 600 }}>
<Stack spacing={2.5}>
<TextField
label="Kontonummer"
type="number"
value={form.kontonummer ?? ''}
onChange={e => setForm(f => ({ ...f, kontonummer: Number(e.target.value) }))}
required
fullWidth
/>
<TextField
label="Bezeichnung"
value={form.bezeichnung ?? ''}
onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))}
required
fullWidth
/>
<FormControl fullWidth>
<InputLabel>Übergeordnetes Konto</InputLabel>
<Select
value={form.parent_id ?? ''}
label="Übergeordnetes Konto"
onChange={e => setForm(f => ({ ...f, parent_id: e.target.value ? Number(e.target.value) : undefined }))}
>
<MenuItem value=""><em>Kein übergeordnetes Konto</em></MenuItem>
{otherKonten.map(k => (
<MenuItem key={k.id} value={k.id}>{k.kontonummer} {k.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
<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
/>
<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
/>
<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
/>
<TextField
label="Notizen"
value={form.notizen ?? ''}
onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))}
multiline
rows={3}
fullWidth
/>
</Stack>
</Paper>
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
<DialogTitle>Konto löschen</DialogTitle>
<DialogContent>
<Typography>
Möchten Sie das Konto <strong>{konto.kontonummer} {konto.bezeichnung}</strong> wirklich löschen?
Diese Aktion kann nicht rückgängig gemacht werden.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
color="error"
onClick={() => { deleteMut.mutate(); setDeleteOpen(false); }}
disabled={deleteMut.isPending}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}