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

847 lines
38 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, 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<HaushaltsjahrFormData>({
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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{existing ? 'Haushaltsjahr bearbeiten' : 'Neues Haushaltsjahr'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Jahr" type="number" value={form.jahr} onChange={e => setForm(f => ({ ...f, jahr: parseInt(e.target.value, 10) }))} />
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} />
<TextField label="Beginn" type="date" value={form.beginn} onChange={e => setForm(f => ({ ...f, beginn: e.target.value }))} InputLabelProps={{ shrink: true }} />
<TextField label="Ende" type="date" value={form.ende} onChange={e => setForm(f => ({ ...f, ende: e.target.value }))} InputLabelProps={{ shrink: true }} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abbrechen</Button>
<Button variant="contained" onClick={() => onSave(form)}>Speichern</Button>
</DialogActions>
</Dialog>
);
}
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<BankkontoFormData>(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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{existing ? 'Bankkonto bearbeiten' : 'Neues Bankkonto'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
<TextField label="IBAN" value={form.iban} onChange={e => setForm(f => ({ ...f, iban: e.target.value }))} />
<TextField label="BIC" value={form.bic} onChange={e => setForm(f => ({ ...f, bic: e.target.value }))} />
<TextField label="Institut" value={form.institut} onChange={e => setForm(f => ({ ...f, institut: e.target.value }))} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abbrechen</Button>
<Button variant="contained" onClick={() => onSave(form)}>Speichern</Button>
</DialogActions>
</Dialog>
);
}
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<KontoFormData>(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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{existing ? 'Konto bearbeiten' : 'Neues Konto'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Kontonummer" value={form.kontonummer} onChange={e => setForm(f => ({ ...f, kontonummer: e.target.value }))} required />
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
<FormControl fullWidth>
<InputLabel>Kontotyp</InputLabel>
<Select value={form.konto_typ_id ?? ''} label="Kontotyp" onChange={e => setForm(f => ({ ...f, konto_typ_id: e.target.value ? Number(e.target.value) : undefined }))}>
<MenuItem value=""><em>Kein Typ</em></MenuItem>
{kontoTypen.map(kt => <MenuItem key={kt.id} value={kt.id}>{kt.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
<TextField label="Budget (€)" type="number" value={form.budget_betrag} onChange={e => setForm(f => ({ ...f, budget_betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01' }} />
<TextField label="Notizen" value={form.notizen} onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abbrechen</Button>
<Button variant="contained" onClick={() => onSave(form)}>Speichern</Button>
</DialogActions>
</Dialog>
);
}
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<TransaktionFormData>({
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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Neue Transaktion</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), konto_id: null }))}>
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel>Typ</InputLabel>
<Select value={form.typ} label="Typ" onChange={e => setForm(f => ({ ...f, typ: e.target.value as 'einnahme' | 'ausgabe' }))}>
<MenuItem value="ausgabe">Ausgabe</MenuItem>
<MenuItem value="einnahme">Einnahme</MenuItem>
</Select>
</FormControl>
<TextField label="Betrag (€)" 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 />
<FormControl fullWidth>
<InputLabel>Konto</InputLabel>
<Select value={form.konto_id ?? ''} label="Konto" onChange={e => setForm(f => ({ ...f, konto_id: e.target.value ? Number(e.target.value) : null }))}>
<MenuItem value=""><em>Kein Konto</em></MenuItem>
{konten.map(k => <MenuItem key={k.id} value={k.id}>{k.kontonummer} {k.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Bankkonto</InputLabel>
<Select value={form.bankkonto_id ?? ''} label="Bankkonto" onChange={e => setForm(f => ({ ...f, bankkonto_id: e.target.value ? Number(e.target.value) : null }))}>
<MenuItem value=""><em>Kein Bankkonto</em></MenuItem>
{bankkonten.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>)}
</Select>
</FormControl>
<TextField label="Beschreibung" value={form.beschreibung} onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))} />
<TextField label="Empfänger/Auftraggeber" value={form.empfaenger_auftraggeber} onChange={e => setForm(f => ({ ...f, empfaenger_auftraggeber: e.target.value }))} />
<TextField label="Verwendungszweck" value={form.verwendungszweck} onChange={e => setForm(f => ({ ...f, verwendungszweck: e.target.value }))} />
<TextField label="Belegnummer" 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" onClick={() => onSave(form)} disabled={!form.haushaltsjahr_id || !form.betrag || !form.datum}>Erstellen</Button>
</DialogActions>
</Dialog>
);
}
// ─── 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 (
<Box>
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControl sx={{ minWidth: 240 }}>
<InputLabel>Haushaltsjahr</InputLabel>
<Select value={selectedJahrId ?? ''} label="Haushaltsjahr" onChange={e => onJahrChange(Number(e.target.value))}>
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}{hj.abgeschlossen ? ' (abgeschlossen)' : ''}</MenuItem>)}
</Select>
</FormControl>
</Box>
{isLoading && <CircularProgress />}
{stats && (
<>
<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(stats.total_einnahmen)}</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography color="text.secondary" variant="body2">Ausgaben</Typography>
<Typography variant="h5" color="error.main">{fmtEur(stats.total_ausgaben)}</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography color="text.secondary" variant="body2">Saldo</Typography>
<Typography variant="h5" color={stats.saldo >= 0 ? 'success.main' : 'error.main'}>{fmtEur(stats.saldo)}</Typography>
</CardContent>
</Card>
</Box>
<Typography variant="h6" gutterBottom>Konten</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2 }}>
{stats.konten_budget.map(k => (
<Card key={k.id}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600}>{k.kontonummer} {k.bezeichnung}</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>{k.konto_typ_bezeichnung || ''}</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2">Gebucht: {fmtEur(k.gebucht_betrag)}</Typography>
<Typography variant="body2">Budget: {fmtEur(k.budget_betrag)}</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(k.auslastung_prozent, 100)}
color={k.auslastung_prozent >= 90 ? 'error' : k.auslastung_prozent >= 75 ? 'warning' : 'primary'}
sx={{ height: 8, borderRadius: 4 }}
/>
<Typography variant="caption" color={k.auslastung_prozent >= 90 ? 'error' : 'text.secondary'}>
{k.auslastung_prozent}% · Verfügbar: {fmtEur(k.verfuegbar_betrag)}
</Typography>
</CardContent>
</Card>
))}
</Box>
</>
)}
{!selectedJahrId && !isLoading && (
<Typography color="text.secondary">Bitte ein Haushaltsjahr auswählen</Typography>
)}
</Box>
);
}
// ─── 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<TransaktionFilters>({ 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 (
<Box>
{/* Filters */}
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>Haushaltsjahr</InputLabel>
<Select size="small" value={filters.haushaltsjahr_id ?? ''} label="Haushaltsjahr"
onChange={e => { const v = Number(e.target.value); setFilters(f => ({ ...f, haushaltsjahr_id: v || undefined })); onJahrChange(v); }}>
<MenuItem value=""><em>Alle</em></MenuItem>
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 140 }}>
<InputLabel>Status</InputLabel>
<Select size="small" value={filters.status ?? ''} label="Status"
onChange={e => setFilters(f => ({ ...f, status: (e.target.value as TransaktionStatus) || undefined }))}>
<MenuItem value=""><em>Alle</em></MenuItem>
{(Object.entries(TRANSAKTION_STATUS_LABELS) as [TransaktionStatus, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
</Select>
</FormControl>
<FormControl 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 }))}>
<MenuItem value=""><em>Alle</em></MenuItem>
<MenuItem value="einnahme">Einnahme</MenuItem>
<MenuItem value="ausgabe">Ausgabe</MenuItem>
</Select>
</FormControl>
<TextField size="small" label="Suche" value={filters.search ?? ''}
onChange={e => setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} />
</Box>
{isLoading ? <CircularProgress /> : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Nr.</TableCell>
<TableCell>Datum</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Konto</TableCell>
<TableCell align="right">Betrag</TableCell>
<TableCell>Status</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{transaktionen.length === 0 && (
<TableRow><TableCell colSpan={8} align="center"><Typography color="text.secondary">Keine Transaktionen</Typography></TableCell></TableRow>
)}
{transaktionen.map((t: Transaktion) => (
<TableRow key={t.id} hover>
<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'} />
</TableCell>
<TableCell>{t.beschreibung || t.empfaenger_auftraggeber || ''}</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>
<TableCell>
<Chip label={TRANSAKTION_STATUS_LABELS[t.status]} size="small" color={TRANSAKTION_STATUS_COLORS[t.status]} />
</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5}>
{t.status === 'entwurf' && hasPermission('buchhaltung:edit') && (
<Tooltip title="Buchen">
<IconButton size="small" color="primary" onClick={() => buchenMut.mutate(t.id)}>
<BookmarkAdd fontSize="small" />
</IconButton>
</Tooltip>
)}
{(t.status === 'gebucht' || t.status === 'freigegeben') && hasPermission('buchhaltung:edit') && (
<Tooltip title="Stornieren">
<IconButton size="small" color="warning" onClick={() => stornoMut.mutate(t.id)}>
<Cancel fontSize="small" />
</IconButton>
</Tooltip>
)}
{t.status === 'entwurf' && hasPermission('buchhaltung:delete') && (
<Tooltip title="Löschen">
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(t.id)}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{hasPermission('buchhaltung:create') && (
<Fab color="primary" sx={{ position: 'fixed', bottom: 32, right: 80 }} onClick={() => setCreateOpen(true)}>
<AddIcon />
</Fab>
)}
<TransaktionDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onSave={data => createMut.mutate(data)}
/>
</Box>
);
}
// ─── 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<KontoFormData> }) => 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<BankkontoFormData> }) => 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<HaushaltsjahrFormData> }) => 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 (
<Box>
<Tabs value={subTab} onChange={(_, v) => setSubTab(v)} sx={{ mb: 2 }}>
<Tab label="Konten" />
<Tab label="Bankkonten" />
<Tab label="Haushaltsjahre" />
</Tabs>
{/* Sub-Tab 0: Konten */}
{subTab === 0 && (
<Box>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<FormControl sx={{ minWidth: 240 }}>
<InputLabel>Haushaltsjahr</InputLabel>
<Select size="small" value={selectedJahrId ?? ''} label="Haushaltsjahr" onChange={e => onJahrChange(Number(e.target.value))}>
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setKontoDialog({ open: true })}>Konto anlegen</Button>}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Kontonummer</TableCell>
<TableCell>Bezeichnung</TableCell>
<TableCell>Typ</TableCell>
<TableCell align="right">Budget</TableCell>
{canManage && <TableCell>Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{konten.length === 0 && <TableRow><TableCell colSpan={5} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>}
{konten.map((k: Konto) => (
<TableRow key={k.id} hover>
<TableCell>{k.kontonummer}</TableCell>
<TableCell>{k.bezeichnung}</TableCell>
<TableCell>{k.konto_typ_bezeichnung || ''}</TableCell>
<TableCell align="right">{fmtEur(k.budget_betrag)}</TableCell>
{canManage && (
<TableCell>
<IconButton size="small" onClick={() => setKontoDialog({ open: true, existing: k })}><Edit fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteKontoMut.mutate(k.id)}><Delete fontSize="small" /></IconButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{selectedJahrId && <KontoDialog
open={kontoDialog.open}
onClose={() => setKontoDialog({ open: false })}
haushaltsjahrId={selectedJahrId}
existing={kontoDialog.existing}
onSave={data => kontoDialog.existing
? updateKontoMut.mutate({ id: kontoDialog.existing.id, data })
: createKontoMut.mutate(data)
}
/>}
</Box>
)}
{/* Sub-Tab 1: Bankkonten */}
{subTab === 1 && (
<Box>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setBankDialog({ open: true })}>Bankkonto anlegen</Button>}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>IBAN</TableCell>
<TableCell>Institut</TableCell>
<TableCell>Standard</TableCell>
{canManage && <TableCell>Aktionen</TableCell>}
</TableRow>
</TableHead>
<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>
<TableCell>{bk.bezeichnung}</TableCell>
<TableCell>{bk.iban || ''}</TableCell>
<TableCell>{bk.institut || ''}</TableCell>
<TableCell>{bk.ist_standard ? <Chip label="Standard" size="small" color="primary" /> : ''}</TableCell>
{canManage && (
<TableCell>
<IconButton size="small" onClick={() => setBankDialog({ open: true, existing: bk })}><Edit fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deleteBankMut.mutate(bk.id)}><Delete fontSize="small" /></IconButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<BankkontoDialog
open={bankDialog.open}
onClose={() => setBankDialog({ open: false })}
existing={bankDialog.existing}
onSave={data => bankDialog.existing
? updateBankMut.mutate({ id: bankDialog.existing.id, data })
: createBankMut.mutate(data)
}
/>
</Box>
)}
{/* Sub-Tab 2: Haushaltsjahre */}
{subTab === 2 && (
<Box>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setJahrDialog({ open: true })}>Haushaltsjahr anlegen</Button>}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Jahr</TableCell>
<TableCell>Bezeichnung</TableCell>
<TableCell>Beginn</TableCell>
<TableCell>Ende</TableCell>
<TableCell>Status</TableCell>
{canManage && <TableCell>Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{haushaltsjahre.length === 0 && <TableRow><TableCell colSpan={6} align="center"><Typography color="text.secondary">Keine Haushaltsjahre</Typography></TableCell></TableRow>}
{haushaltsjahre.map((hj: Haushaltsjahr) => (
<TableRow key={hj.id} hover>
<TableCell>{hj.jahr}</TableCell>
<TableCell>{hj.bezeichnung}</TableCell>
<TableCell>{fmtDate(hj.beginn)}</TableCell>
<TableCell>{fmtDate(hj.ende)}</TableCell>
<TableCell>{hj.abgeschlossen ? <Chip label="Abgeschlossen" size="small" color="default" /> : <Chip label="Aktiv" size="small" color="success" />}</TableCell>
{canManage && (
<TableCell>
<IconButton size="small" onClick={() => setJahrDialog({ open: true, existing: hj })}><Edit fontSize="small" /></IconButton>
{!hj.abgeschlossen && (
<Tooltip title="Abschließen">
<IconButton size="small" color="warning" onClick={() => closeJahrMut.mutate(hj.id)}><Lock fontSize="small" /></IconButton>
</Tooltip>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<HaushaltsjahrDialog
open={jahrDialog.open}
onClose={() => setJahrDialog({ open: false })}
existing={jahrDialog.existing}
onSave={data => jahrDialog.existing
? updateJahrMut.mutate({ id: jahrDialog.existing.id, data })
: createJahrMut.mutate(data)
}
/>
</Box>
)}
</Box>
);
}
// ─── 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<number | null>(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 (
<MainLayout>
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2 }}>
<Typography variant="h4" fontWeight={700}>Buchhaltung</Typography>
</Box>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 3 }}>
<Tab label="Übersicht" />
<Tab label="Transaktionen" />
<Tab label="Konten" />
</Tabs>
{tab === 0 && (
<UebersichtTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
)}
{tab === 1 && (
<TransaktionenTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
)}
{tab === 2 && (
<KontenTab
haushaltsjahre={haushaltsjahre}
selectedJahrId={selectedJahrId}
onJahrChange={setSelectedJahrId}
/>
)}
</Box>
</MainLayout>
);
}