feat(buchhaltung): add transfers, bank statements, Haushaltsplan, and PDF export

This commit is contained in:
Matthias Hochmeister
2026-03-30 17:05:18 +02:00
parent 2eb59e9ff1
commit 5acfd7cc4f
14 changed files with 1911 additions and 10 deletions

View File

@@ -41,6 +41,9 @@ import Checklisten from './pages/Checklisten';
import Buchhaltung from './pages/Buchhaltung';
import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail';
import BuchhaltungKontoManage from './pages/BuchhaltungKontoManage';
import BuchhaltungBankkontoDetail from './pages/BuchhaltungBankkontoDetail';
import Haushaltsplan from './pages/Haushaltsplan';
import HaushaltsplanDetail from './pages/HaushaltsplanDetail';
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
import Issues from './pages/Issues';
@@ -397,6 +400,30 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/buchhaltung/bankkonto/:id"
element={
<ProtectedRoute>
<BuchhaltungBankkontoDetail />
</ProtectedRoute>
}
/>
<Route
path="/haushaltsplan"
element={
<ProtectedRoute>
<Haushaltsplan />
</ProtectedRoute>
}
/>
<Route
path="/haushaltsplan/:id"
element={
<ProtectedRoute>
<HaushaltsplanDetail />
</ProtectedRoute>
}
/>
<Route
path="/checklisten/ausfuehrung/:id"
element={

View File

@@ -152,6 +152,7 @@ const baseNavigationItems: NavigationItem[] = [
{ text: 'Übersicht', path: '/buchhaltung?tab=0' },
{ text: 'Transaktionen', path: '/buchhaltung?tab=1' },
{ text: 'Konten', path: '/buchhaltung?tab=2' },
{ text: 'Haushaltspläne', path: '/haushaltsplan' },
],
permission: 'buchhaltung:view',
},

View File

@@ -1356,6 +1356,11 @@ export default function BestellungDetail() {
Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs.
</Typography>
)}
{statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && (
<Alert severity="warning" sx={{ mt: 2 }}>
Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern.
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>Abbrechen</Button>

View File

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

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import {
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { ArrowBack } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { buchhaltungApi } from '../services/buchhaltung';
import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types';
import type { BankkontoStatementRow } from '../types/buchhaltung.types';
function fmtEur(val: number) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
}
function fmtDate(val: string) {
return new Date(val).toLocaleDateString('de-DE');
}
export default function BuchhaltungBankkontoDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const bankkontoId = Number(id);
const [von, setVon] = useState('');
const [bis, setBis] = useState('');
const [appliedVon, setAppliedVon] = useState('');
const [appliedBis, setAppliedBis] = useState('');
const { data, isLoading, isError } = useQuery({
queryKey: ['bankkonto-statement', bankkontoId, appliedVon, appliedBis],
queryFn: () => buchhaltungApi.getBankkontoStatement(bankkontoId, {
von: appliedVon || undefined,
bis: appliedBis || undefined,
}),
enabled: !!bankkontoId,
});
const handleApply = () => {
setAppliedVon(von);
setAppliedBis(bis);
};
return (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Tooltip title="Zurueck">
<IconButton onClick={() => navigate('/buchhaltung?tab=2')}>
<ArrowBack />
</IconButton>
</Tooltip>
<Typography variant="h4" sx={{ flexGrow: 1 }}>
{data?.bankkonto?.bezeichnung ?? 'Bankkonto'}
{data?.bankkonto?.iban && (
<Typography component="span" variant="body1" color="text.secondary" sx={{ ml: 2 }}>
{data.bankkonto.iban}
</Typography>
)}
</Typography>
</Box>
{/* Date range filter */}
<Paper variant="outlined" sx={{ p: 1.5, mb: 3 }}>
<Stack direction="row" spacing={2} alignItems="center">
<TextField
size="small"
label="Von"
type="date"
value={von}
onChange={e => setVon(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
size="small"
label="Bis"
type="date"
value={bis}
onChange={e => setBis(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button variant="contained" size="small" onClick={handleApply}>
Anwenden
</Button>
</Stack>
</Paper>
{isLoading && <CircularProgress />}
{isError && <Typography color="error">Fehler beim Laden der Kontodaten.</Typography>}
{data && !isLoading && (
<>
{/* Summary cards */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mb: 3 }}>
<Card>
<CardContent>
<Typography color="text.secondary" variant="body2">Einnahmen</Typography>
<Typography variant="h5" color="success.main">{fmtEur(data.einnahmen)}</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography color="text.secondary" variant="body2">Ausgaben</Typography>
<Typography variant="h5" color="error.main">{fmtEur(data.ausgaben)}</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography color="text.secondary" variant="body2">Saldo</Typography>
<Typography variant="h5" color={data.saldo >= 0 ? 'success.main' : 'error.main'}>{fmtEur(data.saldo)}</Typography>
</CardContent>
</Card>
</Box>
{/* Statement table */}
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Datum</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell>Beleg-Nr.</TableCell>
<TableCell align="right">Betrag</TableCell>
<TableCell align="right">Laufender Saldo</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography color="text.secondary">Keine Transaktionen im Zeitraum</Typography>
</TableCell>
</TableRow>
)}
{data.rows.map((row: BankkontoStatementRow) => {
const isEinnahme = row.typ === 'einnahme';
const isTransfer = row.typ === 'transfer';
return (
<TableRow key={row.id} hover>
<TableCell>{fmtDate(row.datum)}</TableCell>
<TableCell>
<Chip
label={isTransfer ? `Transfer${row.transfer_ziel_bezeichnung ? ' \u2192 ' + row.transfer_ziel_bezeichnung : ''}` : TRANSAKTION_TYP_LABELS[row.typ]}
size="small"
color={isEinnahme ? 'success' : isTransfer ? 'info' : 'error'}
/>
</TableCell>
<TableCell>{row.beschreibung || '\u2013'}</TableCell>
<TableCell>{row.beleg_nr || '\u2013'}</TableCell>
<TableCell
align="right"
sx={{
color: isEinnahme ? 'success.main' : isTransfer ? 'info.main' : 'error.main',
fontWeight: 600,
}}
>
{row.typ === 'ausgabe' ? '-' : row.typ === 'einnahme' ? '+' : ''}{fmtEur(row.betrag)}
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>
{fmtEur(row.laufender_saldo)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</>
)}
</DashboardLayout>
);
}

View File

@@ -0,0 +1,163 @@
import { useState } from 'react';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon, Delete, Visibility } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { buchhaltungApi } from '../services/buchhaltung';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import type { Planung, PlanungStatus, Haushaltsjahr } from '../types/buchhaltung.types';
const STATUS_LABELS: Record<PlanungStatus, string> = {
entwurf: 'Entwurf',
aktiv: 'Aktiv',
abgeschlossen: 'Abgeschlossen',
};
const STATUS_COLORS: Record<PlanungStatus, 'default' | 'success' | 'info'> = {
entwurf: 'default',
aktiv: 'success',
abgeschlossen: 'info',
};
function fmtDate(val: string) {
return new Date(val).toLocaleDateString('de-DE');
}
function fmtEur(val: number) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
}
export default function Haushaltsplan() {
const navigate = useNavigate();
const qc = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const canManage = hasPermission('buchhaltung:manage_accounts');
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<{ bezeichnung: string; haushaltsjahr_id: string }>({ bezeichnung: '', haushaltsjahr_id: '' });
const { data: planungen = [], isLoading } = useQuery({
queryKey: ['planungen'],
queryFn: buchhaltungApi.listPlanungen,
});
const { data: haushaltsjahre = [] } = useQuery({
queryKey: ['haushaltsjahre'],
queryFn: buchhaltungApi.getHaushaltsjahre,
});
const createMut = useMutation({
mutationFn: buchhaltungApi.createPlanung,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planungen'] }); setDialogOpen(false); showSuccess('Haushaltsplan erstellt'); },
onError: () => showError('Haushaltsplan konnte nicht erstellt werden'),
});
const deleteMut = useMutation({
mutationFn: buchhaltungApi.deletePlanung,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planungen'] }); showSuccess('Haushaltsplan gelöscht'); },
onError: () => showError('Haushaltsplan konnte nicht gelöscht werden'),
});
return (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>Haushaltspläne</Typography>
{canManage && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setForm({ bezeichnung: '', haushaltsjahr_id: '' }); setDialogOpen(true); }}>
Neuer Haushaltsplan
</Button>
)}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Haushaltsjahr</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Positionen</TableCell>
<TableCell align="right">Gesamt (GWG + Anl. + Inst.)</TableCell>
<TableCell>Erstellt am</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!isLoading && planungen.length === 0 && (
<TableRow><TableCell colSpan={7} align="center"><Typography color="text.secondary">Keine Haushaltspläne vorhanden</Typography></TableCell></TableRow>
)}
{planungen.map((p: Planung) => {
const total = Number(p.total_gwg) + Number(p.total_anlagen) + Number(p.total_instandhaltung);
return (
<TableRow key={p.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/haushaltsplan/${p.id}`)}>
<TableCell>{p.bezeichnung}</TableCell>
<TableCell>{p.haushaltsjahr_bezeichnung || ''}</TableCell>
<TableCell><Chip label={STATUS_LABELS[p.status] || p.status} size="small" color={STATUS_COLORS[p.status] || 'default'} /></TableCell>
<TableCell align="right">{p.positionen_count}</TableCell>
<TableCell align="right">{fmtEur(total)}</TableCell>
<TableCell>{fmtDate(p.erstellt_am)}</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<IconButton size="small" onClick={() => navigate(`/haushaltsplan/${p.id}`)}><Visibility fontSize="small" /></IconButton>
{canManage && <IconButton size="small" color="error" onClick={() => deleteMut.mutate(p.id)}><Delete fontSize="small" /></IconButton>}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neuer Haushaltsplan</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
<FormControl fullWidth>
<InputLabel>Haushaltsjahr (optional)</InputLabel>
<Select value={form.haushaltsjahr_id} label="Haushaltsjahr (optional)" onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: e.target.value as string }))}>
<MenuItem value="">Kein Haushaltsjahr</MenuItem>
{haushaltsjahre.map((hj: Haushaltsjahr) => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.bezeichnung.trim()}
onClick={() => createMut.mutate({
bezeichnung: form.bezeichnung.trim(),
haushaltsjahr_id: form.haushaltsjahr_id ? Number(form.haushaltsjahr_id) : undefined,
})}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,271 @@
import { useState } from 'react';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import {
Alert,
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon, ArrowBack, Delete, Edit, PlaylistAdd } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { buchhaltungApi } from '../services/buchhaltung';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import type { Konto, Planposition, PlanungStatus } from '../types/buchhaltung.types';
const STATUS_LABELS: Record<PlanungStatus, string> = {
entwurf: 'Entwurf',
aktiv: 'Aktiv',
abgeschlossen: 'Abgeschlossen',
};
const STATUS_COLORS: Record<PlanungStatus, 'default' | 'success' | 'info'> = {
entwurf: 'default',
aktiv: 'success',
abgeschlossen: 'info',
};
function fmtEur(val: number) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
}
export default function HaushaltsplanDetail() {
const { id } = useParams<{ id: string }>();
const planungId = Number(id);
const navigate = useNavigate();
const qc = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const canManage = hasPermission('buchhaltung:manage_accounts');
const [posDialog, setPosDialog] = useState<{ open: boolean; existing?: Planposition }>({ open: false });
const [posForm, setPosForm] = useState<{ konto_id: string; bezeichnung: string; budget_gwg: string; budget_anlagen: string; budget_instandhaltung: string; notizen: string }>({
konto_id: '', bezeichnung: '', budget_gwg: '0', budget_anlagen: '0', budget_instandhaltung: '0', notizen: '',
});
const { data: planung, isLoading, isError } = useQuery({
queryKey: ['planung', planungId],
queryFn: () => buchhaltungApi.getPlanung(planungId),
enabled: !isNaN(planungId),
});
// Load konten for the linked haushaltsjahr (for Konto dropdown in position dialog)
const { data: konten = [] } = useQuery({
queryKey: ['buchhaltung-konten', planung?.haushaltsjahr_id],
queryFn: () => buchhaltungApi.getKonten(planung!.haushaltsjahr_id!),
enabled: !!planung?.haushaltsjahr_id,
});
const createPosMut = useMutation({
mutationFn: (data: Parameters<typeof buchhaltungApi.createPlanposition>[1]) => buchhaltungApi.createPlanposition(planungId, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); setPosDialog({ open: false }); showSuccess('Position hinzugefügt'); },
onError: () => showError('Position konnte nicht erstellt werden'),
});
const updatePosMut = useMutation({
mutationFn: ({ posId, data }: { posId: number; data: Parameters<typeof buchhaltungApi.updatePlanposition>[1] }) => buchhaltungApi.updatePlanposition(posId, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); setPosDialog({ open: false }); showSuccess('Position aktualisiert'); },
onError: () => showError('Position konnte nicht aktualisiert werden'),
});
const deletePosMut = useMutation({
mutationFn: buchhaltungApi.deletePlanposition,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); showSuccess('Position gelöscht'); },
onError: () => showError('Position konnte nicht gelöscht werden'),
});
const createHjMut = useMutation({
mutationFn: () => buchhaltungApi.createHaushaltsjahrFromPlan(planungId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['planung', planungId] });
qc.invalidateQueries({ queryKey: ['haushaltsjahre'] });
qc.invalidateQueries({ queryKey: ['planungen'] });
showSuccess('Haushaltsjahr aus Plan erstellt');
},
onError: (err: any) => showError(err?.response?.data?.message || 'Haushaltsjahr konnte nicht erstellt werden'),
});
const openAddDialog = () => {
setPosForm({ konto_id: '', bezeichnung: '', budget_gwg: '0', budget_anlagen: '0', budget_instandhaltung: '0', notizen: '' });
setPosDialog({ open: true });
};
const openEditDialog = (pos: Planposition) => {
setPosForm({
konto_id: pos.konto_id ? String(pos.konto_id) : '',
bezeichnung: pos.bezeichnung,
budget_gwg: String(pos.budget_gwg),
budget_anlagen: String(pos.budget_anlagen),
budget_instandhaltung: String(pos.budget_instandhaltung),
notizen: pos.notizen || '',
});
setPosDialog({ open: true, existing: pos });
};
const handlePosSave = () => {
const data = {
konto_id: posForm.konto_id ? Number(posForm.konto_id) : null,
bezeichnung: posForm.bezeichnung.trim(),
budget_gwg: parseFloat(posForm.budget_gwg) || 0,
budget_anlagen: parseFloat(posForm.budget_anlagen) || 0,
budget_instandhaltung: parseFloat(posForm.budget_instandhaltung) || 0,
notizen: posForm.notizen.trim() || undefined,
};
if (posDialog.existing) {
updatePosMut.mutate({ posId: posDialog.existing.id, data });
} else {
createPosMut.mutate(data);
}
};
if (isLoading) return <DashboardLayout><Typography>Laden...</Typography></DashboardLayout>;
if (isError || !planung) return <DashboardLayout><Alert severity="error">Haushaltsplan nicht gefunden</Alert></DashboardLayout>;
const positionen = planung.positionen || [];
const totalGwg = positionen.reduce((s, p) => s + Number(p.budget_gwg), 0);
const totalAnlagen = positionen.reduce((s, p) => s + Number(p.budget_anlagen), 0);
const totalInstandhaltung = positionen.reduce((s, p) => s + Number(p.budget_instandhaltung), 0);
return (
<DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<IconButton onClick={() => navigate('/haushaltsplan')}><ArrowBack /></IconButton>
<Typography variant="h4" sx={{ flexGrow: 1 }}>{planung.bezeichnung}</Typography>
<Chip label={STATUS_LABELS[planung.status] || planung.status} color={STATUS_COLORS[planung.status] || 'default'} />
</Box>
{planung.haushaltsjahr_bezeichnung && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Haushaltsjahr: {planung.haushaltsjahr_bezeichnung}
</Typography>
)}
{!planung.haushaltsjahr_id && canManage && (
<Alert severity="info" sx={{ mb: 2 }} action={
<Button color="inherit" size="small" startIcon={<PlaylistAdd />} onClick={() => createHjMut.mutate()} disabled={createHjMut.isPending}>
Haushaltsjahr erstellen
</Button>
}>
Diesem Plan ist noch kein Haushaltsjahr zugeordnet. Sie können aus diesem Plan ein Haushaltsjahr mit Konten erstellen.
</Alert>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Positionen ({positionen.length})</Typography>
{canManage && (
<Button variant="contained" startIcon={<AddIcon />} onClick={openAddDialog}>Position hinzufügen</Button>
)}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Konto</TableCell>
<TableCell align="right">GWG</TableCell>
<TableCell align="right">Anlagen</TableCell>
<TableCell align="right">Instandh.</TableCell>
<TableCell align="right">Gesamt</TableCell>
{canManage && <TableCell>Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{positionen.length === 0 && (
<TableRow><TableCell colSpan={canManage ? 7 : 6} align="center"><Typography color="text.secondary">Keine Positionen</Typography></TableCell></TableRow>
)}
{positionen.map((pos: Planposition) => {
const rowTotal = Number(pos.budget_gwg) + Number(pos.budget_anlagen) + Number(pos.budget_instandhaltung);
return (
<TableRow key={pos.id} hover>
<TableCell>{pos.bezeichnung}</TableCell>
<TableCell>{pos.konto_bezeichnung ? `${pos.konto_kontonummer} ${pos.konto_bezeichnung}` : ''}</TableCell>
<TableCell align="right">{fmtEur(Number(pos.budget_gwg))}</TableCell>
<TableCell align="right">{fmtEur(Number(pos.budget_anlagen))}</TableCell>
<TableCell align="right">{fmtEur(Number(pos.budget_instandhaltung))}</TableCell>
<TableCell align="right">{fmtEur(rowTotal)}</TableCell>
{canManage && (
<TableCell>
<IconButton size="small" onClick={() => openEditDialog(pos)}><Edit fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => deletePosMut.mutate(pos.id)}><Delete fontSize="small" /></IconButton>
</TableCell>
)}
</TableRow>
);
})}
{positionen.length > 0 && (
<TableRow sx={{ '& td': { fontWeight: 'bold' } }}>
<TableCell colSpan={2}>Summe</TableCell>
<TableCell align="right">{fmtEur(totalGwg)}</TableCell>
<TableCell align="right">{fmtEur(totalAnlagen)}</TableCell>
<TableCell align="right">{fmtEur(totalInstandhaltung)}</TableCell>
<TableCell align="right">{fmtEur(totalGwg + totalAnlagen + totalInstandhaltung)}</TableCell>
{canManage && <TableCell />}
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Position Add/Edit Dialog */}
<Dialog open={posDialog.open} onClose={() => setPosDialog({ open: false })} maxWidth="sm" fullWidth>
<DialogTitle>{posDialog.existing ? 'Position bearbeiten' : 'Neue Position'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Bezeichnung" value={posForm.bezeichnung} onChange={e => setPosForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
{konten.length > 0 && (
<FormControl fullWidth>
<InputLabel>Konto (optional)</InputLabel>
<Select value={posForm.konto_id} label="Konto (optional)" onChange={e => setPosForm(f => ({ ...f, konto_id: e.target.value as string }))}>
<MenuItem value="">Kein Konto</MenuItem>
{konten.map((k: Konto) => <MenuItem key={k.id} value={k.id}>{k.kontonummer} {k.bezeichnung}</MenuItem>)}
</Select>
</FormControl>
)}
<TextField
label="Budget GWG"
type="number"
value={posForm.budget_gwg}
onChange={e => setPosForm(f => ({ ...f, budget_gwg: e.target.value }))}
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
/>
<TextField
label="Budget Anlagen"
type="number"
value={posForm.budget_anlagen}
onChange={e => setPosForm(f => ({ ...f, budget_anlagen: e.target.value }))}
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
/>
<TextField
label="Budget Instandhaltung"
type="number"
value={posForm.budget_instandhaltung}
onChange={e => setPosForm(f => ({ ...f, budget_instandhaltung: e.target.value }))}
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
/>
<TextField label="Notizen" value={posForm.notizen} onChange={e => setPosForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setPosDialog({ open: false })}>Abbrechen</Button>
<Button variant="contained" disabled={!posForm.bezeichnung.trim()} onClick={handlePosSave}>Speichern</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}

View File

@@ -11,7 +11,11 @@ import type {
Freigabe,
Kategorie,
ErstattungFormData, ErstattungLinks,
TransferFormData,
BuchhaltungAudit,
BuchhaltungEinstellungen,
BankkontoStatement,
Planung, PlanungDetail, Planposition,
} from '../types/buchhaltung.types';
export const buchhaltungApi = {
@@ -67,6 +71,20 @@ export const buchhaltungApi = {
deleteBankkonto: async (id: number): Promise<void> => {
await api.delete(`/api/buchhaltung/bankkonten/${id}`);
},
getBankkontoStatement: async (id: number, params?: { von?: string; bis?: string }): Promise<BankkontoStatement> => {
const qs = new URLSearchParams();
if (params?.von) qs.set('von', params.von);
if (params?.bis) qs.set('bis', params.bis);
const r = await api.get(`/api/buchhaltung/bankkonten/${id}/transaktionen?${qs.toString()}`);
const d = r.data;
return {
bankkonto: d.konto,
einnahmen: d.summary.gesamteinnahmen,
ausgaben: d.summary.gesamtausgaben,
saldo: d.summary.saldo,
rows: d.rows,
};
},
// ── Konten ───────────────────────────────────────────────────────────────────
getKonten: async (haushaltsjahrId: number): Promise<Konto[]> => {
@@ -220,9 +238,60 @@ export const buchhaltungApi = {
return r.data.data;
},
// ── Transfers ──────────────────────────────────────────────────────────────
createTransfer: async (data: TransferFormData): Promise<{ debit: Transaktion; credit: Transaktion }> => {
const r = await api.post('/api/buchhaltung/transfers', data);
return r.data.data;
},
// ── Audit ─────────────────────────────────────────────────────────────────
getAudit: async (transaktionId: number): Promise<BuchhaltungAudit[]> => {
const r = await api.get(`/api/buchhaltung/audit/${transaktionId}`);
return r.data.data;
},
// ── Einstellungen ─────────────────────────────────────────────────────────
getEinstellungen: async (): Promise<BuchhaltungEinstellungen> => {
const r = await api.get('/api/buchhaltung/einstellungen');
return r.data.data;
},
setEinstellungen: async (data: Record<string, unknown>): Promise<void> => {
await api.put('/api/buchhaltung/einstellungen', data);
},
// ── Planung ───────────────────────────────────────────────────────────────
listPlanungen: async (): Promise<Planung[]> => {
const r = await api.get('/api/buchhaltung/planung');
return r.data.data;
},
getPlanung: async (id: number): Promise<PlanungDetail> => {
const r = await api.get(`/api/buchhaltung/planung/${id}`);
return r.data.data;
},
createPlanung: async (data: { haushaltsjahr_id?: number; bezeichnung: string }): Promise<Planung> => {
const r = await api.post('/api/buchhaltung/planung', data);
return r.data.data;
},
updatePlanung: async (id: number, data: { bezeichnung?: string; status?: string }): Promise<Planung> => {
const r = await api.put(`/api/buchhaltung/planung/${id}`, data);
return r.data.data;
},
deletePlanung: async (id: number): Promise<void> => {
await api.delete(`/api/buchhaltung/planung/${id}`);
},
createPlanposition: async (planungId: number, data: { konto_id?: number | null; bezeichnung: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }): Promise<Planposition> => {
const r = await api.post(`/api/buchhaltung/planung/${planungId}/positionen`, data);
return r.data.data;
},
updatePlanposition: async (id: number, data: Partial<{ konto_id: number | null; bezeichnung: string; budget_gwg: number; budget_anlagen: number; budget_instandhaltung: number; notizen: string; sort_order: number }>): Promise<Planposition> => {
const r = await api.put(`/api/buchhaltung/planung/positionen/${id}`, data);
return r.data.data;
},
deletePlanposition: async (id: number): Promise<void> => {
await api.delete(`/api/buchhaltung/planung/positionen/${id}`);
},
createHaushaltsjahrFromPlan: async (planungId: number): Promise<Haushaltsjahr> => {
const r = await api.post(`/api/buchhaltung/planung/${planungId}/create-haushaltsjahr`);
return r.data.data;
},
};

View File

@@ -1,6 +1,6 @@
// Lookup types
export type KontoArt = 'einnahme' | 'ausgabe' | 'vermoegen' | 'verbindlichkeit';
export type TransaktionTyp = 'einnahme' | 'ausgabe';
export type TransaktionTyp = 'einnahme' | 'ausgabe' | 'transfer';
export type TransaktionStatus = 'entwurf' | 'gebucht' | 'freigegeben' | 'storniert';
export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt';
export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
@@ -32,6 +32,7 @@ export const TRANSAKTION_STATUS_COLORS: Record<TransaktionStatus, 'default' | 'w
export const TRANSAKTION_TYP_LABELS: Record<TransaktionTyp, string> = {
einnahme: 'Einnahme',
ausgabe: 'Ausgabe',
transfer: 'Transfer',
};
export const KONTO_ART_LABELS: Record<KontoArt, string> = {
@@ -154,6 +155,8 @@ export interface Transaktion {
konto_bezeichnung?: string;
konto_kontonummer?: number;
bankkonto_bezeichnung?: string;
transfer_ziel_bankkonto_id?: number;
transfer_ziel_bezeichnung?: string;
belege?: Beleg[];
}
@@ -306,3 +309,71 @@ export interface ErstattungLinks {
erstattung_transaktion_id: number | null;
quell_transaktion_ids: number[];
}
export interface TransferFormData {
haushaltsjahr_id: number;
bankkonto_id: number;
transfer_ziel_bankkonto_id: number;
betrag: number;
datum: string;
beschreibung?: string;
beleg_nr?: string;
}
export interface BuchhaltungEinstellungen {
default_alert_threshold?: string;
[key: string]: unknown;
}
export interface Planung {
id: number;
bezeichnung: string;
haushaltsjahr_id: number | null;
haushaltsjahr_bezeichnung?: string;
haushaltsjahr_jahr?: number;
beschreibung?: string;
status: PlanungStatus;
positionen_count: number;
total_gwg: number;
total_anlagen: number;
total_instandhaltung: number;
erstellt_von: string | null;
erstellt_am: string;
}
export interface Planposition {
id: number;
planung_id: number;
konto_id: number | null;
konto_bezeichnung?: string;
konto_kontonummer?: number;
bezeichnung: string;
budget_gwg: number;
budget_anlagen: number;
budget_instandhaltung: number;
notizen: string | null;
sort_order: number;
}
export interface PlanungDetail extends Planung {
positionen: Planposition[];
}
export interface BankkontoStatementRow {
id: number;
datum: string;
typ: TransaktionTyp;
beschreibung: string | null;
beleg_nr: string | null;
betrag: number;
laufender_saldo: number;
transfer_ziel_bezeichnung?: string;
}
export interface BankkontoStatement {
bankkonto: Bankkonto;
einnahmen: number;
ausgaben: number;
saldo: number;
rows: BankkontoStatementRow[];
}