diff --git a/backend/src/controllers/buchhaltung.controller.ts b/backend/src/controllers/buchhaltung.controller.ts index 17c4769..1d010ef 100644 --- a/backend/src/controllers/buchhaltung.controller.ts +++ b/backend/src/controllers/buchhaltung.controller.ts @@ -335,6 +335,109 @@ class BuchhaltungController { res.status(500).json({ success: false, message: 'Einstellungen konnten nicht gespeichert werden' }); } } + + // ── Wiederkehrend ──────────────────────────────────────────────────────────── + + async listWiederkehrend(_req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.getAllWiederkehrend(); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.listWiederkehrend', { error }); + res.status(500).json({ success: false, message: 'Wiederkehrende Buchungen konnten nicht geladen werden' }); + } + } + + async createWiederkehrend(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createWiederkehrend(req.body, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createWiederkehrend', { error }); + res.status(500).json({ success: false, message: 'Wiederkehrende Buchung konnte nicht erstellt werden' }); + } + } + + async updateWiederkehrend(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.updateWiederkehrend(id, req.body); + if (!data) { res.status(404).json({ success: false, message: 'Wiederkehrende Buchung nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.updateWiederkehrend', { error }); + res.status(500).json({ success: false, message: 'Wiederkehrende Buchung konnte nicht aktualisiert werden' }); + } + } + + async deleteWiederkehrend(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + await buchhaltungService.deleteWiederkehrend(id); + res.json({ success: true }); + } catch (error) { + logger.error('BuchhaltungController.deleteWiederkehrend', { error }); + res.status(500).json({ success: false, message: 'Wiederkehrende Buchung konnte nicht gelöscht werden' }); + } + } + + // ── CSV Export ─────────────────────────────────────────────────────────────── + + async exportCsv(req: Request, res: Response): Promise { + const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10); + if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; } + try { + const csv = await buchhaltungService.exportTransaktionenCsv(haushaltsjahrId); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="transaktionen_${haushaltsjahrId}.csv"`); + res.send(csv); + } catch (error) { + logger.error('BuchhaltungController.exportCsv', { error }); + res.status(500).json({ success: false, message: 'CSV-Export fehlgeschlagen' }); + } + } + + // ── Freigaben ──────────────────────────────────────────────────────────────── + + async requestFreigabe(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.createFreigabe(id, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.requestFreigabe', { error }); + res.status(500).json({ success: false, message: 'Freigabe konnte nicht angefragt werden' }); + } + } + + async approveFreigabe(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.approveFreigabe(id, req.body.kommentar, req.user!.id); + if (!data) { res.status(404).json({ success: false, message: 'Freigabe nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.approveFreigabe', { error }); + res.status(500).json({ success: false, message: 'Freigabe konnte nicht genehmigt werden' }); + } + } + + async rejectFreigabe(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.rejectFreigabe(id, req.body.kommentar, req.user!.id); + if (!data) { res.status(404).json({ success: false, message: 'Freigabe nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.rejectFreigabe', { error }); + res.status(500).json({ success: false, message: 'Freigabe konnte nicht abgelehnt werden' }); + } + } } export default new BuchhaltungController(); diff --git a/backend/src/routes/buchhaltung.routes.ts b/backend/src/routes/buchhaltung.routes.ts index a36dc88..e658bf4 100644 --- a/backend/src/routes/buchhaltung.routes.ts +++ b/backend/src/routes/buchhaltung.routes.ts @@ -38,6 +38,19 @@ router.delete('/belege/:id', authenticate, requirePermission('buchhaltung:delete router.get('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.getEinstellungen.bind(buchhaltungController)); router.put('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.setEinstellungen.bind(buchhaltungController)); +// ── Wiederkehrend ───────────────────────────────────────────────────────────── +router.get('/wiederkehrend', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listWiederkehrend.bind(buchhaltungController)); +router.post('/wiederkehrend', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createWiederkehrend.bind(buchhaltungController)); +router.patch('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateWiederkehrend.bind(buchhaltungController)); +router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteWiederkehrend.bind(buchhaltungController)); + +// ── CSV Export ───────────────────────────────────────────────────────────────── +router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController)); + +// ── Freigaben ───────────────────────────────────────────────────────────────── +router.patch('/freigaben/:id/genehmigen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.approveFreigabe.bind(buchhaltungController)); +router.patch('/freigaben/:id/ablehnen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.rejectFreigabe.bind(buchhaltungController)); + // ── Transaktionen (list/create — before /:id) ───────────────────────────────── router.get('/', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listTransaktionen.bind(buchhaltungController)); router.post('/', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransaktion.bind(buchhaltungController)); @@ -48,6 +61,7 @@ router.patch('/:id', authenticate, requirePermission('buchhaltung:edit'), router.delete('/:id', authenticate, requirePermission('buchhaltung:delete'), buchhaltungController.deleteTransaktion.bind(buchhaltungController)); router.post('/:id/buchen', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.buchenTransaktion.bind(buchhaltungController)); router.post('/:id/storno', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.stornoTransaktion.bind(buchhaltungController)); +router.post('/:id/freigabe', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.requestFreigabe.bind(buchhaltungController)); router.post('/:id/belege', authenticate, requirePermission('buchhaltung:create'), uploadBuchhaltung.single('datei'), buchhaltungController.uploadBeleg.bind(buchhaltungController)); export default router; diff --git a/backend/src/services/buchhaltung.service.ts b/backend/src/services/buchhaltung.service.ts index 4037d99..5f0a86e 100644 --- a/backend/src/services/buchhaltung.service.ts +++ b/backend/src/services/buchhaltung.service.ts @@ -810,6 +810,196 @@ async function getOverview(haushaltsjahrId: number) { } } +// --------------------------------------------------------------------------- +// Wiederkehrend (Recurring Bookings) +// --------------------------------------------------------------------------- + +async function getAllWiederkehrend() { + try { + const result = await pool.query( + `SELECT w.*, + k.bezeichnung as konto_bezeichnung, + k.kontonummer as konto_kontonummer, + bk.bezeichnung as bankkonto_bezeichnung + FROM buchhaltung_wiederkehrend w + LEFT JOIN buchhaltung_konten k ON w.konto_id = k.id + LEFT JOIN buchhaltung_bankkonten bk ON w.bankkonto_id = bk.id + ORDER BY w.naechste_ausfuehrung, w.bezeichnung` + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getAllWiederkehrend failed', { error }); + throw new Error('Wiederkehrende Buchungen konnten nicht geladen werden'); + } +} + +async function getWiederkehrendById(id: number) { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_wiederkehrend WHERE id = $1`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.getWiederkehrendById failed', { error, id }); + throw new Error('Wiederkehrende Buchung konnte nicht geladen werden'); + } +} + +async function createWiederkehrend( + data: { + bezeichnung: string; + konto_id?: number | null; + bankkonto_id?: number | null; + typ: 'einnahme' | 'ausgabe'; + betrag: number; + beschreibung?: string; + empfaenger_auftraggeber?: string; + intervall: string; + naechste_ausfuehrung: string; + aktiv?: boolean; + }, + userId: string +) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_wiederkehrend + (bezeichnung, konto_id, bankkonto_id, typ, betrag, beschreibung, empfaenger_auftraggeber, intervall, naechste_ausfuehrung, aktiv, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + data.bezeichnung, + data.konto_id || null, + data.bankkonto_id || null, + data.typ, + data.betrag, + data.beschreibung || null, + data.empfaenger_auftraggeber || null, + data.intervall, + data.naechste_ausfuehrung, + data.aktiv !== false, + userId, + ] + ); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.createWiederkehrend failed', { error }); + throw new Error('Wiederkehrende Buchung konnte nicht erstellt werden'); + } +} + +async function updateWiederkehrend( + id: number, + data: { + bezeichnung?: string; + konto_id?: number | null; + bankkonto_id?: number | null; + typ?: string; + betrag?: number; + beschreibung?: string; + empfaenger_auftraggeber?: string; + intervall?: string; + naechste_ausfuehrung?: string; + aktiv?: boolean; + } +) { + try { + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } + if (data.konto_id !== undefined) { fields.push(`konto_id = $${idx++}`); values.push(data.konto_id); } + if (data.bankkonto_id !== undefined) { fields.push(`bankkonto_id = $${idx++}`); values.push(data.bankkonto_id); } + if (data.typ !== undefined) { fields.push(`typ = $${idx++}`); values.push(data.typ); } + if (data.betrag !== undefined) { fields.push(`betrag = $${idx++}`); values.push(data.betrag); } + if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung || null); } + if (data.empfaenger_auftraggeber !== undefined) { fields.push(`empfaenger_auftraggeber = $${idx++}`); values.push(data.empfaenger_auftraggeber || null); } + if (data.intervall !== undefined) { fields.push(`intervall = $${idx++}`); values.push(data.intervall); } + if (data.naechste_ausfuehrung !== undefined) { fields.push(`naechste_ausfuehrung = $${idx++}`); values.push(data.naechste_ausfuehrung); } + if (data.aktiv !== undefined) { fields.push(`aktiv = $${idx++}`); values.push(data.aktiv); } + if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); + values.push(id); + const result = await pool.query( + `UPDATE buchhaltung_wiederkehrend SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.updateWiederkehrend failed', { error, id }); + throw new Error('Wiederkehrende Buchung konnte nicht aktualisiert werden'); + } +} + +async function deleteWiederkehrend(id: number) { + try { + await pool.query(`DELETE FROM buchhaltung_wiederkehrend WHERE id = $1`, [id]); + } catch (error) { + logger.error('BuchhaltungService.deleteWiederkehrend failed', { error, id }); + throw new Error('Wiederkehrende Buchung konnte nicht gelöscht werden'); + } +} + +// --------------------------------------------------------------------------- +// CSV Export +// --------------------------------------------------------------------------- + +async function exportTransaktionenCsv(haushaltsjahrId: number): Promise { + try { + const result = await pool.query( + `SELECT t.*, + k.bezeichnung as konto_bezeichnung, + k.kontonummer as konto_kontonummer, + bk.bezeichnung as bankkonto_bezeichnung + FROM buchhaltung_transaktionen t + LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id + LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id + WHERE t.haushaltsjahr_id = $1 + ORDER BY t.datum DESC, t.id DESC`, + [haushaltsjahrId] + ); + + const statusLabels: Record = { + entwurf: 'Entwurf', + gebucht: 'Gebucht', + freigegeben: 'Freigegeben', + storniert: 'Storniert', + }; + + const header = [ + 'Nr.', 'Datum', 'Typ', 'Beschreibung', 'Empfänger/Auftraggeber', + 'Verwendungszweck', 'Belegnummer', 'Konto', 'Bankkonto', 'Betrag', 'Status', + ].join(';'); + + const rows = result.rows.map(row => { + const betrag = (row.typ === 'ausgabe' ? '-' : '') + parseFloat(row.betrag).toFixed(2).replace('.', ','); + const konto = row.konto_kontonummer ? `${row.konto_kontonummer} ${row.konto_bezeichnung}` : ''; + const datum = row.datum ? new Date(row.datum).toLocaleDateString('de-DE') : ''; + const escCsv = (val: unknown) => { + const s = String(val ?? ''); + return s.includes(';') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s; + }; + return [ + row.laufende_nummer ?? `E${row.id}`, + datum, + row.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe', + escCsv(row.beschreibung), + escCsv(row.empfaenger_auftraggeber), + escCsv(row.verwendungszweck), + escCsv(row.beleg_nr), + escCsv(konto), + escCsv(row.bankkonto_bezeichnung), + betrag, + statusLabels[row.status] || row.status, + ].join(';'); + }); + + return '\uFEFF' + header + '\r\n' + rows.join('\r\n'); + } catch (error) { + logger.error('BuchhaltungService.exportTransaktionenCsv failed', { error }); + throw new Error('CSV-Export fehlgeschlagen'); + } +} + // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- @@ -852,6 +1042,12 @@ const buchhaltungService = { logAudit, getAuditByTransaktion, getOverview, + getAllWiederkehrend, + getWiederkehrendById, + createWiederkehrend, + updateWiederkehrend, + deleteWiederkehrend, + exportTransaktionenCsv, }; export default buchhaltungService; diff --git a/frontend/src/components/dashboard/BuchhaltungWidget.tsx b/frontend/src/components/dashboard/BuchhaltungWidget.tsx new file mode 100644 index 0000000..488525c --- /dev/null +++ b/frontend/src/components/dashboard/BuchhaltungWidget.tsx @@ -0,0 +1,106 @@ +import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material'; +import { AccountBalance } from '@mui/icons-material'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { buchhaltungApi } from '../../services/buchhaltung'; + +function fmtEur(val: number) { + return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val); +} + +function BuchhaltungWidget() { + const navigate = useNavigate(); + + const { data: haushaltsjahre = [], isLoading: loadingJahre } = useQuery({ + queryKey: ['haushaltsjahre'], + queryFn: buchhaltungApi.getHaushaltsjahre, + refetchInterval: 5 * 60 * 1000, + retry: 1, + }); + + const activeJahr = haushaltsjahre.find(hj => !hj.abgeschlossen) || haushaltsjahre[0]; + + const { data: stats, isLoading: loadingStats, isError } = useQuery({ + queryKey: ['buchhaltung-stats', activeJahr?.id], + queryFn: () => buchhaltungApi.getStats(activeJahr!.id), + enabled: !!activeJahr, + refetchInterval: 5 * 60 * 1000, + retry: 1, + }); + + const isLoading = loadingJahre || (!!activeJahr && loadingStats); + + if (isLoading) { + return ( + + + Buchhaltung + + + + ); + } + + if (!activeJahr) { + return ( + navigate('/buchhaltung')}> + + + Buchhaltung + + + Kein aktives Haushaltsjahr + + + ); + } + + if (isError || !stats) { + return ( + navigate('/buchhaltung')}> + + + Buchhaltung + + + Daten konnten nicht geladen werden + + + ); + } + + const overBudgetCount = stats.konten_budget.filter(k => k.auslastung_prozent >= 80).length; + + return ( + navigate('/buchhaltung')}> + + + Buchhaltung + + + + Einnahmen: + {fmtEur(stats.total_einnahmen)} + + + Ausgaben: + {fmtEur(stats.total_ausgaben)} + + 0 ? 1 : 0 }}> + Saldo: + = 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats.saldo)} + + {overBudgetCount > 0 && ( + 1 ? 'n' : ''} über 80% Budget`} + color="warning" + size="small" + variant="outlined" + /> + )} + + + ); +} + +export default BuchhaltungWidget; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 852b0c0..8bf7e83 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -24,3 +24,4 @@ export { default as IssueQuickAddWidget } from './IssueQuickAddWidget'; export { default as IssueOverviewWidget } from './IssueOverviewWidget'; export { default as ChecklistWidget } from './ChecklistWidget'; export { default as SortableWidget } from './SortableWidget'; +export { default as BuchhaltungWidget } from './BuchhaltungWidget'; diff --git a/frontend/src/constants/widgets.ts b/frontend/src/constants/widgets.ts index 12aac42..e40cb01 100644 --- a/frontend/src/constants/widgets.ts +++ b/frontend/src/constants/widgets.ts @@ -18,6 +18,7 @@ export const WIDGETS = [ { key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true }, { key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true }, { key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true }, + { key: 'buchhaltung', label: 'Buchhaltung', defaultVisible: true }, ] as const; export type WidgetKey = typeof WIDGETS[number]['key']; diff --git a/frontend/src/pages/Buchhaltung.tsx b/frontend/src/pages/Buchhaltung.tsx index 88011ca..121e243 100644 --- a/frontend/src/pages/Buchhaltung.tsx +++ b/frontend/src/pages/Buchhaltung.tsx @@ -11,8 +11,8 @@ import { DialogActions, DialogContent, DialogTitle, - Fab, FormControl, + FormControlLabel, IconButton, InputLabel, LinearProgress, @@ -20,6 +20,7 @@ import { Paper, Select, Stack, + Switch, Tab, Table, TableBody, @@ -37,12 +38,18 @@ import { BookmarkAdd, Cancel, Delete, + Download, Edit, + HowToReg, Lock, + ThumbDown, + ThumbUp, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useLocation, useNavigate } from 'react-router-dom'; import { buchhaltungApi } from '../services/buchhaltung'; +import { bestellungApi } from '../services/bestellung'; +import ChatAwareFab from '../components/shared/ChatAwareFab'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import type { @@ -51,11 +58,14 @@ import type { Konto, KontoFormData, Transaktion, TransaktionFormData, TransaktionFilters, TransaktionStatus, + WiederkehrendBuchung, WiederkehrendFormData, + WiederkehrendIntervall, } from '../types/buchhaltung.types'; import { TRANSAKTION_STATUS_LABELS, TRANSAKTION_STATUS_COLORS, TRANSAKTION_TYP_LABELS, + INTERVALL_LABELS, } from '../types/buchhaltung.types'; // ─── helpers ─────────────────────────────────────────────────────────────────── @@ -234,6 +244,7 @@ function TransaktionDialog({ empfaenger_auftraggeber: '', verwendungszweck: '', beleg_nr: '', + bestellung_id: null, }); const { data: konten = [] } = useQuery({ @@ -242,6 +253,11 @@ function TransaktionDialog({ enabled: form.haushaltsjahr_id > 0, }); const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten }); + const { data: bestellungen = [] } = useQuery({ + queryKey: ['bestellungen-all'], + queryFn: () => bestellungApi.getOrders(), + staleTime: 5 * 60 * 1000, + }); useEffect(() => { if (open) setForm(f => ({ ...f, haushaltsjahr_id: selectedJahrId || 0, datum: today })); @@ -286,6 +302,13 @@ function TransaktionDialog({ setForm(f => ({ ...f, empfaenger_auftraggeber: e.target.value }))} /> setForm(f => ({ ...f, verwendungszweck: e.target.value }))} /> setForm(f => ({ ...f, beleg_nr: e.target.value }))} /> + + Bestellung verknüpfen + + @@ -419,6 +442,39 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { onError: () => showError('Löschen fehlgeschlagen'), }); + const freigabeMut = useMutation({ + mutationFn: (id: number) => buchhaltungApi.requestFreigabe(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); showSuccess('Freigabe angefragt'); }, + onError: () => showError('Freigabe konnte nicht angefragt werden'), + }); + + const approveMut = useMutation({ + mutationFn: (id: number) => buchhaltungApi.approveFreigabe(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); showSuccess('Freigabe genehmigt'); }, + onError: () => showError('Genehmigung fehlgeschlagen'), + }); + + const rejectMut = useMutation({ + mutationFn: (id: number) => buchhaltungApi.rejectFreigabe(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); showSuccess('Freigabe abgelehnt'); }, + onError: () => showError('Ablehnung fehlgeschlagen'), + }); + + const handleExportCsv = async () => { + if (!filters.haushaltsjahr_id) { showError('Bitte ein Haushaltsjahr auswählen'); return; } + try { + const blob = await buchhaltungApi.exportCsv(filters.haushaltsjahr_id); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `transaktionen_${filters.haushaltsjahr_id}.csv`; + a.click(); + URL.revokeObjectURL(url); + } catch { + showError('Export fehlgeschlagen'); + } + }; + useEffect(() => { setFilters(f => ({ ...f, haushaltsjahr_id: selectedJahrId || undefined })); }, [selectedJahrId]); @@ -454,6 +510,13 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} /> + {hasPermission('buchhaltung:export') && ( + + + + + + )} {isLoading ? : ( @@ -482,7 +545,14 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { - {t.beschreibung || t.empfaenger_auftraggeber || '–'} + + + {t.beschreibung || t.empfaenger_auftraggeber || '–'} + {t.bestellung_id && ( + + )} + + {t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung}` : '–'} {t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)} @@ -499,6 +569,27 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { )} + {t.status === 'gebucht' && hasPermission('buchhaltung:edit') && ( + + freigabeMut.mutate(t.id)}> + + + + )} + {t.status === 'freigegeben' && hasPermission('buchhaltung:manage_accounts') && ( + <> + + approveMut.mutate(t.id)}> + + + + + rejectMut.mutate(t.id)}> + + + + + )} {(t.status === 'gebucht' || t.status === 'freigegeben') && hasPermission('buchhaltung:edit') && ( stornoMut.mutate(t.id)}> @@ -523,9 +614,9 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { )} {hasPermission('buchhaltung:create') && ( - setCreateOpen(true)}> + setCreateOpen(true)}> - + )} void; + konten: Konto[]; + bankkonten: Bankkonto[]; + existing?: WiederkehrendBuchung; + onSave: (data: WiederkehrendFormData) => void; +}) { + const today = new Date().toISOString().slice(0, 10); + const empty: WiederkehrendFormData = { + bezeichnung: '', + konto_id: null, + bankkonto_id: null, + typ: 'ausgabe', + betrag: 0, + beschreibung: '', + empfaenger_auftraggeber: '', + intervall: 'monatlich', + naechste_ausfuehrung: today, + aktiv: true, + }; + const [form, setForm] = useState(empty); + + useEffect(() => { + if (existing) { + setForm({ + bezeichnung: existing.bezeichnung, + konto_id: existing.konto_id, + bankkonto_id: existing.bankkonto_id, + typ: existing.typ, + betrag: existing.betrag, + beschreibung: existing.beschreibung || '', + empfaenger_auftraggeber: existing.empfaenger_auftraggeber || '', + intervall: existing.intervall, + naechste_ausfuehrung: existing.naechste_ausfuehrung.slice(0, 10), + aktiv: existing.aktiv, + }); + } else { + setForm(empty); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existing, open]); + + return ( + + {existing ? 'Wiederkehrende Buchung bearbeiten' : 'Wiederkehrende Buchung anlegen'} + + + setForm(f => ({ ...f, bezeichnung: e.target.value }))} required /> + + Typ + + + setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required /> + + Intervall + + + setForm(f => ({ ...f, naechste_ausfuehrung: e.target.value }))} InputLabelProps={{ shrink: true }} required /> + + Konto + + + + Bankkonto + + + setForm(f => ({ ...f, beschreibung: e.target.value }))} /> + setForm(f => ({ ...f, empfaenger_auftraggeber: e.target.value }))} /> + setForm(f => ({ ...f, aktiv: e.target.checked }))} />} + label="Aktiv" + /> + + + + + + + + ); +} + function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { haushaltsjahre: Haushaltsjahr[]; selectedJahrId: number | null; @@ -553,6 +745,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { 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 [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false }); const { data: konten = [] } = useQuery({ queryKey: ['buchhaltung-konten', selectedJahrId], @@ -560,6 +753,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { enabled: selectedJahrId != null, }); const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten }); + const { data: wiederkehrend = [] } = useQuery({ queryKey: ['buchhaltung-wiederkehrend'], queryFn: buchhaltungApi.getWiederkehrend }); const canManage = hasPermission('buchhaltung:manage_accounts'); @@ -611,12 +805,29 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { onError: (e: Error) => showError(e.message || 'Abschluss fehlgeschlagen'), }); + const createWiederkehrendMut = useMutation({ + mutationFn: buchhaltungApi.createWiederkehrend, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung erstellt'); }, + onError: () => showError('Erstellen fehlgeschlagen'), + }); + const updateWiederkehrendMut = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateWiederkehrend(id, data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); setWiederkehrendDialog({ open: false }); showSuccess('Wiederkehrende Buchung aktualisiert'); }, + onError: () => showError('Aktualisierung fehlgeschlagen'), + }); + const deleteWiederkehrendMut = useMutation({ + mutationFn: buchhaltungApi.deleteWiederkehrend, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-wiederkehrend'] }); showSuccess('Wiederkehrende Buchung gelöscht'); }, + onError: () => showError('Löschen fehlgeschlagen'), + }); + return ( setSubTab(v)} sx={{ mb: 2 }}> + {/* Sub-Tab 0: Konten */} @@ -775,6 +986,64 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { /> )} + + {/* Sub-Tab 3: Wiederkehrend */} + {subTab === 3 && ( + + + {canManage && } + + + + + + Bezeichnung + Typ + Betrag + Intervall + Nächste Ausführung + Aktiv + {canManage && Aktionen} + + + + {wiederkehrend.length === 0 && Keine wiederkehrenden Buchungen} + {wiederkehrend.map((w: WiederkehrendBuchung) => ( + + {w.bezeichnung} + + + + + {w.typ === 'ausgabe' ? '-' : '+'}{fmtEur(w.betrag)} + + {INTERVALL_LABELS[w.intervall]} + {fmtDate(w.naechste_ausfuehrung)} + {w.aktiv ? : } + {canManage && ( + + setWiederkehrendDialog({ open: true, existing: w })}> + deleteWiederkehrendMut.mutate(w.id)}> + + )} + + ))} + +
+
+ setWiederkehrendDialog({ open: false })} + konten={konten} + bankkonten={bankkonten} + existing={wiederkehrendDialog.existing} + onSave={data => wiederkehrendDialog.existing + ? updateWiederkehrendMut.mutate({ id: wiederkehrendDialog.existing.id, data }) + : createWiederkehrendMut.mutate(data) + } + /> +
+ )} ); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1dfff58..1a8303f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -49,6 +49,7 @@ import AusruestungsanfrageWidget from '../components/dashboard/Ausruestungsanfra import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget'; import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget'; import ChecklistWidget from '../components/dashboard/ChecklistWidget'; +import BuchhaltungWidget from '../components/dashboard/BuchhaltungWidget'; import { preferencesApi } from '../services/settings'; import { configApi } from '../services/config'; import { WidgetKey } from '../constants/widgets'; @@ -73,7 +74,7 @@ const GROUP_ORDER: { name: GroupName; title: string }[] = [ // Default widget order per group (used when no preference is set) const DEFAULT_ORDER: Record = { - status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue'], + status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue', 'buchhaltung'], kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'], dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'], information: ['links', 'bannerWidget'], @@ -120,6 +121,7 @@ function Dashboard() { { key: 'ausruestungsanfragen', widgetKey: 'ausruestungsanfragen', permission: 'ausruestungsanfrage:widget', component: }, { key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: }, { key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: }, + { key: 'buchhaltung', widgetKey: 'buchhaltung', permission: 'buchhaltung:widget', component: }, ], kalender: [ { key: 'events', widgetKey: 'events', permission: 'kalender:view', component: }, diff --git a/frontend/src/services/buchhaltung.ts b/frontend/src/services/buchhaltung.ts index f313441..2b239ab 100644 --- a/frontend/src/services/buchhaltung.ts +++ b/frontend/src/services/buchhaltung.ts @@ -7,6 +7,8 @@ import type { Transaktion, TransaktionFormData, TransaktionFilters, Beleg, BuchhaltungStats, + WiederkehrendBuchung, WiederkehrendFormData, + Freigabe, } from '../types/buchhaltung.types'; export const buchhaltungApi = { @@ -126,4 +128,41 @@ export const buchhaltungApi = { deleteBeleg: async (id: number): Promise => { await api.delete(`/api/buchhaltung/belege/${id}`); }, + + // ── Wiederkehrend ───────────────────────────────────────────────────────────── + getWiederkehrend: async (): Promise => { + const r = await api.get('/api/buchhaltung/wiederkehrend'); + return r.data.data; + }, + createWiederkehrend: async (data: WiederkehrendFormData): Promise => { + const r = await api.post('/api/buchhaltung/wiederkehrend', data); + return r.data.data; + }, + updateWiederkehrend: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/buchhaltung/wiederkehrend/${id}`, data); + return r.data.data; + }, + deleteWiederkehrend: async (id: number): Promise => { + await api.delete(`/api/buchhaltung/wiederkehrend/${id}`); + }, + + // ── CSV Export ──────────────────────────────────────────────────────────────── + exportCsv: async (haushaltsjahrId: number): Promise => { + const r = await api.get(`/api/buchhaltung/export/csv?haushaltsjahr_id=${haushaltsjahrId}`, { responseType: 'blob' }); + return r.data; + }, + + // ── Freigaben ───────────────────────────────────────────────────────────────── + requestFreigabe: async (transaktionId: number): Promise => { + const r = await api.post(`/api/buchhaltung/${transaktionId}/freigabe`); + return r.data.data; + }, + approveFreigabe: async (id: number, kommentar?: string): Promise => { + const r = await api.patch(`/api/buchhaltung/freigaben/${id}/genehmigen`, { kommentar }); + return r.data.data; + }, + rejectFreigabe: async (id: number, kommentar?: string): Promise => { + const r = await api.patch(`/api/buchhaltung/freigaben/${id}/ablehnen`, { kommentar }); + return r.data.data; + }, }; diff --git a/frontend/src/types/buchhaltung.types.ts b/frontend/src/types/buchhaltung.types.ts index 2f42b84..f99832d 100644 --- a/frontend/src/types/buchhaltung.types.ts +++ b/frontend/src/types/buchhaltung.types.ts @@ -215,6 +215,7 @@ export interface TransaktionFormData { empfaenger_auftraggeber?: string; verwendungszweck?: string; beleg_nr?: string; + bestellung_id?: number | null; } // Filter type for transaction list @@ -227,3 +228,16 @@ export interface TransaktionFilters { datum_bis?: string; search?: string; } + +export interface WiederkehrendFormData { + bezeichnung: string; + konto_id?: number | null; + bankkonto_id?: number | null; + typ: TransaktionTyp; + betrag: number; + beschreibung?: string; + empfaenger_auftraggeber?: string; + intervall: WiederkehrendIntervall; + naechste_ausfuehrung: string; + aktiv?: boolean; +}