feat(buchhaltung): add transfers, bank statements, Haushaltsplan, and PDF export
This commit is contained in:
@@ -1356,6 +1356,11 @@ export default function BestellungDetail() {
|
||||
Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs.
|
||||
</Typography>
|
||||
)}
|
||||
{statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern.
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>Abbrechen</Button>
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
@@ -51,6 +50,9 @@ import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
HowToReg,
|
||||
Lock,
|
||||
Save,
|
||||
SwapHoriz,
|
||||
PictureAsPdf as PdfIcon,
|
||||
ThumbDown,
|
||||
ThumbUp,
|
||||
} from '@mui/icons-material';
|
||||
@@ -58,6 +60,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { bestellungApi } from '../services/bestellung';
|
||||
import { configApi, type PdfSettings } from '../services/config';
|
||||
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
@@ -66,20 +70,24 @@ import type {
|
||||
Bankkonto, BankkontoFormData,
|
||||
Konto, KontoFormData,
|
||||
KontoTreeNode,
|
||||
KontoTyp,
|
||||
Kategorie,
|
||||
Transaktion, TransaktionFormData, TransaktionFilters,
|
||||
TransaktionTyp,
|
||||
TransaktionStatus,
|
||||
AusgabenTyp,
|
||||
WiederkehrendBuchung, WiederkehrendFormData,
|
||||
WiederkehrendIntervall,
|
||||
BudgetTyp,
|
||||
ErstattungFormData,
|
||||
TransferFormData,
|
||||
} from '../types/buchhaltung.types';
|
||||
import {
|
||||
TRANSAKTION_STATUS_LABELS,
|
||||
TRANSAKTION_STATUS_COLORS,
|
||||
TRANSAKTION_TYP_LABELS,
|
||||
INTERVALL_LABELS,
|
||||
KONTO_ART_LABELS,
|
||||
} from '../types/buchhaltung.types';
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -98,6 +106,149 @@ function TabPanel({ children, value, index }: { children: React.ReactNode; value
|
||||
return value === index ? <Box role="tabpanel" sx={{ pt: 3 }}>{children}</Box> : null;
|
||||
}
|
||||
|
||||
// ─── PDF Export ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let _pdfSettingsCache: PdfSettings | null = null;
|
||||
let _pdfSettingsCacheTime = 0;
|
||||
|
||||
async function fetchPdfSettings(): Promise<PdfSettings> {
|
||||
if (_pdfSettingsCache && Date.now() - _pdfSettingsCacheTime < 30_000) {
|
||||
return _pdfSettingsCache;
|
||||
}
|
||||
try {
|
||||
_pdfSettingsCache = await configApi.getPdfSettings();
|
||||
_pdfSettingsCacheTime = Date.now();
|
||||
return _pdfSettingsCache;
|
||||
} catch {
|
||||
return { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBuchhaltungPdf(
|
||||
jahrBezeichnung: string,
|
||||
totalEinnahmen: number,
|
||||
totalAusgaben: number,
|
||||
saldo: number,
|
||||
treeData: KontoTreeNode[],
|
||||
transaktionen: Transaktion[],
|
||||
) {
|
||||
const { jsPDF } = await import('jspdf');
|
||||
const autoTable = (await import('jspdf-autotable')).default;
|
||||
const pdfSettings = await fetchPdfSettings();
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const pageWidth = 210;
|
||||
|
||||
// ── Page 1: Summary ──
|
||||
let y = await addPdfHeader(doc, pdfSettings, pageWidth);
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Buchhaltung \u2014 ${jahrBezeichnung}`, 10, y);
|
||||
y += 12;
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Einnahmen:`, 10, y);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(46, 125, 50);
|
||||
doc.text(fmtEur(totalEinnahmen), 50, y);
|
||||
y += 7;
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text(`Ausgaben:`, 10, y);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(211, 47, 47);
|
||||
doc.text(fmtEur(totalAusgaben), 50, y);
|
||||
y += 7;
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text(`Saldo:`, 10, y);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(saldo >= 0 ? 46 : 211, saldo >= 0 ? 125 : 47, saldo >= 0 ? 50 : 47);
|
||||
doc.text(fmtEur(saldo), 50, y);
|
||||
y += 14;
|
||||
|
||||
doc.setTextColor(0, 0, 0);
|
||||
|
||||
// ── Page 2+: Konten tree table ──
|
||||
doc.setFontSize(13);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Konten', 10, y);
|
||||
y += 6;
|
||||
|
||||
const kontenRows = treeData.map(k => [
|
||||
`${k.kontonummer} ${k.bezeichnung}`,
|
||||
fmtEur(Number(k.budget_gwg || 0)),
|
||||
fmtEur(Number(k.budget_anlagen || 0)),
|
||||
fmtEur(Number(k.budget_instandhaltung || 0)),
|
||||
fmtEur(Number(k.budget_gwg || 0) + Number(k.budget_anlagen || 0) + Number(k.budget_instandhaltung || 0)),
|
||||
fmtEur(Number(k.spent_gwg || 0) + Number(k.spent_anlagen || 0) + Number(k.spent_instandhaltung || 0)),
|
||||
fmtEur(Number(k.einnahmen_betrag || 0)),
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
head: [['Konto', 'Budget GWG', 'Budget Anl.', 'Budget Inst.', 'Budget Ges.', 'Ausgaben Ges.', 'Einnahmen']],
|
||||
body: kontenRows,
|
||||
startY: y,
|
||||
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold', fontSize: 7 },
|
||||
styles: { fontSize: 7, cellPadding: 1.5 },
|
||||
alternateRowStyles: { fillColor: [250, 235, 235] },
|
||||
margin: { left: 10, right: 10 },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 50 },
|
||||
1: { halign: 'right' },
|
||||
2: { halign: 'right' },
|
||||
3: { halign: 'right' },
|
||||
4: { halign: 'right' },
|
||||
5: { halign: 'right' },
|
||||
6: { halign: 'right' },
|
||||
},
|
||||
didDrawPage: addPdfFooter(doc, pdfSettings),
|
||||
});
|
||||
|
||||
// ── Next page(s): Transactions ──
|
||||
doc.addPage();
|
||||
let txY = await addPdfHeader(doc, pdfSettings, pageWidth);
|
||||
|
||||
doc.setFontSize(13);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Transaktionen', 10, txY);
|
||||
txY += 6;
|
||||
|
||||
const txRows = transaktionen.map(t => [
|
||||
fmtDate(t.datum),
|
||||
t.typ === 'transfer' ? 'Transfer' : t.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe',
|
||||
t.beschreibung || t.empfaenger_auftraggeber || '',
|
||||
t.beleg_nr || '',
|
||||
t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung || ''}` : '',
|
||||
`${t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}${fmtEur(t.betrag)}`,
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
head: [['Datum', 'Typ', 'Beschreibung', 'Beleg-Nr.', 'Konto', 'Betrag']],
|
||||
body: txRows,
|
||||
startY: txY,
|
||||
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold', fontSize: 7 },
|
||||
styles: { fontSize: 7, cellPadding: 1.5 },
|
||||
alternateRowStyles: { fillColor: [250, 235, 235] },
|
||||
margin: { left: 10, right: 10 },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 22 },
|
||||
1: { cellWidth: 18 },
|
||||
2: { cellWidth: 55 },
|
||||
3: { cellWidth: 22 },
|
||||
4: { cellWidth: 40 },
|
||||
5: { halign: 'right', cellWidth: 25 },
|
||||
},
|
||||
didDrawPage: addPdfFooter(doc, pdfSettings),
|
||||
});
|
||||
|
||||
doc.save(`buchhaltung_${jahrBezeichnung.replace(/\s+/g, '_')}.pdf`);
|
||||
}
|
||||
|
||||
// ─── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function HaushaltsjahrDialog({
|
||||
@@ -566,6 +717,8 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
onJahrChange: (id: number) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const { showError } = useNotification();
|
||||
const { data: treeData = [], isLoading } = useQuery({
|
||||
queryKey: ['kontenTree', selectedJahrId],
|
||||
queryFn: () => buchhaltungApi.getKontenTree(selectedJahrId!),
|
||||
@@ -576,6 +729,11 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!),
|
||||
enabled: selectedJahrId != null,
|
||||
});
|
||||
const { data: transaktionenForPdf = [] } = useQuery({
|
||||
queryKey: ['buchhaltung-transaktionen', { haushaltsjahr_id: selectedJahrId }],
|
||||
queryFn: () => buchhaltungApi.getTransaktionen({ haushaltsjahr_id: selectedJahrId! }),
|
||||
enabled: selectedJahrId != null,
|
||||
});
|
||||
|
||||
const tree = buildTree(treeData);
|
||||
|
||||
@@ -602,6 +760,23 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}{hj.abgeschlossen ? ' (abgeschlossen)' : ''}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{hasPermission('buchhaltung:export') && selectedJahrId && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<PdfIcon />}
|
||||
onClick={async () => {
|
||||
const hj = haushaltsjahre.find(h => h.id === selectedJahrId);
|
||||
if (!hj) return;
|
||||
try {
|
||||
await generateBuchhaltungPdf(hj.bezeichnung, totalEinnahmen, totalAusgaben, saldo, treeData, transaktionenForPdf);
|
||||
} catch {
|
||||
showError('PDF-Export fehlgeschlagen');
|
||||
}
|
||||
}}
|
||||
>
|
||||
PDF exportieren
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isLoading && <CircularProgress />}
|
||||
@@ -860,6 +1035,82 @@ function ErstattungDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function TransferDialog({
|
||||
open,
|
||||
onClose,
|
||||
haushaltsjahre,
|
||||
selectedJahrId,
|
||||
bankkonten,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
haushaltsjahre: Haushaltsjahr[];
|
||||
selectedJahrId: number | null;
|
||||
bankkonten: Bankkonto[];
|
||||
onSave: (data: TransferFormData) => void;
|
||||
}) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [form, setForm] = useState<TransferFormData>({
|
||||
haushaltsjahr_id: selectedJahrId || 0,
|
||||
bankkonto_id: 0,
|
||||
transfer_ziel_bankkonto_id: 0,
|
||||
betrag: 0,
|
||||
datum: today,
|
||||
beschreibung: '',
|
||||
beleg_nr: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setForm(f => ({ ...f, haushaltsjahr_id: selectedJahrId || 0, bankkonto_id: 0, transfer_ziel_bankkonto_id: 0, betrag: 0, datum: today, beschreibung: '', beleg_nr: '' }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const zielOptions = bankkonten.filter(bk => bk.id !== form.bankkonto_id);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Transfer zwischen Bankkonten</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Haushaltsjahr</InputLabel>
|
||||
<Select value={form.haushaltsjahr_id || ''} label="Haushaltsjahr" onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: Number(e.target.value) }))}>
|
||||
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Quell-Bankkonto</InputLabel>
|
||||
<Select value={form.bankkonto_id || ''} label="Quell-Bankkonto" onChange={e => setForm(f => ({ ...f, bankkonto_id: Number(e.target.value), transfer_ziel_bankkonto_id: f.transfer_ziel_bankkonto_id === Number(e.target.value) ? 0 : f.transfer_ziel_bankkonto_id }))}>
|
||||
{bankkonten.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Ziel-Bankkonto</InputLabel>
|
||||
<Select value={form.transfer_ziel_bankkonto_id || ''} label="Ziel-Bankkonto" onChange={e => setForm(f => ({ ...f, transfer_ziel_bankkonto_id: Number(e.target.value) }))}>
|
||||
{zielOptions.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Betrag (EUR)" type="number" value={form.betrag} onChange={e => setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required />
|
||||
<TextField label="Datum" type="date" value={form.datum} onChange={e => setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required />
|
||||
<TextField label="Beschreibung" value={form.beschreibung} onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))} />
|
||||
<TextField label="Beleg-Nr." value={form.beleg_nr} onChange={e => setForm(f => ({ ...f, beleg_nr: e.target.value }))} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!form.haushaltsjahr_id || !form.bankkonto_id || !form.transfer_ziel_bankkonto_id || !form.betrag || !form.datum}
|
||||
onClick={() => onSave(form)}
|
||||
>
|
||||
Transfer erstellen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab 1: Transaktionen ─────────────────────────────────────────────────────
|
||||
|
||||
function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
@@ -878,6 +1129,9 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
// ── Erstattung state ──
|
||||
const [erstattungOpen, setErstattungOpen] = useState(false);
|
||||
|
||||
// ── Transfer state ──
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
|
||||
// ── Wiederkehrend state ──
|
||||
const [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false });
|
||||
|
||||
@@ -962,6 +1216,13 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
onError: () => showError('Erstattung konnte nicht erstellt werden'),
|
||||
});
|
||||
|
||||
// ── Transfer mutation ──
|
||||
const createTransferMut = useMutation({
|
||||
mutationFn: buchhaltungApi.createTransfer,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); setTransferOpen(false); showSuccess('Transfer erstellt'); },
|
||||
onError: () => showError('Transfer konnte nicht erstellt werden'),
|
||||
});
|
||||
|
||||
const handleExportCsv = async () => {
|
||||
if (!filters.haushaltsjahr_id) { showError('Bitte ein Haushaltsjahr auswählen'); return; }
|
||||
try {
|
||||
@@ -1034,10 +1295,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
<FormControl size="small" sx={{ minWidth: 130 }}>
|
||||
<InputLabel>Typ</InputLabel>
|
||||
<Select size="small" value={filters.typ ?? ''} label="Typ"
|
||||
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
|
||||
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as TransaktionTyp) || undefined }))}>
|
||||
<MenuItem value=""><em>Alle</em></MenuItem>
|
||||
<MenuItem value="einnahme">Einnahme</MenuItem>
|
||||
<MenuItem value="ausgabe">Ausgabe</MenuItem>
|
||||
<MenuItem value="transfer">Transfer</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField size="small" label="Suche" value={filters.search ?? ''}
|
||||
@@ -1067,6 +1329,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
Erstattung erfassen
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('buchhaltung:create') && (
|
||||
<Button size="small" variant="outlined" startIcon={<SwapHoriz />} onClick={() => setTransferOpen(true)}>
|
||||
Transfer
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
@@ -1106,7 +1373,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
|
||||
<TableCell>{fmtDate(t.datum)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={TRANSAKTION_TYP_LABELS[t.typ]} size="small" color={t.typ === 'einnahme' ? 'success' : 'error'} />
|
||||
<Chip
|
||||
label={t.typ === 'transfer' ? `Transfer${t.transfer_ziel_bezeichnung ? ' \u2192 ' + t.transfer_ziel_bezeichnung : ''}` : TRANSAKTION_TYP_LABELS[t.typ]}
|
||||
size="small"
|
||||
color={t.typ === 'einnahme' ? 'success' : t.typ === 'transfer' ? 'info' : 'error'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
@@ -1117,8 +1388,8 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung}` : '–'}</TableCell>
|
||||
<TableCell align="right" sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main', fontWeight: 600 }}>
|
||||
{t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)}
|
||||
<TableCell align="right" sx={{ color: t.typ === 'einnahme' ? 'success.main' : t.typ === 'transfer' ? 'info.main' : 'error.main', fontWeight: 600 }}>
|
||||
{t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={TRANSAKTION_STATUS_LABELS[t.status]} size="small" color={TRANSAKTION_STATUS_COLORS[t.status]} />
|
||||
@@ -1198,6 +1469,15 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
transaktionen={transaktionen}
|
||||
onSave={data => createErstattungMut.mutate(data)}
|
||||
/>
|
||||
|
||||
<TransferDialog
|
||||
open={transferOpen}
|
||||
onClose={() => setTransferOpen(false)}
|
||||
haushaltsjahre={haushaltsjahre}
|
||||
selectedJahrId={selectedJahrId}
|
||||
bankkonten={bankkonten}
|
||||
onSave={data => createTransferMut.mutate(data)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1423,6 +1703,45 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
const [newKategorie, setNewKategorie] = useState('');
|
||||
const [addingKategorie, setAddingKategorie] = useState(false);
|
||||
|
||||
// ── Einstellungen state ────────────────────────────────────────────────────
|
||||
const [kontoTypDialog, setKontoTypDialog] = useState<{ open: boolean; existing?: KontoTyp }>({ open: false });
|
||||
const [kontoTypForm, setKontoTypForm] = useState<{ bezeichnung: string; art: string; sort_order: number }>({ bezeichnung: '', art: 'ausgabe', sort_order: 0 });
|
||||
const [alertThreshold, setAlertThreshold] = useState<string>('80');
|
||||
|
||||
const { data: kontoTypen = [] } = useQuery({ queryKey: ['konto-typen'], queryFn: buchhaltungApi.getKontoTypen });
|
||||
const { data: einstellungen } = useQuery({
|
||||
queryKey: ['buchhaltung-einstellungen'],
|
||||
queryFn: buchhaltungApi.getEinstellungen,
|
||||
enabled: hasPermission('buchhaltung:manage_settings'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (einstellungen?.default_alert_threshold) {
|
||||
setAlertThreshold(String(einstellungen.default_alert_threshold));
|
||||
}
|
||||
}, [einstellungen]);
|
||||
|
||||
const createKontoTypMut = useMutation({
|
||||
mutationFn: buchhaltungApi.createKontoTyp,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['konto-typen'] }); setKontoTypDialog({ open: false }); showSuccess('Konto-Typ erstellt'); },
|
||||
onError: () => showError('Konto-Typ konnte nicht erstellt werden'),
|
||||
});
|
||||
const updateKontoTypMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<{ bezeichnung: string; art: string; sort_order: number }> }) => buchhaltungApi.updateKontoTyp(id, data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['konto-typen'] }); setKontoTypDialog({ open: false }); showSuccess('Konto-Typ aktualisiert'); },
|
||||
onError: () => showError('Konto-Typ konnte nicht aktualisiert werden'),
|
||||
});
|
||||
const deleteKontoTypMut = useMutation({
|
||||
mutationFn: buchhaltungApi.deleteKontoTyp,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['konto-typen'] }); showSuccess('Konto-Typ gelöscht'); },
|
||||
onError: (err: any) => showError(err?.response?.data?.message || 'Konto-Typ konnte nicht gelöscht werden'),
|
||||
});
|
||||
const saveEinstellungenMut = useMutation({
|
||||
mutationFn: buchhaltungApi.setEinstellungen,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-einstellungen'] }); showSuccess('Einstellungen gespeichert'); },
|
||||
onError: () => showError('Einstellungen konnten nicht gespeichert werden'),
|
||||
});
|
||||
|
||||
const createKategorieMut = useMutation({
|
||||
mutationFn: buchhaltungApi.createKategorie,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-kategorien'] }); setNewKategorie(''); setAddingKategorie(false); showSuccess('Kategorie erstellt'); },
|
||||
@@ -1491,6 +1810,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
<Tab label="Konten" />
|
||||
<Tab label="Bankkonten" />
|
||||
<Tab label="Haushaltsjahre" />
|
||||
{hasPermission('buchhaltung:manage_settings') && <Tab label="Einstellungen" />}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
@@ -1620,7 +1940,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
<TableBody>
|
||||
{bankkonten.length === 0 && <TableRow><TableCell colSpan={5} align="center"><Typography color="text.secondary">Keine Bankkonten</Typography></TableCell></TableRow>}
|
||||
{bankkonten.map((bk: Bankkonto) => (
|
||||
<TableRow key={bk.id} hover>
|
||||
<TableRow key={bk.id} hover onClick={() => navigate(`/buchhaltung/bankkonto/${bk.id}`)} sx={{ cursor: 'pointer' }}>
|
||||
<TableCell>{bk.bezeichnung}</TableCell>
|
||||
<TableCell>{bk.iban || '–'}</TableCell>
|
||||
<TableCell>{bk.institut || '–'}</TableCell>
|
||||
@@ -1701,6 +2021,97 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Sub-Tab 3: Einstellungen */}
|
||||
{subTab === 3 && hasPermission('buchhaltung:manage_settings') && (
|
||||
<Box>
|
||||
{/* Konto-Typen CRUD */}
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>Konto-Typen</Typography>
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setKontoTypForm({ bezeichnung: '', art: 'ausgabe', sort_order: 0 }); setKontoTypDialog({ open: true }); }}>Konto-Typ anlegen</Button>
|
||||
</Box>
|
||||
<TableContainer component={Paper} sx={{ mb: 4 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Art</TableCell>
|
||||
<TableCell align="right">Sortierung</TableCell>
|
||||
<TableCell>Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{kontoTypen.length === 0 && <TableRow><TableCell colSpan={4} align="center"><Typography color="text.secondary">Keine Konto-Typen</Typography></TableCell></TableRow>}
|
||||
{kontoTypen.map((kt: KontoTyp) => (
|
||||
<TableRow key={kt.id} hover>
|
||||
<TableCell>{kt.bezeichnung}</TableCell>
|
||||
<TableCell>{KONTO_ART_LABELS[kt.art] || kt.art}</TableCell>
|
||||
<TableCell align="right">{kt.sort_order}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => { setKontoTypForm({ bezeichnung: kt.bezeichnung, art: kt.art, sort_order: kt.sort_order }); setKontoTypDialog({ open: true, existing: kt }); }}><Edit fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => deleteKontoTypMut.mutate(kt.id)}><Delete fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Alert-Schwellwert */}
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>Budget-Warnung</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
label="Standard-Schwellwert"
|
||||
type="number"
|
||||
value={alertThreshold}
|
||||
onChange={e => setAlertThreshold(e.target.value)}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
|
||||
helperText="Budget-Warnung wird ausgelöst, wenn die Auslastung diesen Wert erreicht"
|
||||
sx={{ width: 280 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
onClick={() => saveEinstellungenMut.mutate({ default_alert_threshold: alertThreshold })}
|
||||
disabled={saveEinstellungenMut.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Konto-Typ Dialog */}
|
||||
<Dialog open={kontoTypDialog.open} onClose={() => setKontoTypDialog({ open: false })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{kontoTypDialog.existing ? 'Konto-Typ bearbeiten' : 'Neuer Konto-Typ'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Bezeichnung" value={kontoTypForm.bezeichnung} onChange={e => setKontoTypForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Art</InputLabel>
|
||||
<Select value={kontoTypForm.art} label="Art" onChange={e => setKontoTypForm(f => ({ ...f, art: e.target.value }))}>
|
||||
<MenuItem value="einnahme">Einnahmen</MenuItem>
|
||||
<MenuItem value="ausgabe">Ausgaben</MenuItem>
|
||||
<MenuItem value="vermoegen">Vermögen</MenuItem>
|
||||
<MenuItem value="verbindlichkeit">Verbindlichkeiten</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Sortierung" type="number" value={kontoTypForm.sort_order} onChange={e => setKontoTypForm(f => ({ ...f, sort_order: parseInt(e.target.value, 10) || 0 }))} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setKontoTypDialog({ open: false })}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={() => {
|
||||
if (kontoTypDialog.existing) {
|
||||
updateKontoTypMut.mutate({ id: kontoTypDialog.existing.id, data: kontoTypForm });
|
||||
} else {
|
||||
createKontoTypMut.mutate(kontoTypForm);
|
||||
}
|
||||
}}>Speichern</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
190
frontend/src/pages/BuchhaltungBankkontoDetail.tsx
Normal file
190
frontend/src/pages/BuchhaltungBankkontoDetail.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types';
|
||||
import type { BankkontoStatementRow } from '../types/buchhaltung.types';
|
||||
|
||||
function fmtEur(val: number) {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
||||
}
|
||||
|
||||
function fmtDate(val: string) {
|
||||
return new Date(val).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
export default function BuchhaltungBankkontoDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const bankkontoId = Number(id);
|
||||
|
||||
const [von, setVon] = useState('');
|
||||
const [bis, setBis] = useState('');
|
||||
const [appliedVon, setAppliedVon] = useState('');
|
||||
const [appliedBis, setAppliedBis] = useState('');
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['bankkonto-statement', bankkontoId, appliedVon, appliedBis],
|
||||
queryFn: () => buchhaltungApi.getBankkontoStatement(bankkontoId, {
|
||||
von: appliedVon || undefined,
|
||||
bis: appliedBis || undefined,
|
||||
}),
|
||||
enabled: !!bankkontoId,
|
||||
});
|
||||
|
||||
const handleApply = () => {
|
||||
setAppliedVon(von);
|
||||
setAppliedBis(bis);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Tooltip title="Zurueck">
|
||||
<IconButton onClick={() => navigate('/buchhaltung?tab=2')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
{data?.bankkonto?.bezeichnung ?? 'Bankkonto'}
|
||||
{data?.bankkonto?.iban && (
|
||||
<Typography component="span" variant="body1" color="text.secondary" sx={{ ml: 2 }}>
|
||||
{data.bankkonto.iban}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Date range filter */}
|
||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
size="small"
|
||||
label="Von"
|
||||
type="date"
|
||||
value={von}
|
||||
onChange={e => setVon(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Bis"
|
||||
type="date"
|
||||
value={bis}
|
||||
onChange={e => setBis(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="contained" size="small" onClick={handleApply}>
|
||||
Anwenden
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{isLoading && <CircularProgress />}
|
||||
{isError && <Typography color="error">Fehler beim Laden der Kontodaten.</Typography>}
|
||||
|
||||
{data && !isLoading && (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mb: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" variant="body2">Einnahmen</Typography>
|
||||
<Typography variant="h5" color="success.main">{fmtEur(data.einnahmen)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" variant="body2">Ausgaben</Typography>
|
||||
<Typography variant="h5" color="error.main">{fmtEur(data.ausgaben)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" variant="body2">Saldo</Typography>
|
||||
<Typography variant="h5" color={data.saldo >= 0 ? 'success.main' : 'error.main'}>{fmtEur(data.saldo)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Statement table */}
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Datum</TableCell>
|
||||
<TableCell>Typ</TableCell>
|
||||
<TableCell>Beschreibung</TableCell>
|
||||
<TableCell>Beleg-Nr.</TableCell>
|
||||
<TableCell align="right">Betrag</TableCell>
|
||||
<TableCell align="right">Laufender Saldo</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
<Typography color="text.secondary">Keine Transaktionen im Zeitraum</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{data.rows.map((row: BankkontoStatementRow) => {
|
||||
const isEinnahme = row.typ === 'einnahme';
|
||||
const isTransfer = row.typ === 'transfer';
|
||||
return (
|
||||
<TableRow key={row.id} hover>
|
||||
<TableCell>{fmtDate(row.datum)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={isTransfer ? `Transfer${row.transfer_ziel_bezeichnung ? ' \u2192 ' + row.transfer_ziel_bezeichnung : ''}` : TRANSAKTION_TYP_LABELS[row.typ]}
|
||||
size="small"
|
||||
color={isEinnahme ? 'success' : isTransfer ? 'info' : 'error'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{row.beschreibung || '\u2013'}</TableCell>
|
||||
<TableCell>{row.beleg_nr || '\u2013'}</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
color: isEinnahme ? 'success.main' : isTransfer ? 'info.main' : 'error.main',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{row.typ === 'ausgabe' ? '-' : row.typ === 'einnahme' ? '+' : ''}{fmtEur(row.betrag)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>
|
||||
{fmtEur(row.laufender_saldo)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
163
frontend/src/pages/Haushaltsplan.tsx
Normal file
163
frontend/src/pages/Haushaltsplan.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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