feat(buchhaltung): add transfers, bank statements, Haushaltsplan, and PDF export

This commit is contained in:
Matthias Hochmeister
2026-03-30 17:05:18 +02:00
parent 2eb59e9ff1
commit 5acfd7cc4f
14 changed files with 1911 additions and 10 deletions

View File

@@ -0,0 +1,271 @@
import { useState } from 'react';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import {
Alert,
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon, ArrowBack, Delete, Edit, PlaylistAdd } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { buchhaltungApi } from '../services/buchhaltung';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import type { Konto, Planposition, PlanungStatus } from '../types/buchhaltung.types';
const STATUS_LABELS: Record<PlanungStatus, string> = {
entwurf: 'Entwurf',
aktiv: 'Aktiv',
abgeschlossen: 'Abgeschlossen',
};
const STATUS_COLORS: Record<PlanungStatus, 'default' | 'success' | 'info'> = {
entwurf: 'default',
aktiv: 'success',
abgeschlossen: 'info',
};
function fmtEur(val: number) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
}
export default function HaushaltsplanDetail() {
const { id } = useParams<{ id: string }>();
const planungId = Number(id);
const navigate = useNavigate();
const qc = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const canManage = hasPermission('buchhaltung:manage_accounts');
const [posDialog, setPosDialog] = useState<{ open: boolean; existing?: Planposition }>({ open: false });
const [posForm, setPosForm] = useState<{ konto_id: string; bezeichnung: string; budget_gwg: string; budget_anlagen: string; budget_instandhaltung: string; notizen: string }>({
konto_id: '', bezeichnung: '', budget_gwg: '0', budget_anlagen: '0', budget_instandhaltung: '0', notizen: '',
});
const { data: planung, isLoading, isError } = useQuery({
queryKey: ['planung', planungId],
queryFn: () => buchhaltungApi.getPlanung(planungId),
enabled: !isNaN(planungId),
});
// Load konten for the linked haushaltsjahr (for Konto dropdown in position dialog)
const { data: konten = [] } = useQuery({
queryKey: ['buchhaltung-konten', planung?.haushaltsjahr_id],
queryFn: () => buchhaltungApi.getKonten(planung!.haushaltsjahr_id!),
enabled: !!planung?.haushaltsjahr_id,
});
const createPosMut = useMutation({
mutationFn: (data: Parameters<typeof buchhaltungApi.createPlanposition>[1]) => buchhaltungApi.createPlanposition(planungId, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); setPosDialog({ open: false }); showSuccess('Position hinzugefügt'); },
onError: () => showError('Position konnte nicht erstellt werden'),
});
const updatePosMut = useMutation({
mutationFn: ({ posId, data }: { posId: number; data: Parameters<typeof buchhaltungApi.updatePlanposition>[1] }) => buchhaltungApi.updatePlanposition(posId, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); setPosDialog({ open: false }); showSuccess('Position aktualisiert'); },
onError: () => showError('Position konnte nicht aktualisiert werden'),
});
const deletePosMut = useMutation({
mutationFn: buchhaltungApi.deletePlanposition,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); showSuccess('Position gelöscht'); },
onError: () => showError('Position konnte nicht gelöscht werden'),
});
const createHjMut = useMutation({
mutationFn: () => buchhaltungApi.createHaushaltsjahrFromPlan(planungId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['planung', planungId] });
qc.invalidateQueries({ queryKey: ['haushaltsjahre'] });
qc.invalidateQueries({ queryKey: ['planungen'] });
showSuccess('Haushaltsjahr aus Plan erstellt');
},
onError: (err: any) => showError(err?.response?.data?.message || 'Haushaltsjahr konnte nicht erstellt werden'),
});
const openAddDialog = () => {
setPosForm({ konto_id: '', bezeichnung: '', budget_gwg: '0', budget_anlagen: '0', budget_instandhaltung: '0', notizen: '' });
setPosDialog({ open: true });
};
const openEditDialog = (pos: Planposition) => {
setPosForm({
konto_id: pos.konto_id ? String(pos.konto_id) : '',
bezeichnung: pos.bezeichnung,
budget_gwg: String(pos.budget_gwg),
budget_anlagen: String(pos.budget_anlagen),
budget_instandhaltung: String(pos.budget_instandhaltung),
notizen: pos.notizen || '',
});
setPosDialog({ open: true, existing: pos });
};
const handlePosSave = () => {
const data = {
konto_id: posForm.konto_id ? Number(posForm.konto_id) : null,
bezeichnung: posForm.bezeichnung.trim(),
budget_gwg: parseFloat(posForm.budget_gwg) || 0,
budget_anlagen: parseFloat(posForm.budget_anlagen) || 0,
budget_instandhaltung: parseFloat(posForm.budget_instandhaltung) || 0,
notizen: posForm.notizen.trim() || undefined,
};
if (posDialog.existing) {
updatePosMut.mutate({ posId: posDialog.existing.id, data });
} else {
createPosMut.mutate(data);
}
};
if (isLoading) return <DashboardLayout><Typography>Laden...</Typography></DashboardLayout>;
if (isError || !planung) return <DashboardLayout><Alert severity="error">Haushaltsplan nicht gefunden</Alert></DashboardLayout>;
const positionen = planung.positionen || [];
const totalGwg = positionen.reduce((s, p) => s + Number(p.budget_gwg), 0);
const totalAnlagen = positionen.reduce((s, p) => s + Number(p.budget_anlagen), 0);
const totalInstandhaltung = positionen.reduce((s, p) => s + Number(p.budget_instandhaltung), 0);
return (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/haushaltsplan')}><ArrowBack /></IconButton>
<Typography variant="h4" sx={{ flexGrow: 1 }}>{planung.bezeichnung}</Typography>
<Chip label={STATUS_LABELS[planung.status] || planung.status} color={STATUS_COLORS[planung.status] || 'default'} />
</Box>
{planung.haushaltsjahr_bezeichnung && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Haushaltsjahr: {planung.haushaltsjahr_bezeichnung}
</Typography>
)}
{!planung.haushaltsjahr_id && canManage && (
<Alert severity="info" sx={{ mb: 2 }} action={
<Button color="inherit" size="small" startIcon={<PlaylistAdd />} onClick={() => createHjMut.mutate()} disabled={createHjMut.isPending}>
Haushaltsjahr erstellen
</Button>
}>
Diesem Plan ist noch kein Haushaltsjahr zugeordnet. Sie können aus diesem Plan ein Haushaltsjahr mit Konten erstellen.
</Alert>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Positionen ({positionen.length})</Typography>
{canManage && (
<Button variant="contained" startIcon={<AddIcon />} onClick={openAddDialog}>Position hinzufügen</Button>
)}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Konto</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>
{positionen.length === 0 && (
<TableRow><TableCell colSpan={canManage ? 7 : 6} align="center"><Typography color="text.secondary">Keine Positionen</Typography></TableCell></TableRow>
)}
{positionen.map((pos: Planposition) => {
const rowTotal = Number(pos.budget_gwg) + Number(pos.budget_anlagen) + Number(pos.budget_instandhaltung);
return (
<TableRow key={pos.id} hover>
<TableCell>{pos.bezeichnung}</TableCell>
<TableCell>{pos.konto_bezeichnung ? `${pos.konto_kontonummer} ${pos.konto_bezeichnung}` : ''}</TableCell>
<TableCell align="right">{fmtEur(Number(pos.budget_gwg))}</TableCell>
<TableCell align="right">{fmtEur(Number(pos.budget_anlagen))}</TableCell>
<TableCell align="right">{fmtEur(Number(pos.budget_instandhaltung))}</TableCell>
<TableCell align="right">{fmtEur(rowTotal)}</TableCell>
{canManage && (
<TableCell>
<IconButton size="small" onClick={() => openEditDialog(pos)}><Edit fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deletePosMut.mutate(pos.id)}><Delete fontSize="small" /></IconButton>
</TableCell>
)}
</TableRow>
);
})}
{positionen.length > 0 && (
<TableRow sx={{ '& td': { fontWeight: 'bold' } }}>
<TableCell colSpan={2}>Summe</TableCell>
<TableCell align="right">{fmtEur(totalGwg)}</TableCell>
<TableCell align="right">{fmtEur(totalAnlagen)}</TableCell>
<TableCell align="right">{fmtEur(totalInstandhaltung)}</TableCell>
<TableCell align="right">{fmtEur(totalGwg + totalAnlagen + totalInstandhaltung)}</TableCell>
{canManage && <TableCell />}
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Position Add/Edit Dialog */}
<Dialog open={posDialog.open} onClose={() => setPosDialog({ open: false })} maxWidth="sm" fullWidth>
<DialogTitle>{posDialog.existing ? 'Position bearbeiten' : 'Neue Position'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Bezeichnung" value={posForm.bezeichnung} onChange={e => setPosForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
{konten.length > 0 && (
<FormControl fullWidth>
<InputLabel>Konto (optional)</InputLabel>
<Select value={posForm.konto_id} label="Konto (optional)" onChange={e => setPosForm(f => ({ ...f, konto_id: e.target.value as string }))}>
<MenuItem value="">Kein Konto</MenuItem>
{konten.map((k: Konto) => <MenuItem key={k.id} value={k.id}>{k.kontonummer} {k.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
)}
<TextField
label="Budget GWG"
type="number"
value={posForm.budget_gwg}
onChange={e => setPosForm(f => ({ ...f, budget_gwg: e.target.value }))}
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
/>
<TextField
label="Budget Anlagen"
type="number"
value={posForm.budget_anlagen}
onChange={e => setPosForm(f => ({ ...f, budget_anlagen: e.target.value }))}
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
/>
<TextField
label="Budget Instandhaltung"
type="number"
value={posForm.budget_instandhaltung}
onChange={e => setPosForm(f => ({ ...f, budget_instandhaltung: e.target.value }))}
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
/>
<TextField label="Notizen" value={posForm.notizen} onChange={e => setPosForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setPosDialog({ open: false })}>Abbrechen</Button>
<Button variant="contained" disabled={!posForm.bezeichnung.trim()} onClick={handlePosSave}>Speichern</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}