272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|