feat: add Buchhaltung dashboard widget, CSV export, Bestellungen linking, recurring bookings, and approval workflow
This commit is contained in:
@@ -335,6 +335,109 @@ class BuchhaltungController {
|
|||||||
res.status(500).json({ success: false, message: 'Einstellungen konnten nicht gespeichert werden' });
|
res.status(500).json({ success: false, message: 'Einstellungen konnten nicht gespeichert werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Wiederkehrend ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listWiederkehrend(_req: Request, res: Response): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
export default new BuchhaltungController();
|
||||||
|
|||||||
@@ -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.get('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.getEinstellungen.bind(buchhaltungController));
|
||||||
router.put('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.setEinstellungen.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) ─────────────────────────────────
|
// ── Transaktionen (list/create — before /:id) ─────────────────────────────────
|
||||||
router.get('/', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listTransaktionen.bind(buchhaltungController));
|
router.get('/', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listTransaktionen.bind(buchhaltungController));
|
||||||
router.post('/', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransaktion.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.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/buchen', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.buchenTransaktion.bind(buchhaltungController));
|
||||||
router.post('/:id/storno', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.stornoTransaktion.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));
|
router.post('/:id/belege', authenticate, requirePermission('buchhaltung:create'), uploadBuchhaltung.single('datei'), buchhaltungController.uploadBeleg.bind(buchhaltungController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<string, string> = {
|
||||||
|
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
|
// Export
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -852,6 +1042,12 @@ const buchhaltungService = {
|
|||||||
logAudit,
|
logAudit,
|
||||||
getAuditByTransaktion,
|
getAuditByTransaktion,
|
||||||
getOverview,
|
getOverview,
|
||||||
|
getAllWiederkehrend,
|
||||||
|
getWiederkehrendById,
|
||||||
|
createWiederkehrend,
|
||||||
|
updateWiederkehrend,
|
||||||
|
deleteWiederkehrend,
|
||||||
|
exportTransaktionenCsv,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buchhaltungService;
|
export default buchhaltungService;
|
||||||
|
|||||||
106
frontend/src/components/dashboard/BuchhaltungWidget.tsx
Normal file
106
frontend/src/components/dashboard/BuchhaltungWidget.tsx
Normal file
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>Buchhaltung</Typography>
|
||||||
|
<Skeleton variant="rectangular" height={60} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeJahr) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="h6">Buchhaltung</Typography>
|
||||||
|
<AccountBalance fontSize="small" color="action" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">Kein aktives Haushaltsjahr</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !stats) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="h6">Buchhaltung</Typography>
|
||||||
|
<AccountBalance fontSize="small" color="action" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">Daten konnten nicht geladen werden</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overBudgetCount = stats.konten_budget.filter(k => k.auslastung_prozent >= 80).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||||
|
<Typography variant="h6">Buchhaltung</Typography>
|
||||||
|
<AccountBalance fontSize="small" color="action" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" component="span">Einnahmen: </Typography>
|
||||||
|
<Typography variant="body2" color="success.main" component="span" fontWeight={600}>{fmtEur(stats.total_einnahmen)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" component="span">Ausgaben: </Typography>
|
||||||
|
<Typography variant="body2" color="error.main" component="span" fontWeight={600}>{fmtEur(stats.total_ausgaben)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mb: overBudgetCount > 0 ? 1 : 0 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" component="span">Saldo: </Typography>
|
||||||
|
<Typography variant="body2" color={stats.saldo >= 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats.saldo)}</Typography>
|
||||||
|
</Box>
|
||||||
|
{overBudgetCount > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={`${overBudgetCount} Konto${overBudgetCount > 1 ? 'n' : ''} über 80% Budget`}
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BuchhaltungWidget;
|
||||||
@@ -24,3 +24,4 @@ export { default as IssueQuickAddWidget } from './IssueQuickAddWidget';
|
|||||||
export { default as IssueOverviewWidget } from './IssueOverviewWidget';
|
export { default as IssueOverviewWidget } from './IssueOverviewWidget';
|
||||||
export { default as ChecklistWidget } from './ChecklistWidget';
|
export { default as ChecklistWidget } from './ChecklistWidget';
|
||||||
export { default as SortableWidget } from './SortableWidget';
|
export { default as SortableWidget } from './SortableWidget';
|
||||||
|
export { default as BuchhaltungWidget } from './BuchhaltungWidget';
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const WIDGETS = [
|
|||||||
{ key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true },
|
{ key: 'issueQuickAdd', label: 'Issue melden', defaultVisible: true },
|
||||||
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
|
{ key: 'issueOverview', label: 'Issue Übersicht', defaultVisible: true },
|
||||||
{ key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true },
|
{ key: 'checklistOverdue', label: 'Checklisten', defaultVisible: true },
|
||||||
|
{ key: 'buchhaltung', label: 'Buchhaltung', defaultVisible: true },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WidgetKey = typeof WIDGETS[number]['key'];
|
export type WidgetKey = typeof WIDGETS[number]['key'];
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Fab,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
|
Switch,
|
||||||
Tab,
|
Tab,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -37,12 +38,18 @@ import {
|
|||||||
BookmarkAdd,
|
BookmarkAdd,
|
||||||
Cancel,
|
Cancel,
|
||||||
Delete,
|
Delete,
|
||||||
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
|
HowToReg,
|
||||||
Lock,
|
Lock,
|
||||||
|
ThumbDown,
|
||||||
|
ThumbUp,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { buchhaltungApi } from '../services/buchhaltung';
|
import { buchhaltungApi } from '../services/buchhaltung';
|
||||||
|
import { bestellungApi } from '../services/bestellung';
|
||||||
|
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
import type {
|
import type {
|
||||||
@@ -51,11 +58,14 @@ import type {
|
|||||||
Konto, KontoFormData,
|
Konto, KontoFormData,
|
||||||
Transaktion, TransaktionFormData, TransaktionFilters,
|
Transaktion, TransaktionFormData, TransaktionFilters,
|
||||||
TransaktionStatus,
|
TransaktionStatus,
|
||||||
|
WiederkehrendBuchung, WiederkehrendFormData,
|
||||||
|
WiederkehrendIntervall,
|
||||||
} from '../types/buchhaltung.types';
|
} from '../types/buchhaltung.types';
|
||||||
import {
|
import {
|
||||||
TRANSAKTION_STATUS_LABELS,
|
TRANSAKTION_STATUS_LABELS,
|
||||||
TRANSAKTION_STATUS_COLORS,
|
TRANSAKTION_STATUS_COLORS,
|
||||||
TRANSAKTION_TYP_LABELS,
|
TRANSAKTION_TYP_LABELS,
|
||||||
|
INTERVALL_LABELS,
|
||||||
} from '../types/buchhaltung.types';
|
} from '../types/buchhaltung.types';
|
||||||
|
|
||||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -234,6 +244,7 @@ function TransaktionDialog({
|
|||||||
empfaenger_auftraggeber: '',
|
empfaenger_auftraggeber: '',
|
||||||
verwendungszweck: '',
|
verwendungszweck: '',
|
||||||
beleg_nr: '',
|
beleg_nr: '',
|
||||||
|
bestellung_id: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: konten = [] } = useQuery({
|
const { data: konten = [] } = useQuery({
|
||||||
@@ -242,6 +253,11 @@ function TransaktionDialog({
|
|||||||
enabled: form.haushaltsjahr_id > 0,
|
enabled: form.haushaltsjahr_id > 0,
|
||||||
});
|
});
|
||||||
const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten });
|
const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten });
|
||||||
|
const { data: bestellungen = [] } = useQuery({
|
||||||
|
queryKey: ['bestellungen-all'],
|
||||||
|
queryFn: () => bestellungApi.getOrders(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) setForm(f => ({ ...f, haushaltsjahr_id: selectedJahrId || 0, datum: today }));
|
if (open) setForm(f => ({ ...f, haushaltsjahr_id: selectedJahrId || 0, datum: today }));
|
||||||
@@ -286,6 +302,13 @@ function TransaktionDialog({
|
|||||||
<TextField label="Empfänger/Auftraggeber" value={form.empfaenger_auftraggeber} onChange={e => setForm(f => ({ ...f, empfaenger_auftraggeber: 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="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 }))} />
|
<TextField label="Belegnummer" value={form.beleg_nr} onChange={e => setForm(f => ({ ...f, beleg_nr: e.target.value }))} />
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Bestellung verknüpfen</InputLabel>
|
||||||
|
<Select value={form.bestellung_id ?? ''} label="Bestellung verknüpfen" onChange={e => setForm(f => ({ ...f, bestellung_id: e.target.value ? Number(e.target.value) : null }))}>
|
||||||
|
<MenuItem value=""><em>Keine Bestellung</em></MenuItem>
|
||||||
|
{bestellungen.map(b => <MenuItem key={b.id} value={b.id}>{b.laufende_nummer ? `#${b.laufende_nummer} – ` : ''}{b.bezeichnung}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@@ -419,6 +442,39 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
onError: () => showError('Löschen fehlgeschlagen'),
|
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(() => {
|
useEffect(() => {
|
||||||
setFilters(f => ({ ...f, haushaltsjahr_id: selectedJahrId || undefined }));
|
setFilters(f => ({ ...f, haushaltsjahr_id: selectedJahrId || undefined }));
|
||||||
}, [selectedJahrId]);
|
}, [selectedJahrId]);
|
||||||
@@ -454,6 +510,13 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<TextField size="small" label="Suche" value={filters.search ?? ''}
|
<TextField size="small" label="Suche" value={filters.search ?? ''}
|
||||||
onChange={e => setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} />
|
onChange={e => setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} />
|
||||||
|
{hasPermission('buchhaltung:export') && (
|
||||||
|
<Tooltip title="CSV exportieren">
|
||||||
|
<IconButton size="small" onClick={handleExportCsv} disabled={!filters.haushaltsjahr_id}>
|
||||||
|
<Download fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{isLoading ? <CircularProgress /> : (
|
{isLoading ? <CircularProgress /> : (
|
||||||
@@ -482,7 +545,14 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip label={TRANSAKTION_TYP_LABELS[t.typ]} size="small" color={t.typ === 'einnahme' ? 'success' : 'error'} />
|
<Chip label={TRANSAKTION_TYP_LABELS[t.typ]} size="small" color={t.typ === 'einnahme' ? 'success' : 'error'} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{t.beschreibung || t.empfaenger_auftraggeber || '–'}</TableCell>
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<span>{t.beschreibung || t.empfaenger_auftraggeber || '–'}</span>
|
||||||
|
{t.bestellung_id && (
|
||||||
|
<Chip label={`Best. #${t.bestellung_id}`} size="small" variant="outlined" color="info" sx={{ fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
<TableCell>{t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung}` : '–'}</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 }}>
|
<TableCell align="right" sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main', fontWeight: 600 }}>
|
||||||
{t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)}
|
{t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)}
|
||||||
@@ -499,6 +569,27 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{t.status === 'gebucht' && hasPermission('buchhaltung:edit') && (
|
||||||
|
<Tooltip title="Freigabe anfordern">
|
||||||
|
<IconButton size="small" color="info" onClick={() => freigabeMut.mutate(t.id)}>
|
||||||
|
<HowToReg fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{t.status === 'freigegeben' && hasPermission('buchhaltung:manage_accounts') && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Genehmigen">
|
||||||
|
<IconButton size="small" color="success" onClick={() => approveMut.mutate(t.id)}>
|
||||||
|
<ThumbUp fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Ablehnen">
|
||||||
|
<IconButton size="small" color="error" onClick={() => rejectMut.mutate(t.id)}>
|
||||||
|
<ThumbDown fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{(t.status === 'gebucht' || t.status === 'freigegeben') && hasPermission('buchhaltung:edit') && (
|
{(t.status === 'gebucht' || t.status === 'freigegeben') && hasPermission('buchhaltung:edit') && (
|
||||||
<Tooltip title="Stornieren">
|
<Tooltip title="Stornieren">
|
||||||
<IconButton size="small" color="warning" onClick={() => stornoMut.mutate(t.id)}>
|
<IconButton size="small" color="warning" onClick={() => stornoMut.mutate(t.id)}>
|
||||||
@@ -523,9 +614,9 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasPermission('buchhaltung:create') && (
|
{hasPermission('buchhaltung:create') && (
|
||||||
<Fab color="primary" sx={{ position: 'fixed', bottom: 32, right: 80 }} onClick={() => setCreateOpen(true)}>
|
<ChatAwareFab color="primary" onClick={() => setCreateOpen(true)}>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
</Fab>
|
</ChatAwareFab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TransaktionDialog
|
<TransaktionDialog
|
||||||
@@ -541,6 +632,107 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
|
|
||||||
// ─── Tab 2: Konten ────────────────────────────────────────────────────────────
|
// ─── Tab 2: Konten ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WiederkehrendDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
konten,
|
||||||
|
bankkonten,
|
||||||
|
existing,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => 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<WiederkehrendFormData>(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 (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{existing ? 'Wiederkehrende Buchung bearbeiten' : 'Wiederkehrende Buchung anlegen'}</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 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 />
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel>Intervall</InputLabel>
|
||||||
|
<Select value={form.intervall} label="Intervall" onChange={e => setForm(f => ({ ...f, intervall: e.target.value as WiederkehrendIntervall }))}>
|
||||||
|
{(Object.entries(INTERVALL_LABELS) as [WiederkehrendIntervall, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField label="Nächste Ausführung" type="date" value={form.naechste_ausfuehrung} onChange={e => setForm(f => ({ ...f, naechste_ausfuehrung: 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}</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 }))} />
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={form.aktiv !== false} onChange={e => setForm(f => ({ ...f, aktiv: e.target.checked }))} />}
|
||||||
|
label="Aktiv"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={() => onSave(form)} disabled={!form.bezeichnung || !form.betrag || !form.naechste_ausfuehrung}>Speichern</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||||
haushaltsjahre: Haushaltsjahr[];
|
haushaltsjahre: Haushaltsjahr[];
|
||||||
selectedJahrId: number | null;
|
selectedJahrId: number | null;
|
||||||
@@ -553,6 +745,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
const [kontoDialog, setKontoDialog] = useState<{ open: boolean; existing?: Konto }>({ open: false });
|
const [kontoDialog, setKontoDialog] = useState<{ open: boolean; existing?: Konto }>({ open: false });
|
||||||
const [bankDialog, setBankDialog] = useState<{ open: boolean; existing?: Bankkonto }>({ open: false });
|
const [bankDialog, setBankDialog] = useState<{ open: boolean; existing?: Bankkonto }>({ open: false });
|
||||||
const [jahrDialog, setJahrDialog] = useState<{ open: boolean; existing?: Haushaltsjahr }>({ 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({
|
const { data: konten = [] } = useQuery({
|
||||||
queryKey: ['buchhaltung-konten', selectedJahrId],
|
queryKey: ['buchhaltung-konten', selectedJahrId],
|
||||||
@@ -560,6 +753,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
enabled: selectedJahrId != null,
|
enabled: selectedJahrId != null,
|
||||||
});
|
});
|
||||||
const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten });
|
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');
|
const canManage = hasPermission('buchhaltung:manage_accounts');
|
||||||
|
|
||||||
@@ -611,12 +805,29 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
onError: (e: Error) => showError(e.message || 'Abschluss fehlgeschlagen'),
|
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<WiederkehrendFormData> }) => 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Tabs value={subTab} onChange={(_, v) => setSubTab(v)} sx={{ mb: 2 }}>
|
<Tabs value={subTab} onChange={(_, v) => setSubTab(v)} sx={{ mb: 2 }}>
|
||||||
<Tab label="Konten" />
|
<Tab label="Konten" />
|
||||||
<Tab label="Bankkonten" />
|
<Tab label="Bankkonten" />
|
||||||
<Tab label="Haushaltsjahre" />
|
<Tab label="Haushaltsjahre" />
|
||||||
|
<Tab label="Wiederkehrend" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Sub-Tab 0: Konten */}
|
{/* Sub-Tab 0: Konten */}
|
||||||
@@ -775,6 +986,64 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sub-Tab 3: Wiederkehrend */}
|
||||||
|
{subTab === 3 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setWiederkehrendDialog({ open: true })}>Anlegen</Button>}
|
||||||
|
</Box>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Bezeichnung</TableCell>
|
||||||
|
<TableCell>Typ</TableCell>
|
||||||
|
<TableCell align="right">Betrag</TableCell>
|
||||||
|
<TableCell>Intervall</TableCell>
|
||||||
|
<TableCell>Nächste Ausführung</TableCell>
|
||||||
|
<TableCell>Aktiv</TableCell>
|
||||||
|
{canManage && <TableCell>Aktionen</TableCell>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{wiederkehrend.length === 0 && <TableRow><TableCell colSpan={7} align="center"><Typography color="text.secondary">Keine wiederkehrenden Buchungen</Typography></TableCell></TableRow>}
|
||||||
|
{wiederkehrend.map((w: WiederkehrendBuchung) => (
|
||||||
|
<TableRow key={w.id} hover>
|
||||||
|
<TableCell>{w.bezeichnung}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={TRANSAKTION_TYP_LABELS[w.typ]} size="small" color={w.typ === 'einnahme' ? 'success' : 'error'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ color: w.typ === 'einnahme' ? 'success.main' : 'error.main', fontWeight: 600 }}>
|
||||||
|
{w.typ === 'ausgabe' ? '-' : '+'}{fmtEur(w.betrag)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{INTERVALL_LABELS[w.intervall]}</TableCell>
|
||||||
|
<TableCell>{fmtDate(w.naechste_ausfuehrung)}</TableCell>
|
||||||
|
<TableCell>{w.aktiv ? <Chip label="Aktiv" size="small" color="success" /> : <Chip label="Inaktiv" size="small" color="default" />}</TableCell>
|
||||||
|
{canManage && (
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => setWiederkehrendDialog({ open: true, existing: w })}><Edit fontSize="small" /></IconButton>
|
||||||
|
<IconButton size="small" color="error" onClick={() => deleteWiederkehrendMut.mutate(w.id)}><Delete fontSize="small" /></IconButton>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<WiederkehrendDialog
|
||||||
|
open={wiederkehrendDialog.open}
|
||||||
|
onClose={() => setWiederkehrendDialog({ open: false })}
|
||||||
|
konten={konten}
|
||||||
|
bankkonten={bankkonten}
|
||||||
|
existing={wiederkehrendDialog.existing}
|
||||||
|
onSave={data => wiederkehrendDialog.existing
|
||||||
|
? updateWiederkehrendMut.mutate({ id: wiederkehrendDialog.existing.id, data })
|
||||||
|
: createWiederkehrendMut.mutate(data)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import AusruestungsanfrageWidget from '../components/dashboard/Ausruestungsanfra
|
|||||||
import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
|
import IssueQuickAddWidget from '../components/dashboard/IssueQuickAddWidget';
|
||||||
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
|
import IssueOverviewWidget from '../components/dashboard/IssueOverviewWidget';
|
||||||
import ChecklistWidget from '../components/dashboard/ChecklistWidget';
|
import ChecklistWidget from '../components/dashboard/ChecklistWidget';
|
||||||
|
import BuchhaltungWidget from '../components/dashboard/BuchhaltungWidget';
|
||||||
import { preferencesApi } from '../services/settings';
|
import { preferencesApi } from '../services/settings';
|
||||||
import { configApi } from '../services/config';
|
import { configApi } from '../services/config';
|
||||||
import { WidgetKey } from '../constants/widgets';
|
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)
|
// Default widget order per group (used when no preference is set)
|
||||||
const DEFAULT_ORDER: Record<GroupName, string[]> = {
|
const DEFAULT_ORDER: Record<GroupName, string[]> = {
|
||||||
status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue'],
|
status: ['vehicles', 'equipment', 'atemschutz', 'adminStatus', 'bestellungen', 'ausruestungsanfragen', 'issueOverview', 'checklistOverdue', 'buchhaltung'],
|
||||||
kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'],
|
kalender: ['events', 'vehicleBookingList', 'vehicleBooking', 'eventQuickAdd'],
|
||||||
dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'],
|
dienste: ['bookstackRecent', 'bookstackSearch', 'vikunjaTasks', 'vikunjaQuickAdd', 'issueQuickAdd'],
|
||||||
information: ['links', 'bannerWidget'],
|
information: ['links', 'bannerWidget'],
|
||||||
@@ -120,6 +121,7 @@ function Dashboard() {
|
|||||||
{ key: 'ausruestungsanfragen', widgetKey: 'ausruestungsanfragen', permission: 'ausruestungsanfrage:widget', component: <AusruestungsanfrageWidget /> },
|
{ key: 'ausruestungsanfragen', widgetKey: 'ausruestungsanfragen', permission: 'ausruestungsanfrage:widget', component: <AusruestungsanfrageWidget /> },
|
||||||
{ key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: <IssueOverviewWidget /> },
|
{ key: 'issueOverview', widgetKey: 'issueOverview', permission: 'issues:view_all', component: <IssueOverviewWidget /> },
|
||||||
{ key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: <ChecklistWidget /> },
|
{ key: 'checklistOverdue', widgetKey: 'checklistOverdue', permission: 'checklisten:widget', component: <ChecklistWidget /> },
|
||||||
|
{ key: 'buchhaltung', widgetKey: 'buchhaltung', permission: 'buchhaltung:widget', component: <BuchhaltungWidget /> },
|
||||||
],
|
],
|
||||||
kalender: [
|
kalender: [
|
||||||
{ key: 'events', widgetKey: 'events', permission: 'kalender:view', component: <UpcomingEventsWidget /> },
|
{ key: 'events', widgetKey: 'events', permission: 'kalender:view', component: <UpcomingEventsWidget /> },
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
Transaktion, TransaktionFormData, TransaktionFilters,
|
Transaktion, TransaktionFormData, TransaktionFilters,
|
||||||
Beleg,
|
Beleg,
|
||||||
BuchhaltungStats,
|
BuchhaltungStats,
|
||||||
|
WiederkehrendBuchung, WiederkehrendFormData,
|
||||||
|
Freigabe,
|
||||||
} from '../types/buchhaltung.types';
|
} from '../types/buchhaltung.types';
|
||||||
|
|
||||||
export const buchhaltungApi = {
|
export const buchhaltungApi = {
|
||||||
@@ -126,4 +128,41 @@ export const buchhaltungApi = {
|
|||||||
deleteBeleg: async (id: number): Promise<void> => {
|
deleteBeleg: async (id: number): Promise<void> => {
|
||||||
await api.delete(`/api/buchhaltung/belege/${id}`);
|
await api.delete(`/api/buchhaltung/belege/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Wiederkehrend ─────────────────────────────────────────────────────────────
|
||||||
|
getWiederkehrend: async (): Promise<WiederkehrendBuchung[]> => {
|
||||||
|
const r = await api.get('/api/buchhaltung/wiederkehrend');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createWiederkehrend: async (data: WiederkehrendFormData): Promise<WiederkehrendBuchung> => {
|
||||||
|
const r = await api.post('/api/buchhaltung/wiederkehrend', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateWiederkehrend: async (id: number, data: Partial<WiederkehrendFormData>): Promise<WiederkehrendBuchung> => {
|
||||||
|
const r = await api.patch(`/api/buchhaltung/wiederkehrend/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteWiederkehrend: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/buchhaltung/wiederkehrend/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── CSV Export ────────────────────────────────────────────────────────────────
|
||||||
|
exportCsv: async (haushaltsjahrId: number): Promise<Blob> => {
|
||||||
|
const r = await api.get(`/api/buchhaltung/export/csv?haushaltsjahr_id=${haushaltsjahrId}`, { responseType: 'blob' });
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Freigaben ─────────────────────────────────────────────────────────────────
|
||||||
|
requestFreigabe: async (transaktionId: number): Promise<Freigabe> => {
|
||||||
|
const r = await api.post(`/api/buchhaltung/${transaktionId}/freigabe`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
approveFreigabe: async (id: number, kommentar?: string): Promise<Freigabe> => {
|
||||||
|
const r = await api.patch(`/api/buchhaltung/freigaben/${id}/genehmigen`, { kommentar });
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
rejectFreigabe: async (id: number, kommentar?: string): Promise<Freigabe> => {
|
||||||
|
const r = await api.patch(`/api/buchhaltung/freigaben/${id}/ablehnen`, { kommentar });
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ export interface TransaktionFormData {
|
|||||||
empfaenger_auftraggeber?: string;
|
empfaenger_auftraggeber?: string;
|
||||||
verwendungszweck?: string;
|
verwendungszweck?: string;
|
||||||
beleg_nr?: string;
|
beleg_nr?: string;
|
||||||
|
bestellung_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter type for transaction list
|
// Filter type for transaction list
|
||||||
@@ -227,3 +228,16 @@ export interface TransaktionFilters {
|
|||||||
datum_bis?: string;
|
datum_bis?: string;
|
||||||
search?: 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user