feat(buchhaltung): move collapse arrows to row end, always-visible filters, summary row, sortable transactions, account manage page
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
201
frontend/src/pages/BuchhaltungKontoManage.tsx
Normal file
201
frontend/src/pages/BuchhaltungKontoManage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user