Files
dashboard/frontend/src/pages/HaushaltsplanDetail.tsx

272 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}