feat(buchhaltung): add transfers, bank statements, Haushaltsplan, and PDF export
This commit is contained in:
271
frontend/src/pages/HaushaltsplanDetail.tsx
Normal file
271
frontend/src/pages/HaushaltsplanDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user