import { useState, useEffect } from 'react'; import { Box, Button, Card, CardContent, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Fab, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, Stack, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tabs, TextField, Tooltip, Typography, } from '@mui/material'; import { Add as AddIcon, BookmarkAdd, Cancel, Delete, Edit, Lock, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useLocation, useNavigate } from 'react-router-dom'; import MainLayout from '../components/shared/MainLayout'; import { buchhaltungApi } from '../services/buchhaltung'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import type { Haushaltsjahr, HaushaltsjahrFormData, Bankkonto, BankkontoFormData, Konto, KontoFormData, Transaktion, TransaktionFormData, TransaktionFilters, TransaktionStatus, } from '../types/buchhaltung.types'; import { TRANSAKTION_STATUS_LABELS, TRANSAKTION_STATUS_COLORS, TRANSAKTION_TYP_LABELS, } from '../types/buchhaltung.types'; // ─── helpers ─────────────────────────────────────────────────────────────────── 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'); } // ─── Sub-components ──────────────────────────────────────────────────────────── function HaushaltsjahrDialog({ open, onClose, existing, onSave, }: { open: boolean; onClose: () => void; existing?: Haushaltsjahr; onSave: (data: HaushaltsjahrFormData) => void; }) { const year = new Date().getFullYear(); const [form, setForm] = useState({ jahr: year, bezeichnung: `Haushaltsjahr ${year}`, beginn: `${year}-01-01`, ende: `${year}-12-31`, }); useEffect(() => { if (existing) { setForm({ jahr: existing.jahr, bezeichnung: existing.bezeichnung, beginn: existing.beginn.slice(0, 10), ende: existing.ende.slice(0, 10) }); } else { setForm({ jahr: year, bezeichnung: `Haushaltsjahr ${year}`, beginn: `${year}-01-01`, ende: `${year}-12-31` }); } }, [existing, open, year]); return ( {existing ? 'Haushaltsjahr bearbeiten' : 'Neues Haushaltsjahr'} setForm(f => ({ ...f, jahr: parseInt(e.target.value, 10) }))} /> setForm(f => ({ ...f, bezeichnung: e.target.value }))} /> setForm(f => ({ ...f, beginn: e.target.value }))} InputLabelProps={{ shrink: true }} /> setForm(f => ({ ...f, ende: e.target.value }))} InputLabelProps={{ shrink: true }} /> ); } function BankkontoDialog({ open, onClose, existing, onSave, }: { open: boolean; onClose: () => void; existing?: Bankkonto; onSave: (data: BankkontoFormData) => void; }) { const empty: BankkontoFormData = { bezeichnung: '', iban: '', bic: '', institut: '', ist_standard: false }; const [form, setForm] = useState(empty); useEffect(() => { if (existing) { setForm({ bezeichnung: existing.bezeichnung, iban: existing.iban || '', bic: existing.bic || '', institut: existing.institut || '', ist_standard: existing.ist_standard }); } else { setForm(empty); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [existing, open]); return ( {existing ? 'Bankkonto bearbeiten' : 'Neues Bankkonto'} setForm(f => ({ ...f, bezeichnung: e.target.value }))} required /> setForm(f => ({ ...f, iban: e.target.value }))} /> setForm(f => ({ ...f, bic: e.target.value }))} /> setForm(f => ({ ...f, institut: e.target.value }))} /> ); } function KontoDialog({ open, onClose, haushaltsjahrId, existing, onSave, }: { open: boolean; onClose: () => void; haushaltsjahrId: number; existing?: Konto; onSave: (data: KontoFormData) => void; }) { const { data: kontoTypen = [] } = useQuery({ queryKey: ['konto-typen'], queryFn: buchhaltungApi.getKontoTypen }); const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: '', bezeichnung: '', budget_betrag: 0, notizen: '' }; const [form, setForm] = useState(empty); useEffect(() => { if (existing) { setForm({ haushaltsjahr_id: haushaltsjahrId, konto_typ_id: existing.konto_typ_id ?? undefined, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_betrag: existing.budget_betrag, notizen: existing.notizen || '' }); } else { setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [existing, open, haushaltsjahrId]); return ( {existing ? 'Konto bearbeiten' : 'Neues Konto'} setForm(f => ({ ...f, kontonummer: e.target.value }))} required /> setForm(f => ({ ...f, bezeichnung: e.target.value }))} required /> Kontotyp setForm(f => ({ ...f, budget_betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01' }} /> setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} /> ); } function TransaktionDialog({ open, onClose, haushaltsjahre, selectedJahrId, onSave, }: { open: boolean; onClose: () => void; haushaltsjahre: Haushaltsjahr[]; selectedJahrId: number | null; onSave: (data: TransaktionFormData) => void; }) { const today = new Date().toISOString().slice(0, 10); const [form, setForm] = useState({ haushaltsjahr_id: selectedJahrId || 0, typ: 'ausgabe', betrag: 0, datum: today, konto_id: null, bankkonto_id: null, beschreibung: '', empfaenger_auftraggeber: '', verwendungszweck: '', beleg_nr: '', }); const { data: konten = [] } = useQuery({ queryKey: ['buchhaltung-konten', form.haushaltsjahr_id], queryFn: () => buchhaltungApi.getKonten(form.haushaltsjahr_id), enabled: form.haushaltsjahr_id > 0, }); const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten }); useEffect(() => { if (open) setForm(f => ({ ...f, haushaltsjahr_id: selectedJahrId || 0, datum: today })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); return ( Neue Transaktion Haushaltsjahr Typ setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required /> setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required /> Konto Bankkonto setForm(f => ({ ...f, beschreibung: e.target.value }))} /> setForm(f => ({ ...f, empfaenger_auftraggeber: e.target.value }))} /> setForm(f => ({ ...f, verwendungszweck: e.target.value }))} /> setForm(f => ({ ...f, beleg_nr: e.target.value }))} /> ); } // ─── Tab 0: Übersicht ───────────────────────────────────────────────────────── function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { haushaltsjahre: Haushaltsjahr[]; selectedJahrId: number | null; onJahrChange: (id: number) => void; }) { const { data: stats, isLoading } = useQuery({ queryKey: ['buchhaltung-stats', selectedJahrId], queryFn: () => buchhaltungApi.getStats(selectedJahrId!), enabled: selectedJahrId != null, }); return ( Haushaltsjahr {isLoading && } {stats && ( <> Einnahmen {fmtEur(stats.total_einnahmen)} Ausgaben {fmtEur(stats.total_ausgaben)} Saldo = 0 ? 'success.main' : 'error.main'}>{fmtEur(stats.saldo)} Konten {stats.konten_budget.map(k => ( {k.kontonummer} – {k.bezeichnung} {k.konto_typ_bezeichnung || '–'} Gebucht: {fmtEur(k.gebucht_betrag)} Budget: {fmtEur(k.budget_betrag)} = 90 ? 'error' : k.auslastung_prozent >= 75 ? 'warning' : 'primary'} sx={{ height: 8, borderRadius: 4 }} /> = 90 ? 'error' : 'text.secondary'}> {k.auslastung_prozent}% · Verfügbar: {fmtEur(k.verfuegbar_betrag)} ))} )} {!selectedJahrId && !isLoading && ( Bitte ein Haushaltsjahr auswählen )} ); } // ─── Tab 1: Transaktionen ───────────────────────────────────────────────────── function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { haushaltsjahre: Haushaltsjahr[]; selectedJahrId: number | null; onJahrChange: (id: number) => void; }) { const qc = useQueryClient(); const { showSuccess, showError } = useNotification(); const { hasPermission } = usePermissionContext(); const [filters, setFilters] = useState({ haushaltsjahr_id: selectedJahrId || undefined }); const [createOpen, setCreateOpen] = useState(false); const { data: transaktionen = [], isLoading } = useQuery({ queryKey: ['buchhaltung-transaktionen', filters], queryFn: () => buchhaltungApi.getTransaktionen(filters), }); const createMut = useMutation({ mutationFn: buchhaltungApi.createTransaktion, onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); setCreateOpen(false); showSuccess('Transaktion erstellt'); }, onError: () => showError('Transaktion konnte nicht erstellt werden'), }); const buchenMut = useMutation({ mutationFn: (id: number) => buchhaltungApi.buchenTransaktion(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); showSuccess('Transaktion gebucht'); }, onError: () => showError('Buchung fehlgeschlagen'), }); const stornoMut = useMutation({ mutationFn: (id: number) => buchhaltungApi.stornoTransaktion(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); showSuccess('Transaktion storniert'); }, onError: () => showError('Storno fehlgeschlagen'), }); const deleteMut = useMutation({ mutationFn: (id: number) => buchhaltungApi.deleteTransaktion(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); showSuccess('Transaktion gelöscht'); }, onError: () => showError('Löschen fehlgeschlagen'), }); useEffect(() => { setFilters(f => ({ ...f, haushaltsjahr_id: selectedJahrId || undefined })); }, [selectedJahrId]); return ( {/* Filters */} Haushaltsjahr Status Typ setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} /> {isLoading ? : ( Nr. Datum Typ Beschreibung Konto Betrag Status Aktionen {transaktionen.length === 0 && ( Keine Transaktionen )} {transaktionen.map((t: Transaktion) => ( {t.laufende_nummer ?? `E${t.id}`} {fmtDate(t.datum)} {t.beschreibung || t.empfaenger_auftraggeber || '–'} {t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung}` : '–'} {t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)} {t.status === 'entwurf' && hasPermission('buchhaltung:edit') && ( buchenMut.mutate(t.id)}> )} {(t.status === 'gebucht' || t.status === 'freigegeben') && hasPermission('buchhaltung:edit') && ( stornoMut.mutate(t.id)}> )} {t.status === 'entwurf' && hasPermission('buchhaltung:delete') && ( deleteMut.mutate(t.id)}> )} ))}
)} {hasPermission('buchhaltung:create') && ( setCreateOpen(true)}> )} setCreateOpen(false)} haushaltsjahre={haushaltsjahre} selectedJahrId={selectedJahrId} onSave={data => createMut.mutate(data)} />
); } // ─── Tab 2: Konten ──────────────────────────────────────────────────────────── function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { haushaltsjahre: Haushaltsjahr[]; selectedJahrId: number | null; onJahrChange: (id: number) => void; }) { const qc = useQueryClient(); const { showSuccess, showError } = useNotification(); const { hasPermission } = usePermissionContext(); const [subTab, setSubTab] = useState(0); const [kontoDialog, setKontoDialog] = useState<{ open: boolean; existing?: Konto }>({ open: false }); const [bankDialog, setBankDialog] = useState<{ open: boolean; existing?: Bankkonto }>({ open: false }); const [jahrDialog, setJahrDialog] = useState<{ open: boolean; existing?: Haushaltsjahr }>({ open: false }); const { data: konten = [] } = useQuery({ queryKey: ['buchhaltung-konten', selectedJahrId], queryFn: () => buchhaltungApi.getKonten(selectedJahrId!), enabled: selectedJahrId != null, }); const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten }); const canManage = hasPermission('buchhaltung:manage_accounts'); const createKontoMut = useMutation({ mutationFn: buchhaltungApi.createKonto, onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); setKontoDialog({ open: false }); showSuccess('Konto erstellt'); }, onError: () => showError('Konto konnte nicht erstellt werden'), }); const updateKontoMut = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateKonto(id, data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); setKontoDialog({ open: false }); showSuccess('Konto aktualisiert'); }, onError: () => showError('Konto konnte nicht aktualisiert werden'), }); const deleteKontoMut = useMutation({ mutationFn: buchhaltungApi.deleteKonto, onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); showSuccess('Konto deaktiviert'); }, onError: () => showError('Löschen fehlgeschlagen'), }); const createBankMut = useMutation({ mutationFn: buchhaltungApi.createBankkonto, onSuccess: () => { qc.invalidateQueries({ queryKey: ['bankkonten'] }); setBankDialog({ open: false }); showSuccess('Bankkonto erstellt'); }, onError: () => showError('Bankkonto konnte nicht erstellt werden'), }); const updateBankMut = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateBankkonto(id, data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['bankkonten'] }); setBankDialog({ open: false }); showSuccess('Bankkonto aktualisiert'); }, onError: () => showError('Bankkonto konnte nicht aktualisiert werden'), }); const deleteBankMut = useMutation({ mutationFn: buchhaltungApi.deleteBankkonto, onSuccess: () => { qc.invalidateQueries({ queryKey: ['bankkonten'] }); showSuccess('Bankkonto deaktiviert'); }, onError: () => showError('Löschen fehlgeschlagen'), }); const createJahrMut = useMutation({ mutationFn: buchhaltungApi.createHaushaltsjahr, onSuccess: (hj) => { qc.invalidateQueries({ queryKey: ['haushaltsjahre'] }); setJahrDialog({ open: false }); onJahrChange(hj.id); showSuccess('Haushaltsjahr erstellt'); }, onError: () => showError('Haushaltsjahr konnte nicht erstellt werden'), }); const updateJahrMut = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateHaushaltsjahr(id, data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['haushaltsjahre'] }); setJahrDialog({ open: false }); showSuccess('Haushaltsjahr aktualisiert'); }, onError: () => showError('Aktualisierung fehlgeschlagen'), }); const closeJahrMut = useMutation({ mutationFn: (id: number) => buchhaltungApi.closeHaushaltsjahr(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ['haushaltsjahre'] }); showSuccess('Haushaltsjahr abgeschlossen'); }, onError: (e: Error) => showError(e.message || 'Abschluss fehlgeschlagen'), }); return ( setSubTab(v)} sx={{ mb: 2 }}> {/* Sub-Tab 0: Konten */} {subTab === 0 && ( Haushaltsjahr {canManage && } Kontonummer Bezeichnung Typ Budget {canManage && Aktionen} {konten.length === 0 && Keine Konten} {konten.map((k: Konto) => ( {k.kontonummer} {k.bezeichnung} {k.konto_typ_bezeichnung || '–'} {fmtEur(k.budget_betrag)} {canManage && ( setKontoDialog({ open: true, existing: k })}> deleteKontoMut.mutate(k.id)}> )} ))}
{selectedJahrId && setKontoDialog({ open: false })} haushaltsjahrId={selectedJahrId} existing={kontoDialog.existing} onSave={data => kontoDialog.existing ? updateKontoMut.mutate({ id: kontoDialog.existing.id, data }) : createKontoMut.mutate(data) } />}
)} {/* Sub-Tab 1: Bankkonten */} {subTab === 1 && ( {canManage && } Bezeichnung IBAN Institut Standard {canManage && Aktionen} {bankkonten.length === 0 && Keine Bankkonten} {bankkonten.map((bk: Bankkonto) => ( {bk.bezeichnung} {bk.iban || '–'} {bk.institut || '–'} {bk.ist_standard ? : '–'} {canManage && ( setBankDialog({ open: true, existing: bk })}> deleteBankMut.mutate(bk.id)}> )} ))}
setBankDialog({ open: false })} existing={bankDialog.existing} onSave={data => bankDialog.existing ? updateBankMut.mutate({ id: bankDialog.existing.id, data }) : createBankMut.mutate(data) } />
)} {/* Sub-Tab 2: Haushaltsjahre */} {subTab === 2 && ( {canManage && } Jahr Bezeichnung Beginn Ende Status {canManage && Aktionen} {haushaltsjahre.length === 0 && Keine Haushaltsjahre} {haushaltsjahre.map((hj: Haushaltsjahr) => ( {hj.jahr} {hj.bezeichnung} {fmtDate(hj.beginn)} {fmtDate(hj.ende)} {hj.abgeschlossen ? : } {canManage && ( setJahrDialog({ open: true, existing: hj })}> {!hj.abgeschlossen && ( closeJahrMut.mutate(hj.id)}> )} )} ))}
setJahrDialog({ open: false })} existing={jahrDialog.existing} onSave={data => jahrDialog.existing ? updateJahrMut.mutate({ id: jahrDialog.existing.id, data }) : createJahrMut.mutate(data) } />
)}
); } // ─── Main Page ──────────────────────────────────────────────────────────────── export default function Buchhaltung() { const location = useLocation(); const navigate = useNavigate(); const searchParams = new URLSearchParams(location.search); const tabFromUrl = parseInt(searchParams.get('tab') || '0', 10); const [tab, setTab] = useState(isNaN(tabFromUrl) ? 0 : tabFromUrl); const [selectedJahrId, setSelectedJahrId] = useState(null); const { data: haushaltsjahre = [] } = useQuery({ queryKey: ['haushaltsjahre'], queryFn: buchhaltungApi.getHaushaltsjahre, onSuccess: (data: Haushaltsjahr[]) => { if (data.length > 0 && !selectedJahrId) { const active = data.find(hj => !hj.abgeschlossen) || data[0]; setSelectedJahrId(active.id); } }, }); const handleTabChange = (_: React.SyntheticEvent, newVal: number) => { setTab(newVal); navigate(`/buchhaltung?tab=${newVal}`, { replace: true }); }; return ( Buchhaltung {tab === 0 && ( )} {tab === 1 && ( )} {tab === 2 && ( )} ); }