feat: add Buchhaltung module with fiscal years, budget accounts, transactions, and approval workflow

This commit is contained in:
Matthias Hochmeister
2026-03-28 19:48:32 +01:00
parent 4349de9bc9
commit 18b1300de8
14 changed files with 2791 additions and 1 deletions

View File

@@ -38,6 +38,7 @@ import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestel
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
import Checklisten from './pages/Checklisten';
import Buchhaltung from './pages/Buchhaltung';
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
import Issues from './pages/Issues';
@@ -370,6 +371,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/buchhaltung"
element={
<ProtectedRoute>
<Buchhaltung />
</ProtectedRoute>
}
/>
<Route
path="/checklisten/ausfuehrung/:id"
element={

View File

@@ -118,6 +118,11 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
'Verwaltung': ['manage_templates'],
'Widget': ['widget'],
},
buchhaltung: {
'Ansicht': ['view', 'widget'],
'Transaktionen': ['create', 'edit', 'delete', 'export'],
'Verwaltung': ['manage_accounts', 'manage_settings'],
},
admin: {
'Allgemein': ['view', 'write'],
'Services': ['view_services', 'edit_services'],

View File

@@ -30,6 +30,7 @@ import {
BookOnline,
Forum,
AssignmentTurnedIn,
AccountBalance as AccountBalanceIcon,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
@@ -143,6 +144,17 @@ const baseNavigationItems: NavigationItem[] = [
path: '/checklisten',
permission: 'checklisten:view',
},
{
text: 'Buchhaltung',
icon: <AccountBalanceIcon />,
path: '/buchhaltung',
subItems: [
{ text: 'Übersicht', path: '/buchhaltung?tab=0' },
{ text: 'Transaktionen', path: '/buchhaltung?tab=1' },
{ text: 'Konten', path: '/buchhaltung?tab=2' },
],
permission: 'buchhaltung:view',
},
{
text: 'Issues',
icon: <BugReport />,

View File

@@ -0,0 +1,846 @@
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>
);
}

View File

@@ -0,0 +1,129 @@
import { api } from './api';
import type {
Haushaltsjahr, HaushaltsjahrFormData,
Bankkonto, BankkontoFormData,
Konto, KontoFormData, KontoBudgetInfo,
KontoTyp,
Transaktion, TransaktionFormData, TransaktionFilters,
Beleg,
BuchhaltungStats,
} from '../types/buchhaltung.types';
export const buchhaltungApi = {
// ── Haushaltsjahre ──────────────────────────────────────────────────────────
getHaushaltsjahre: async (): Promise<Haushaltsjahr[]> => {
const r = await api.get('/api/buchhaltung/haushaltsjahre');
return r.data.data;
},
createHaushaltsjahr: async (data: HaushaltsjahrFormData): Promise<Haushaltsjahr> => {
const r = await api.post('/api/buchhaltung/haushaltsjahre', data);
return r.data.data;
},
updateHaushaltsjahr: async (id: number, data: Partial<HaushaltsjahrFormData>): Promise<Haushaltsjahr> => {
const r = await api.patch(`/api/buchhaltung/haushaltsjahre/${id}`, data);
return r.data.data;
},
closeHaushaltsjahr: async (id: number): Promise<Haushaltsjahr> => {
const r = await api.post(`/api/buchhaltung/haushaltsjahre/${id}/close`);
return r.data.data;
},
// ── Konto-Typen ─────────────────────────────────────────────────────────────
getKontoTypen: async (): Promise<KontoTyp[]> => {
const r = await api.get('/api/buchhaltung/konto-typen');
return r.data.data;
},
// ── Bankkonten ───────────────────────────────────────────────────────────────
getBankkonten: async (): Promise<Bankkonto[]> => {
const r = await api.get('/api/buchhaltung/bankkonten');
return r.data.data;
},
createBankkonto: async (data: BankkontoFormData): Promise<Bankkonto> => {
const r = await api.post('/api/buchhaltung/bankkonten', data);
return r.data.data;
},
updateBankkonto: async (id: number, data: Partial<BankkontoFormData>): Promise<Bankkonto> => {
const r = await api.patch(`/api/buchhaltung/bankkonten/${id}`, data);
return r.data.data;
},
deleteBankkonto: async (id: number): Promise<void> => {
await api.delete(`/api/buchhaltung/bankkonten/${id}`);
},
// ── Konten ───────────────────────────────────────────────────────────────────
getKonten: async (haushaltsjahrId: number): Promise<Konto[]> => {
const r = await api.get(`/api/buchhaltung/konten?haushaltsjahr_id=${haushaltsjahrId}`);
return r.data.data;
},
createKonto: async (data: KontoFormData): Promise<Konto> => {
const r = await api.post('/api/buchhaltung/konten', data);
return r.data.data;
},
updateKonto: async (id: number, data: Partial<KontoFormData>): Promise<Konto> => {
const r = await api.patch(`/api/buchhaltung/konten/${id}`, data);
return r.data.data;
},
deleteKonto: async (id: number): Promise<void> => {
await api.delete(`/api/buchhaltung/konten/${id}`);
},
getKontoBudget: async (id: number): Promise<KontoBudgetInfo> => {
const r = await api.get(`/api/buchhaltung/konten/${id}/budget`);
return r.data.data;
},
// ── Stats ────────────────────────────────────────────────────────────────────
getStats: async (haushaltsjahrId: number): Promise<BuchhaltungStats> => {
const r = await api.get(`/api/buchhaltung/stats?haushaltsjahr_id=${haushaltsjahrId}`);
return r.data.data;
},
// ── Transaktionen ─────────────────────────────────────────────────────────────
getTransaktionen: async (filters?: TransaktionFilters): Promise<Transaktion[]> => {
const params = new URLSearchParams();
if (filters?.haushaltsjahr_id) params.set('haushaltsjahr_id', String(filters.haushaltsjahr_id));
if (filters?.konto_id) params.set('konto_id', String(filters.konto_id));
if (filters?.status) params.set('status', filters.status);
if (filters?.typ) params.set('typ', filters.typ);
if (filters?.datum_von) params.set('datum_von', filters.datum_von);
if (filters?.datum_bis) params.set('datum_bis', filters.datum_bis);
if (filters?.search) params.set('search', filters.search);
const r = await api.get(`/api/buchhaltung?${params.toString()}`);
return r.data.data;
},
getTransaktion: async (id: number): Promise<Transaktion> => {
const r = await api.get(`/api/buchhaltung/${id}`);
return r.data.data;
},
createTransaktion: async (data: TransaktionFormData): Promise<Transaktion> => {
const r = await api.post('/api/buchhaltung', data);
return r.data.data;
},
updateTransaktion: async (id: number, data: Partial<TransaktionFormData>): Promise<Transaktion> => {
const r = await api.patch(`/api/buchhaltung/${id}`, data);
return r.data.data;
},
deleteTransaktion: async (id: number): Promise<void> => {
await api.delete(`/api/buchhaltung/${id}`);
},
buchenTransaktion: async (id: number): Promise<Transaktion> => {
const r = await api.post(`/api/buchhaltung/${id}/buchen`);
return r.data.data;
},
stornoTransaktion: async (id: number): Promise<Transaktion> => {
const r = await api.post(`/api/buchhaltung/${id}/storno`);
return r.data.data;
},
uploadBeleg: async (transaktionId: number, file: File): Promise<Beleg> => {
const formData = new FormData();
formData.append('datei', file);
const r = await api.post(`/api/buchhaltung/${transaktionId}/belege`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return r.data.data;
},
deleteBeleg: async (id: number): Promise<void> => {
await api.delete(`/api/buchhaltung/belege/${id}`);
},
};

View File

@@ -0,0 +1,229 @@
// Lookup types
export type KontoArt = 'einnahme' | 'ausgabe' | 'vermoegen' | 'verbindlichkeit';
export type TransaktionTyp = 'einnahme' | 'ausgabe';
export type TransaktionStatus = 'entwurf' | 'gebucht' | 'freigegeben' | 'storniert';
export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt';
export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
export type PlanungStatus = 'entwurf' | 'aktiv' | 'abgeschlossen';
// Label maps
export const TRANSAKTION_STATUS_LABELS: Record<TransaktionStatus, string> = {
entwurf: 'Entwurf',
gebucht: 'Gebucht',
freigegeben: 'Freigegeben',
storniert: 'Storniert',
};
export const TRANSAKTION_STATUS_COLORS: Record<TransaktionStatus, 'default' | 'warning' | 'success' | 'error'> = {
entwurf: 'default',
gebucht: 'warning',
freigegeben: 'success',
storniert: 'error',
};
export const TRANSAKTION_TYP_LABELS: Record<TransaktionTyp, string> = {
einnahme: 'Einnahme',
ausgabe: 'Ausgabe',
};
export const KONTO_ART_LABELS: Record<KontoArt, string> = {
einnahme: 'Einnahmen',
ausgabe: 'Ausgaben',
vermoegen: 'Vermögen',
verbindlichkeit: 'Verbindlichkeiten',
};
export const INTERVALL_LABELS: Record<WiederkehrendIntervall, string> = {
monatlich: 'Monatlich',
quartalsweise: 'Quartalsweise',
halbjaehrlich: 'Halbjährlich',
jaehrlich: 'Jährlich',
};
// Entities
export interface KontoTyp {
id: number;
bezeichnung: string;
art: KontoArt;
sort_order: number;
}
export interface Bankkonto {
id: number;
bezeichnung: string;
iban: string | null;
bic: string | null;
institut: string | null;
ist_standard: boolean;
aktiv: boolean;
erstellt_von: string | null;
erstellt_am: string;
aktualisiert_am: string;
}
export interface Haushaltsjahr {
id: number;
jahr: number;
bezeichnung: string;
beginn: string;
ende: string;
abgeschlossen: boolean;
erstellt_von: string | null;
erstellt_am: string;
aktualisiert_am: string;
}
export interface Konto {
id: number;
haushaltsjahr_id: number;
konto_typ_id: number | null;
kontonummer: string;
bezeichnung: string;
budget_betrag: number;
notizen: string | null;
aktiv: boolean;
erstellt_von: string | null;
erstellt_am: string;
aktualisiert_am: string;
// Joined fields
konto_typ_bezeichnung?: string;
konto_typ_art?: KontoArt;
}
export interface KontoBudgetInfo extends Konto {
gebucht_betrag: number;
ausstehend_betrag: number;
verfuegbar_betrag: number;
auslastung_prozent: number;
}
export interface Transaktion {
id: number;
haushaltsjahr_id: number;
konto_id: number | null;
bankkonto_id: number | null;
laufende_nummer: number | null;
typ: TransaktionTyp;
betrag: number;
datum: string;
buchungsdatum: string | null;
beschreibung: string | null;
empfaenger_auftraggeber: string | null;
verwendungszweck: string | null;
beleg_nr: string | null;
status: TransaktionStatus;
bestellung_id: number | null;
erstellt_von: string | null;
gebucht_von: string | null;
erstellt_am: string;
aktualisiert_am: string;
// Joined fields
konto_bezeichnung?: string;
konto_kontonummer?: string;
bankkonto_bezeichnung?: string;
belege?: Beleg[];
}
export interface Beleg {
id: number;
transaktion_id: number;
dateiname: string;
original_name: string;
dateityp: string;
dateigroesse: number;
erstellt_von: string | null;
erstellt_am: string;
}
export interface Freigabe {
id: number;
transaktion_id: number;
status: FreigabeStatus;
kommentar: string | null;
freigegeben_von: string | null;
freigegeben_am: string | null;
erstellt_am: string;
}
export interface WiederkehrendBuchung {
id: number;
bezeichnung: string;
konto_id: number | null;
bankkonto_id: number | null;
typ: TransaktionTyp;
betrag: number;
beschreibung: string | null;
empfaenger_auftraggeber: string | null;
intervall: WiederkehrendIntervall;
naechste_ausfuehrung: string;
aktiv: boolean;
erstellt_von: string | null;
erstellt_am: string;
aktualisiert_am: string;
}
export interface BuchhaltungAudit {
id: number;
transaktion_id: number | null;
aktion: string;
details: Record<string, unknown> | null;
erstellt_von: string | null;
erstellt_am: string;
}
export interface BuchhaltungStats {
haushaltsjahr_id: number;
total_einnahmen: number;
total_ausgaben: number;
saldo: number;
konten_budget: KontoBudgetInfo[];
}
// Form data types
export interface HaushaltsjahrFormData {
jahr: number;
bezeichnung: string;
beginn: string;
ende: string;
}
export interface BankkontoFormData {
bezeichnung: string;
iban?: string;
bic?: string;
institut?: string;
ist_standard?: boolean;
}
export interface KontoFormData {
haushaltsjahr_id: number;
konto_typ_id?: number;
kontonummer: string;
bezeichnung: string;
budget_betrag?: number;
notizen?: string;
}
export interface TransaktionFormData {
haushaltsjahr_id: number;
konto_id?: number | null;
bankkonto_id?: number | null;
typ: TransaktionTyp;
betrag: number;
datum: string;
beschreibung?: string;
empfaenger_auftraggeber?: string;
verwendungszweck?: string;
beleg_nr?: string;
}
// Filter type for transaction list
export interface TransaktionFilters {
haushaltsjahr_id?: number;
konto_id?: number;
status?: TransaktionStatus;
typ?: TransaktionTyp;
datum_von?: string;
datum_bis?: string;
search?: string;
}