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,163 @@
import { useState } from 'react';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon, Delete, Visibility } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { buchhaltungApi } from '../services/buchhaltung';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import type { Planung, PlanungStatus, Haushaltsjahr } 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 fmtDate(val: string) {
return new Date(val).toLocaleDateString('de-DE');
}
function fmtEur(val: number) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
}
export default function Haushaltsplan() {
const navigate = useNavigate();
const qc = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const canManage = hasPermission('buchhaltung:manage_accounts');
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<{ bezeichnung: string; haushaltsjahr_id: string }>({ bezeichnung: '', haushaltsjahr_id: '' });
const { data: planungen = [], isLoading } = useQuery({
queryKey: ['planungen'],
queryFn: buchhaltungApi.listPlanungen,
});
const { data: haushaltsjahre = [] } = useQuery({
queryKey: ['haushaltsjahre'],
queryFn: buchhaltungApi.getHaushaltsjahre,
});
const createMut = useMutation({
mutationFn: buchhaltungApi.createPlanung,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planungen'] }); setDialogOpen(false); showSuccess('Haushaltsplan erstellt'); },
onError: () => showError('Haushaltsplan konnte nicht erstellt werden'),
});
const deleteMut = useMutation({
mutationFn: buchhaltungApi.deletePlanung,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planungen'] }); showSuccess('Haushaltsplan gelöscht'); },
onError: () => showError('Haushaltsplan konnte nicht gelöscht werden'),
});
return (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>Haushaltspläne</Typography>
{canManage && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setForm({ bezeichnung: '', haushaltsjahr_id: '' }); setDialogOpen(true); }}>
Neuer Haushaltsplan
</Button>
)}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Haushaltsjahr</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Positionen</TableCell>
<TableCell align="right">Gesamt (GWG + Anl. + Inst.)</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!isLoading && planungen.length === 0 && (
<TableRow><TableCell colSpan={7} align="center"><Typography color="text.secondary">Keine Haushaltspläne vorhanden</Typography></TableCell></TableRow>
)}
{planungen.map((p: Planung) => {
const total = Number(p.total_gwg) + Number(p.total_anlagen) + Number(p.total_instandhaltung);
return (
<TableRow key={p.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/haushaltsplan/${p.id}`)}>
<TableCell>{p.bezeichnung}</TableCell>
<TableCell>{p.haushaltsjahr_bezeichnung || ''}</TableCell>
<TableCell><Chip label={STATUS_LABELS[p.status] || p.status} size="small" color={STATUS_COLORS[p.status] || 'default'} /></TableCell>
<TableCell align="right">{p.positionen_count}</TableCell>
<TableCell align="right">{fmtEur(total)}</TableCell>
<TableCell>{fmtDate(p.erstellt_am)}</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<IconButton size="small" onClick={() => navigate(`/haushaltsplan/${p.id}`)}><Visibility fontSize="small" /></IconButton>
{canManage && <IconButton size="small" color="error" onClick={() => deleteMut.mutate(p.id)}><Delete fontSize="small" /></IconButton>}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neuer Haushaltsplan</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
<FormControl fullWidth>
<InputLabel>Haushaltsjahr (optional)</InputLabel>
<Select value={form.haushaltsjahr_id} label="Haushaltsjahr (optional)" onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: e.target.value as string }))}>
<MenuItem value="">Kein Haushaltsjahr</MenuItem>
{haushaltsjahre.map((hj: Haushaltsjahr) => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.bezeichnung.trim()}
onClick={() => createMut.mutate({
bezeichnung: form.bezeichnung.trim(),
haushaltsjahr_id: form.haushaltsjahr_id ? Number(form.haushaltsjahr_id) : undefined,
})}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}