847 lines
38 KiB
TypeScript
847 lines
38 KiB
TypeScript
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>
|
||
);
|
||
}
|