feat(buchhaltung): budget types, erstattungen, recurring tab move, overview dividers, order completion guard
This commit is contained in:
@@ -486,6 +486,33 @@ class BuchhaltungController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Erstattungen ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createErstattung(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.createErstattung({
|
||||||
|
...req.body,
|
||||||
|
erstellt_von: req.user!.id,
|
||||||
|
});
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungController.createErstattung', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Erstattung konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getErstattungLinks(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.getErstattungLinks(id);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungController.getErstattungLinks', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Erstattungsverknüpfungen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Freigaben ────────────────────────────────────────────────────────────────
|
// ── Freigaben ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async requestFreigabe(req: Request, res: Response): Promise<void> {
|
async requestFreigabe(req: Request, res: Response): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Add budget type support to buchhaltung_konten
|
||||||
|
ALTER TABLE buchhaltung_konten
|
||||||
|
ADD COLUMN IF NOT EXISTS budget_typ TEXT NOT NULL DEFAULT 'detailliert';
|
||||||
|
|
||||||
|
ALTER TABLE buchhaltung_konten
|
||||||
|
ADD COLUMN IF NOT EXISTS budget_gesamt NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Erstattung (reimbursement) linking table
|
||||||
|
CREATE TABLE IF NOT EXISTS buchhaltung_erstattung_zuordnungen (
|
||||||
|
erstattung_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
|
||||||
|
ausgabe_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (erstattung_transaktion_id, ausgabe_transaktion_id)
|
||||||
|
);
|
||||||
@@ -53,6 +53,10 @@ router.post('/wiederkehrend', authenticate, requirePermission('buchhaltung
|
|||||||
router.patch('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateWiederkehrend.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));
|
router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteWiederkehrend.bind(buchhaltungController));
|
||||||
|
|
||||||
|
// ── Erstattungen ──────────────────────────────────────────────────────────────
|
||||||
|
router.post('/erstattungen', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createErstattung.bind(buchhaltungController));
|
||||||
|
router.get('/transaktionen/:id/erstattung-links', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getErstattungLinks.bind(buchhaltungController));
|
||||||
|
|
||||||
// ── CSV Export ─────────────────────────────────────────────────────────────────
|
// ── CSV Export ─────────────────────────────────────────────────────────────────
|
||||||
router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController));
|
router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'), buchhaltungController.exportCsv.bind(buchhaltungController));
|
||||||
|
|
||||||
|
|||||||
@@ -471,18 +471,28 @@ async function validateSubPotBudget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createKonto(
|
async function createKonto(
|
||||||
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string },
|
data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number },
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
if (data.parent_id && (data.budget_gwg || data.budget_anlagen || data.budget_instandhaltung)) {
|
if (data.parent_id && (data.budget_gwg || data.budget_anlagen || data.budget_instandhaltung)) {
|
||||||
await validateSubPotBudget(data.parent_id, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0);
|
await validateSubPotBudget(data.parent_id, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Child konten inherit parent's budget_typ
|
||||||
|
let budgetTyp = data.budget_typ || 'detailliert';
|
||||||
|
if (data.parent_id) {
|
||||||
|
const parentRow = await pool.query(`SELECT budget_typ FROM buchhaltung_konten WHERE id = $1`, [data.parent_id]);
|
||||||
|
if (parentRow.rows[0]) {
|
||||||
|
budgetTyp = parentRow.rows[0].budget_typ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von)
|
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, erstellt_von, kategorie_id, budget_typ, budget_gesamt)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.parent_id || null, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, userId]
|
[data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.parent_id || null, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, userId, data.kategorie_id ?? null, budgetTyp, data.budget_gesamt || 0]
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -497,7 +507,7 @@ async function createKonto(
|
|||||||
|
|
||||||
async function updateKonto(
|
async function updateKonto(
|
||||||
id: number,
|
id: number,
|
||||||
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string }
|
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Budget validation for sub-pots
|
// Budget validation for sub-pots
|
||||||
@@ -527,7 +537,27 @@ async function updateKonto(
|
|||||||
if (data.budget_anlagen !== undefined) { fields.push(`budget_anlagen = $${idx++}`); values.push(data.budget_anlagen); }
|
if (data.budget_anlagen !== undefined) { fields.push(`budget_anlagen = $${idx++}`); values.push(data.budget_anlagen); }
|
||||||
if (data.budget_instandhaltung !== undefined) { fields.push(`budget_instandhaltung = $${idx++}`); values.push(data.budget_instandhaltung); }
|
if (data.budget_instandhaltung !== undefined) { fields.push(`budget_instandhaltung = $${idx++}`); values.push(data.budget_instandhaltung); }
|
||||||
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
|
if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
|
||||||
|
if ('kategorie_id' in data) { fields.push(`kategorie_id = $${idx++}`); values.push(data.kategorie_id ?? null); }
|
||||||
|
if (data.budget_typ !== undefined) { fields.push(`budget_typ = $${idx++}`); values.push(data.budget_typ); }
|
||||||
|
if (data.budget_gesamt !== undefined) { fields.push(`budget_gesamt = $${idx++}`); values.push(data.budget_gesamt); }
|
||||||
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||||
|
|
||||||
|
// Child konten must inherit parent's budget_typ
|
||||||
|
if (data.budget_typ !== undefined) {
|
||||||
|
const currentRow = await pool.query(`SELECT parent_id FROM buchhaltung_konten WHERE id = $1`, [id]);
|
||||||
|
const parentId = data.parent_id !== undefined ? data.parent_id : currentRow.rows[0]?.parent_id;
|
||||||
|
if (parentId) {
|
||||||
|
const parentRow = await pool.query(`SELECT budget_typ FROM buchhaltung_konten WHERE id = $1`, [parentId]);
|
||||||
|
if (parentRow.rows[0]) {
|
||||||
|
// Override budget_typ with parent's value
|
||||||
|
const btIdx = fields.findIndex(f => f.startsWith('budget_typ'));
|
||||||
|
if (btIdx !== -1) {
|
||||||
|
values[btIdx] = parentRow.rows[0].budget_typ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
values.push(id);
|
values.push(id);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
`UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
@@ -1231,6 +1261,94 @@ async function exportTransaktionenCsv(haushaltsjahrId: number): Promise<string>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Erstattungen (Reimbursements)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Transaktion {
|
||||||
|
id: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createErstattung(data: {
|
||||||
|
konto_id: number;
|
||||||
|
bankkonto_id: number;
|
||||||
|
betrag: number;
|
||||||
|
datum: string;
|
||||||
|
beschreibung?: string;
|
||||||
|
empfaenger_auftraggeber?: string;
|
||||||
|
verwendungszweck?: string;
|
||||||
|
ausgabe_ids: number[];
|
||||||
|
erstellt_von?: number;
|
||||||
|
}): Promise<Transaktion> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Look up haushaltsjahr_id from the konto
|
||||||
|
const kontoResult = await client.query(
|
||||||
|
`SELECT haushaltsjahr_id FROM buchhaltung_konten WHERE id = $1`,
|
||||||
|
[data.konto_id]
|
||||||
|
);
|
||||||
|
const haushaltsjahrId = kontoResult.rows[0]?.haushaltsjahr_id;
|
||||||
|
|
||||||
|
const txResult = await client.query(
|
||||||
|
`INSERT INTO buchhaltung_transaktionen (typ, konto_id, bankkonto_id, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, erstellt_von, haushaltsjahr_id, status)
|
||||||
|
VALUES ('einnahme', $1, $2, $3, $4, $5, $6, $7, $8, $9, 'entwurf')
|
||||||
|
RETURNING *`,
|
||||||
|
[data.konto_id, data.bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.empfaenger_auftraggeber || null, data.verwendungszweck || null, data.erstellt_von || null, haushaltsjahrId]
|
||||||
|
);
|
||||||
|
const tx = txResult.rows[0];
|
||||||
|
|
||||||
|
for (const ausgabeId of data.ausgabe_ids) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO buchhaltung_erstattung_zuordnungen (erstattung_transaktion_id, ausgabe_transaktion_id) VALUES ($1, $2)`,
|
||||||
|
[tx.id, ausgabeId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return tx;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error('BuchhaltungService.createErstattung failed', { error });
|
||||||
|
throw new Error('Erstattung konnte nicht erstellt werden');
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getErstattungLinks(transaktionId: number): Promise<{
|
||||||
|
erstattung?: any;
|
||||||
|
ausgaben?: any[];
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// If this transaction is an Ausgabe, find its reimbursement
|
||||||
|
const erstattungResult = await pool.query(
|
||||||
|
`SELECT t.* FROM buchhaltung_transaktionen t
|
||||||
|
JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.erstattung_transaktion_id
|
||||||
|
WHERE ez.ausgabe_transaktion_id = $1`,
|
||||||
|
[transaktionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// If this transaction is an Erstattung, find its linked Ausgaben
|
||||||
|
const ausgabenResult = await pool.query(
|
||||||
|
`SELECT t.* FROM buchhaltung_transaktionen t
|
||||||
|
JOIN buchhaltung_erstattung_zuordnungen ez ON t.id = ez.ausgabe_transaktion_id
|
||||||
|
WHERE ez.erstattung_transaktion_id = $1`,
|
||||||
|
[transaktionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
erstattung: erstattungResult.rows[0] || undefined,
|
||||||
|
ausgaben: ausgabenResult.rows,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungService.getErstattungLinks failed', { error, transaktionId });
|
||||||
|
throw new Error('Erstattungsverknüpfungen konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Export
|
// Export
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1286,6 +1404,8 @@ const buchhaltungService = {
|
|||||||
updateWiederkehrend,
|
updateWiederkehrend,
|
||||||
deleteWiederkehrend,
|
deleteWiederkehrend,
|
||||||
exportTransaktionenCsv,
|
exportTransaktionenCsv,
|
||||||
|
createErstattung,
|
||||||
|
getErstattungLinks,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buchhaltungService;
|
export default buchhaltungService;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -205,6 +206,7 @@ export default function BestellungDetail() {
|
|||||||
const canApprove = hasPermission('bestellungen:approve');
|
const canApprove = hasPermission('bestellungen:approve');
|
||||||
const canExport = hasPermission('bestellungen:export');
|
const canExport = hasPermission('bestellungen:export');
|
||||||
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
|
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
|
||||||
|
const allCostsEntered = positionen.length === 0 || positionen.every(p => p.einzelpreis != null && Number(p.einzelpreis) > 0);
|
||||||
|
|
||||||
// All statuses except current, for force override
|
// All statuses except current, for force override
|
||||||
const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', 'abgeschlossen'];
|
const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', 'abgeschlossen'];
|
||||||
@@ -766,7 +768,11 @@ export default function BestellungDetail() {
|
|||||||
|
|
||||||
{/* ── Status Action ── */}
|
{/* ── Status Action ── */}
|
||||||
{(canManageOrders || canCreate || canApprove) && (
|
{(canManageOrders || canCreate || canApprove) && (
|
||||||
<Box sx={{ mb: 3, display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
|
{validTransitions.includes('abgeschlossen' as BestellungStatus) && !allCostsEntered && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen haben Kosten. Bitte Einzelpreise eintragen, bevor die Bestellung abgeschlossen wird.</Alert>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
{validTransitions
|
{validTransitions
|
||||||
.filter((s) => {
|
.filter((s) => {
|
||||||
// Approve/reject transitions from wartet_auf_genehmigung require canApprove
|
// Approve/reject transitions from wartet_auf_genehmigung require canApprove
|
||||||
@@ -787,11 +793,13 @@ export default function BestellungDetail() {
|
|||||||
? 'Ablehnen'
|
? 'Ablehnen'
|
||||||
: `Status: ${BESTELLUNG_STATUS_LABELS[s]}`;
|
: `Status: ${BESTELLUNG_STATUS_LABELS[s]}`;
|
||||||
const color = isApprove ? 'success' : isReject ? 'error' : 'primary';
|
const color = isApprove ? 'success' : isReject ? 'error' : 'primary';
|
||||||
|
const isAbgeschlossen = s === 'abgeschlossen';
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={s}
|
key={s}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color={color as 'success' | 'error' | 'primary'}
|
color={color as 'success' | 'error' | 'primary'}
|
||||||
|
disabled={isAbgeschlossen && !allCostsEntered}
|
||||||
onClick={() => { setStatusForce(false); setStatusConfirmTarget(s); }}
|
onClick={() => { setStatusForce(false); setStatusConfirmTarget(s); }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -831,6 +839,7 @@ export default function BestellungDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Delivery Progress ── */}
|
{/* ── Delivery Progress ── */}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -34,6 +35,8 @@ import {
|
|||||||
TableSortLabel,
|
TableSortLabel,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
@@ -69,6 +72,8 @@ import type {
|
|||||||
AusgabenTyp,
|
AusgabenTyp,
|
||||||
WiederkehrendBuchung, WiederkehrendFormData,
|
WiederkehrendBuchung, WiederkehrendFormData,
|
||||||
WiederkehrendIntervall,
|
WiederkehrendIntervall,
|
||||||
|
BudgetTyp,
|
||||||
|
ErstattungFormData,
|
||||||
} from '../types/buchhaltung.types';
|
} from '../types/buchhaltung.types';
|
||||||
import {
|
import {
|
||||||
TRANSAKTION_STATUS_LABELS,
|
TRANSAKTION_STATUS_LABELS,
|
||||||
@@ -83,6 +88,8 @@ function fmtEur(val: number) {
|
|||||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dividerLeft = { borderLeft: '2px solid', borderColor: 'divider' } as const;
|
||||||
|
|
||||||
function fmtDate(val: string) {
|
function fmtDate(val: string) {
|
||||||
return new Date(val).toLocaleDateString('de-DE');
|
return new Date(val).toLocaleDateString('de-DE');
|
||||||
}
|
}
|
||||||
@@ -199,7 +206,7 @@ function KontoDialog({
|
|||||||
onSave: (data: KontoFormData) => void;
|
onSave: (data: KontoFormData) => void;
|
||||||
externalError?: string | null;
|
externalError?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: 0, bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, parent_id: null, kategorie_id: null, notizen: '' };
|
const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: 0, bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, budget_typ: 'detailliert', budget_gesamt: 0, parent_id: null, kategorie_id: null, notizen: '' };
|
||||||
const [form, setForm] = useState<KontoFormData>(empty);
|
const [form, setForm] = useState<KontoFormData>(empty);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -234,7 +241,7 @@ function KontoDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setForm({ haushaltsjahr_id: haushaltsjahrId, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_gwg: existing.budget_gwg, budget_anlagen: existing.budget_anlagen, budget_instandhaltung: existing.budget_instandhaltung, parent_id: existing.parent_id, kategorie_id: existing.kategorie_id ?? null, notizen: existing.notizen || '' });
|
setForm({ haushaltsjahr_id: haushaltsjahrId, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_gwg: existing.budget_gwg, budget_anlagen: existing.budget_anlagen, budget_instandhaltung: existing.budget_instandhaltung, budget_typ: existing.budget_typ || 'detailliert', budget_gesamt: existing.budget_gesamt || 0, parent_id: existing.parent_id, kategorie_id: existing.kategorie_id ?? null, notizen: existing.notizen || '' });
|
||||||
} else {
|
} else {
|
||||||
setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId });
|
setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId });
|
||||||
}
|
}
|
||||||
@@ -290,6 +297,24 @@ function KontoDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
|
{!form.parent_id && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>Budget-Typ</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.budget_typ || 'detailliert'}
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
onChange={(_, val) => { if (val) setForm(f => ({ ...f, budget_typ: val as BudgetTyp })); }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="detailliert">Detailliert</ToggleButton>
|
||||||
|
<ToggleButton value="einfach">Einfach</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{(form.budget_typ || 'detailliert') === 'einfach' ? (
|
||||||
|
<TextField label="Budget Gesamt (€)" type="number" value={form.budget_gesamt ?? 0} onChange={e => setForm(f => ({ ...f, budget_gesamt: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<TextField label="GWG Budget (€)" type="number" value={form.budget_gwg} onChange={e => setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
<TextField label="GWG Budget (€)" type="number" value={form.budget_gwg} onChange={e => setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
||||||
{selectedParent && siblingBudgets && (
|
{selectedParent && siblingBudgets && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: -1 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ mt: -1 }}>
|
||||||
@@ -308,6 +333,8 @@ function KontoDialog({
|
|||||||
Eltern-Budget: {fmtEur(selectedParent.budget_instandhaltung)}, vergeben: {fmtEur(siblingBudgets.instandhaltung)}, verfügbar: {fmtEur(selectedParent.budget_instandhaltung - siblingBudgets.instandhaltung)}
|
Eltern-Budget: {fmtEur(selectedParent.budget_instandhaltung)}, vergeben: {fmtEur(siblingBudgets.instandhaltung)}, verfügbar: {fmtEur(selectedParent.budget_instandhaltung - siblingBudgets.instandhaltung)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<TextField label="Notizen" value={form.notizen} onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
|
<TextField label="Notizen" value={form.notizen} onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
|
||||||
{saveError && <Alert severity="error" onClose={() => setSaveError(null)}>{saveError}</Alert>}
|
{saveError && <Alert severity="error" onClose={() => setSaveError(null)}>{saveError}</Alert>}
|
||||||
{!saveError && externalError && <Alert severity="error">{externalError}</Alert>}
|
{!saveError && externalError && <Alert severity="error">{externalError}</Alert>}
|
||||||
@@ -385,7 +412,10 @@ function TransaktionDialog({
|
|||||||
<MenuItem value="einnahme">Einnahme</MenuItem>
|
<MenuItem value="einnahme">Einnahme</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{form.typ === 'ausgabe' && (
|
{form.typ === 'ausgabe' && (() => {
|
||||||
|
const selectedKonto = konten.find(k => k.id === form.konto_id);
|
||||||
|
const isEinfach = selectedKonto && (selectedKonto.budget_typ || 'detailliert') === 'einfach';
|
||||||
|
return !isEinfach ? (
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Ausgaben-Typ</InputLabel>
|
<InputLabel>Ausgaben-Typ</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -399,7 +429,8 @@ function TransaktionDialog({
|
|||||||
<MenuItem value="instandhaltung">Instandhaltung</MenuItem>
|
<MenuItem value="instandhaltung">Instandhaltung</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
) : null;
|
||||||
|
})()}
|
||||||
<TextField label="Betrag (€)" type="number" value={form.betrag} onChange={e => setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required />
|
<TextField label="Betrag (€)" type="number" value={form.betrag} onChange={e => setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required />
|
||||||
<TextField label="Datum" type="date" value={form.datum} onChange={e => setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required />
|
<TextField label="Datum" type="date" value={form.datum} onChange={e => setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required />
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
@@ -455,7 +486,8 @@ function buildTree(flat: KontoTreeNode[]): KontoTreeNode[] {
|
|||||||
|
|
||||||
function KontoRow({ konto, depth = 0, onNavigate }: { konto: KontoTreeNode; depth?: number; onNavigate: (id: number) => void }) {
|
function KontoRow({ konto, depth = 0, onNavigate }: { konto: KontoTreeNode; depth?: number; onNavigate: (id: number) => void }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const totalBudget = konto.budget_gwg + konto.budget_anlagen + konto.budget_instandhaltung;
|
const isEinfach = (konto.budget_typ || 'detailliert') === 'einfach';
|
||||||
|
const totalBudget = isEinfach ? Number(konto.budget_gesamt || 0) : konto.budget_gwg + konto.budget_anlagen + konto.budget_instandhaltung;
|
||||||
const totalSpent = konto.spent_gwg + konto.spent_anlagen + konto.spent_instandhaltung;
|
const totalSpent = konto.spent_gwg + konto.spent_anlagen + konto.spent_instandhaltung;
|
||||||
const utilization = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0;
|
const utilization = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0;
|
||||||
|
|
||||||
@@ -472,15 +504,15 @@ function KontoRow({ konto, depth = 0, onNavigate }: { konto: KontoTreeNode; dept
|
|||||||
sx={{ mt: 0.5, height: 4, borderRadius: 2 }} />
|
sx={{ mt: 0.5, height: 4, borderRadius: 2 }} />
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">{fmtEur(konto.budget_gwg)}</TableCell>
|
<TableCell align="right" sx={dividerLeft}>{isEinfach ? '' : fmtEur(konto.budget_gwg)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(konto.budget_anlagen)}</TableCell>
|
<TableCell align="right">{isEinfach ? '' : fmtEur(konto.budget_anlagen)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(konto.budget_instandhaltung)}</TableCell>
|
<TableCell align="right">{isEinfach ? '' : fmtEur(konto.budget_instandhaltung)}</TableCell>
|
||||||
<TableCell align="right"><strong>{fmtEur(totalBudget)}</strong></TableCell>
|
<TableCell align="right"><strong>{fmtEur(totalBudget)}</strong></TableCell>
|
||||||
<TableCell align="right">{fmtEur(konto.spent_gwg)}</TableCell>
|
<TableCell align="right" sx={dividerLeft}>{isEinfach ? '' : fmtEur(konto.spent_gwg)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(konto.spent_anlagen)}</TableCell>
|
<TableCell align="right">{isEinfach ? '' : fmtEur(konto.spent_anlagen)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(konto.spent_instandhaltung)}</TableCell>
|
<TableCell align="right">{isEinfach ? '' : fmtEur(konto.spent_instandhaltung)}</TableCell>
|
||||||
<TableCell align="right"><strong>{fmtEur(totalSpent)}</strong></TableCell>
|
<TableCell align="right"><strong>{fmtEur(totalSpent)}</strong></TableCell>
|
||||||
<TableCell align="right">{fmtEur(konto.einnahmen_betrag)}</TableCell>
|
<TableCell align="right" sx={dividerLeft}>{fmtEur(konto.einnahmen_betrag)}</TableCell>
|
||||||
<TableCell sx={{ width: 40, px: 0.5 }}>
|
<TableCell sx={{ width: 40, px: 0.5 }}>
|
||||||
{konto.children.length > 0 && (
|
{konto.children.length > 0 && (
|
||||||
<IconButton size="small" onClick={e => { e.stopPropagation(); setOpen(!open); }}>
|
<IconButton size="small" onClick={e => { e.stopPropagation(); setOpen(!open); }}>
|
||||||
@@ -605,15 +637,15 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Konto</TableCell>
|
<TableCell>Konto</TableCell>
|
||||||
<TableCell align="right">Budget GWG</TableCell>
|
<TableCell align="right" sx={dividerLeft}>Budget GWG</TableCell>
|
||||||
<TableCell align="right">Budget Anlagen</TableCell>
|
<TableCell align="right">Budget Anlagen</TableCell>
|
||||||
<TableCell align="right">Budget Instandh.</TableCell>
|
<TableCell align="right">Budget Instandh.</TableCell>
|
||||||
<TableCell align="right">Budget Gesamt</TableCell>
|
<TableCell align="right">Budget Gesamt</TableCell>
|
||||||
<TableCell align="right">Ausgaben GWG</TableCell>
|
<TableCell align="right" sx={dividerLeft}>Ausgaben GWG</TableCell>
|
||||||
<TableCell align="right">Ausgaben Anlagen</TableCell>
|
<TableCell align="right">Ausgaben Anlagen</TableCell>
|
||||||
<TableCell align="right">Ausgaben Instandh.</TableCell>
|
<TableCell align="right">Ausgaben Instandh.</TableCell>
|
||||||
<TableCell align="right">Ausgaben Gesamt</TableCell>
|
<TableCell align="right">Ausgaben Gesamt</TableCell>
|
||||||
<TableCell align="right">Einnahmen</TableCell>
|
<TableCell align="right" sx={dividerLeft}>Einnahmen</TableCell>
|
||||||
<TableCell sx={{ width: 40 }} />
|
<TableCell sx={{ width: 40 }} />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -650,15 +682,15 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
rows.push(
|
rows.push(
|
||||||
<TableRow key={`cat-${key}`} sx={{ bgcolor: 'grey.100', '& td': { fontWeight: 600, fontSize: '0.8rem' } }}>
|
<TableRow key={`cat-${key}`} sx={{ bgcolor: 'grey.100', '& td': { fontWeight: 600, fontSize: '0.8rem' } }}>
|
||||||
<TableCell>{katName}</TableCell>
|
<TableCell>{katName}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(catBudgetGwg)}</TableCell>
|
<TableCell align="right" sx={dividerLeft}>{fmtEur(catBudgetGwg)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(catBudgetAnl)}</TableCell>
|
<TableCell align="right">{fmtEur(catBudgetAnl)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(catBudgetInst)}</TableCell>
|
<TableCell align="right">{fmtEur(catBudgetInst)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(catBudgetGwg + catBudgetAnl + catBudgetInst)}</TableCell>
|
<TableCell align="right">{fmtEur(catBudgetGwg + catBudgetAnl + catBudgetInst)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(catSpentGwg)}</TableCell>
|
<TableCell align="right" sx={dividerLeft}>{fmtEur(catSpentGwg)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(catSpentAnl)}</TableCell>
|
<TableCell align="right">{fmtEur(catSpentAnl)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(catSpentInst)}</TableCell>
|
<TableCell align="right">{fmtEur(catSpentInst)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(catSpentGwg + catSpentAnl + catSpentInst)}</TableCell>
|
<TableCell align="right">{fmtEur(catSpentGwg + catSpentAnl + catSpentInst)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(catEinnahmen)}</TableCell>
|
<TableCell align="right" sx={dividerLeft}>{fmtEur(catEinnahmen)}</TableCell>
|
||||||
<TableCell />
|
<TableCell />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
@@ -672,15 +704,15 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
{tree.length > 0 && (
|
{tree.length > 0 && (
|
||||||
<TableRow sx={{ bgcolor: 'action.hover', '& td': { fontWeight: 700 } }}>
|
<TableRow sx={{ bgcolor: 'action.hover', '& td': { fontWeight: 700 } }}>
|
||||||
<TableCell>Gesamt</TableCell>
|
<TableCell>Gesamt</TableCell>
|
||||||
<TableCell align="right">{fmtEur(sumBudgetGwg)}</TableCell>
|
<TableCell align="right" sx={dividerLeft}>{fmtEur(sumBudgetGwg)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(sumBudgetAnlagen)}</TableCell>
|
<TableCell align="right">{fmtEur(sumBudgetAnlagen)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(sumBudgetInst)}</TableCell>
|
<TableCell align="right">{fmtEur(sumBudgetInst)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(sumBudgetGesamt)}</TableCell>
|
<TableCell align="right">{fmtEur(sumBudgetGesamt)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(sumSpentGwg)}</TableCell>
|
<TableCell align="right" sx={dividerLeft}>{fmtEur(sumSpentGwg)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(sumSpentAnlagen)}</TableCell>
|
<TableCell align="right">{fmtEur(sumSpentAnlagen)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(sumSpentInst)}</TableCell>
|
<TableCell align="right">{fmtEur(sumSpentInst)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(sumSpentGesamt)}</TableCell>
|
<TableCell align="right">{fmtEur(sumSpentGesamt)}</TableCell>
|
||||||
<TableCell align="right">{fmtEur(sumEinnahmen)}</TableCell>
|
<TableCell align="right" sx={dividerLeft}>{fmtEur(sumEinnahmen)}</TableCell>
|
||||||
<TableCell />
|
<TableCell />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -696,6 +728,141 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Erstattung Dialog ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ErstattungDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
konten,
|
||||||
|
bankkonten,
|
||||||
|
transaktionen,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
konten: Konto[];
|
||||||
|
bankkonten: Bankkonto[];
|
||||||
|
transaktionen: Transaktion[];
|
||||||
|
onSave: (data: ErstattungFormData) => void;
|
||||||
|
}) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const [selectedKonto, setSelectedKonto] = useState<number | null>(null);
|
||||||
|
const [selectedBank, setSelectedBank] = useState<number | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [betrag, setBetrag] = useState(0);
|
||||||
|
const [datum, setDatum] = useState(today);
|
||||||
|
const [beschreibung, setBeschreibung] = useState('');
|
||||||
|
const [empfaenger, setEmpfaenger] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedKonto(null);
|
||||||
|
setSelectedBank(null);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setBetrag(0);
|
||||||
|
setDatum(today);
|
||||||
|
setBeschreibung('');
|
||||||
|
setEmpfaenger('');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const ausgabeTransaktionen = transaktionen.filter(
|
||||||
|
t => t.typ === 'ausgabe' && t.status === 'gebucht' && (!selectedKonto || t.konto_id === selectedKonto)
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoSum = [...selectedIds].reduce((sum, id) => {
|
||||||
|
const t = transaktionen.find(tx => tx.id === id);
|
||||||
|
return sum + (t ? Number(t.betrag) : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBetrag(autoSum);
|
||||||
|
}, [autoSum]);
|
||||||
|
|
||||||
|
const toggleId = (id: number) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>Erstattung erfassen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel>Konto</InputLabel>
|
||||||
|
<Select value={selectedKonto ?? ''} label="Konto" onChange={e => { setSelectedKonto(e.target.value ? Number(e.target.value) : null); setSelectedIds(new Set()); }}>
|
||||||
|
<MenuItem value=""><em>Alle Konten</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={selectedBank ?? ''} label="Bankkonto" onChange={e => setSelectedBank(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>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2">Ausgabe-Transaktionen auswählen</Typography>
|
||||||
|
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
|
||||||
|
<Table size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell padding="checkbox" />
|
||||||
|
<TableCell>Datum</TableCell>
|
||||||
|
<TableCell>Beschreibung</TableCell>
|
||||||
|
<TableCell align="right">Betrag</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{ausgabeTransaktionen.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={4} align="center"><Typography color="text.secondary" variant="body2">Keine Ausgaben gefunden</Typography></TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{ausgabeTransaktionen.map(t => (
|
||||||
|
<TableRow key={t.id} hover onClick={() => toggleId(t.id)} sx={{ cursor: 'pointer' }}>
|
||||||
|
<TableCell padding="checkbox"><Checkbox checked={selectedIds.has(t.id)} size="small" /></TableCell>
|
||||||
|
<TableCell>{fmtDate(t.datum)}</TableCell>
|
||||||
|
<TableCell>{t.beschreibung || t.empfaenger_auftraggeber || '–'}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(Number(t.betrag))}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TextField label="Erstattungsbetrag (€)" type="number" value={betrag} onChange={e => setBetrag(parseFloat(e.target.value) || 0)} inputProps={{ step: '0.01', min: '0' }} required />
|
||||||
|
<TextField label="Datum" type="date" value={datum} onChange={e => setDatum(e.target.value)} InputLabelProps={{ shrink: true }} required />
|
||||||
|
<TextField label="Beschreibung" value={beschreibung} onChange={e => setBeschreibung(e.target.value)} />
|
||||||
|
<TextField label="Empfänger" value={empfaenger} onChange={e => setEmpfaenger(e.target.value)} />
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!selectedKonto || !betrag || !datum || selectedIds.size === 0}
|
||||||
|
onClick={() => onSave({
|
||||||
|
konto_id: selectedKonto!,
|
||||||
|
bankkonto_id: selectedBank,
|
||||||
|
betrag,
|
||||||
|
datum,
|
||||||
|
beschreibung: beschreibung || undefined,
|
||||||
|
empfaenger_auftraggeber: empfaenger || undefined,
|
||||||
|
quell_transaktion_ids: [...selectedIds],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Erstattung erstellen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tab 1: Transaktionen ─────────────────────────────────────────────────────
|
// ─── Tab 1: Transaktionen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||||
@@ -709,6 +876,21 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
const [filters, setFilters] = useState<TransaktionFilters>({ haushaltsjahr_id: selectedJahrId || undefined });
|
const [filters, setFilters] = useState<TransaktionFilters>({ haushaltsjahr_id: selectedJahrId || undefined });
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [filterAusgabenTyp, setFilterAusgabenTyp] = useState('');
|
const [filterAusgabenTyp, setFilterAusgabenTyp] = useState('');
|
||||||
|
const [txSubTab, setTxSubTab] = useState(0);
|
||||||
|
|
||||||
|
// ── Erstattung state ──
|
||||||
|
const [erstattungOpen, setErstattungOpen] = useState(false);
|
||||||
|
|
||||||
|
// ── Wiederkehrend state ──
|
||||||
|
const [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false });
|
||||||
|
|
||||||
|
const { data: kontenFlat = [] } = useQuery({
|
||||||
|
queryKey: ['buchhaltung-konten', selectedJahrId],
|
||||||
|
queryFn: () => buchhaltungApi.getKonten(selectedJahrId!),
|
||||||
|
enabled: selectedJahrId != null,
|
||||||
|
});
|
||||||
|
const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten });
|
||||||
|
const { data: wiederkehrend = [] } = useQuery({ queryKey: ['buchhaltung-wiederkehrend'], queryFn: buchhaltungApi.getWiederkehrend });
|
||||||
|
|
||||||
const { data: transaktionen = [], isLoading } = useQuery({
|
const { data: transaktionen = [], isLoading } = useQuery({
|
||||||
queryKey: ['buchhaltung-transaktionen', filters],
|
queryKey: ['buchhaltung-transaktionen', filters],
|
||||||
@@ -757,6 +939,32 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
onError: () => showError('Ablehnung fehlgeschlagen'),
|
onError: () => showError('Ablehnung fehlgeschlagen'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Wiederkehrend mutations ──
|
||||||
|
const canManage = hasPermission('buchhaltung:manage_accounts');
|
||||||
|
|
||||||
|
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'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Erstattung mutation ──
|
||||||
|
const createErstattungMut = useMutation({
|
||||||
|
mutationFn: buchhaltungApi.createErstattung,
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); setErstattungOpen(false); showSuccess('Erstattung erstellt'); },
|
||||||
|
onError: () => showError('Erstattung konnte nicht erstellt werden'),
|
||||||
|
});
|
||||||
|
|
||||||
const handleExportCsv = async () => {
|
const handleExportCsv = async () => {
|
||||||
if (!filters.haushaltsjahr_id) { showError('Bitte ein Haushaltsjahr auswählen'); return; }
|
if (!filters.haushaltsjahr_id) { showError('Bitte ein Haushaltsjahr auswählen'); return; }
|
||||||
try {
|
try {
|
||||||
@@ -797,6 +1005,15 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||||
|
<Tabs value={txSubTab} onChange={(_, v) => setTxSubTab(v)}>
|
||||||
|
<Tab label="Transaktionen" />
|
||||||
|
<Tab label="Wiederkehrende Buchungen" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{txSubTab === 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
|
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
|
||||||
@@ -848,6 +1065,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission('buchhaltung:create') && (
|
||||||
|
<Button size="small" variant="outlined" onClick={() => setErstattungOpen(true)}>
|
||||||
|
Erstattung erfassen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -970,6 +1192,74 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
selectedJahrId={selectedJahrId}
|
selectedJahrId={selectedJahrId}
|
||||||
onSave={data => createMut.mutate(data)}
|
onSave={data => createMut.mutate(data)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ErstattungDialog
|
||||||
|
open={erstattungOpen}
|
||||||
|
onClose={() => setErstattungOpen(false)}
|
||||||
|
konten={kontenFlat}
|
||||||
|
bankkonten={bankkonten}
|
||||||
|
transaktionen={transaktionen}
|
||||||
|
onSave={data => createErstattungMut.mutate(data)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{txSubTab === 1 && (
|
||||||
|
<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={kontenFlat}
|
||||||
|
bankkonten={bankkonten}
|
||||||
|
existing={wiederkehrendDialog.existing}
|
||||||
|
onSave={data => wiederkehrendDialog.existing
|
||||||
|
? updateWiederkehrendMut.mutate({ id: wiederkehrendDialog.existing.id, data })
|
||||||
|
: createWiederkehrendMut.mutate(data)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1113,7 +1403,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
const [kontoSaveError, setKontoSaveError] = useState<string | null>(null);
|
const [kontoSaveError, setKontoSaveError] = useState<string | null>(null);
|
||||||
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: kontenFlat = [] } = useQuery({
|
const { data: kontenFlat = [] } = useQuery({
|
||||||
queryKey: ['buchhaltung-konten', selectedJahrId],
|
queryKey: ['buchhaltung-konten', selectedJahrId],
|
||||||
@@ -1128,7 +1417,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
const kontenTree = buildTree(kontenTreeData);
|
const kontenTree = buildTree(kontenTreeData);
|
||||||
const konten = kontenFlat;
|
const konten = kontenFlat;
|
||||||
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 { data: kategorien = [] } = useQuery({
|
const { data: kategorien = [] } = useQuery({
|
||||||
queryKey: ['buchhaltung-kategorien', selectedJahrId],
|
queryKey: ['buchhaltung-kategorien', selectedJahrId],
|
||||||
queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!),
|
queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!),
|
||||||
@@ -1199,22 +1487,6 @@ 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>
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||||
@@ -1222,7 +1494,6 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
<Tab label="Konten" />
|
<Tab label="Konten" />
|
||||||
<Tab label="Bankkonten" />
|
<Tab label="Bankkonten" />
|
||||||
<Tab label="Haushaltsjahre" />
|
<Tab label="Haushaltsjahre" />
|
||||||
<Tab label="Wiederkehrend" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -1433,64 +1704,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,13 @@ export default function BuchhaltungKontoDetail() {
|
|||||||
if (isError || !data) return <DashboardLayout><Alert severity="error">Konto nicht gefunden.</Alert></DashboardLayout>;
|
if (isError || !data) return <DashboardLayout><Alert severity="error">Konto nicht gefunden.</Alert></DashboardLayout>;
|
||||||
|
|
||||||
const { konto, children, transaktionen } = data;
|
const { konto, children, transaktionen } = data;
|
||||||
|
const isEinfach = (konto.budget_typ || 'detailliert') === 'einfach';
|
||||||
const totalEinnahmen = transaktionen
|
const totalEinnahmen = transaktionen
|
||||||
.filter(t => t.typ === 'einnahme' && t.status === 'gebucht')
|
.filter(t => t.typ === 'einnahme' && t.status === 'gebucht')
|
||||||
.reduce((sum, t) => sum + Number(t.betrag), 0);
|
.reduce((sum, t) => sum + Number(t.betrag), 0);
|
||||||
|
const totalAusgaben = transaktionen
|
||||||
|
.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht')
|
||||||
|
.reduce((sum, t) => sum + Number(t.betrag), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@@ -69,6 +73,22 @@ export default function BuchhaltungKontoDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
{isEinfach ? (
|
||||||
|
<>
|
||||||
|
<Grid item xs={12} sm={6} md={4}>
|
||||||
|
<BudgetCard label="Budget Gesamt" budget={konto.budget_gesamt || 0} spent={totalAusgaben} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6} md={4}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">Einnahmen</Typography>
|
||||||
|
<Typography variant="h6" color="success.main">{fmtEur(totalEinnahmen)}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
<BudgetCard label="GWG" budget={konto.budget_gwg} spent={
|
<BudgetCard label="GWG" budget={konto.budget_gwg} spent={
|
||||||
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'gwg').reduce((s, t) => s + Number(t.betrag), 0)
|
transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'gwg').reduce((s, t) => s + Number(t.betrag), 0)
|
||||||
@@ -92,6 +112,8 @@ export default function BuchhaltungKontoDetail() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{children.length > 0 && (
|
{children.length > 0 && (
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import {
|
|||||||
Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select,
|
Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select,
|
||||||
FormControl, InputLabel, Alert, Dialog, DialogTitle,
|
FormControl, InputLabel, Alert, Dialog, DialogTitle,
|
||||||
DialogContent, DialogActions, Skeleton, Divider, LinearProgress, Grid,
|
DialogContent, DialogActions, Skeleton, Divider, LinearProgress, Grid,
|
||||||
|
ToggleButton, ToggleButtonGroup,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material';
|
import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { buchhaltungApi } from '../services/buchhaltung';
|
import { buchhaltungApi } from '../services/buchhaltung';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import type { KontoFormData } from '../types/buchhaltung.types';
|
import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types';
|
||||||
|
|
||||||
const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
|
const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
|
||||||
|
|
||||||
@@ -86,6 +87,8 @@ export default function BuchhaltungKontoManage() {
|
|||||||
budget_gwg: k.budget_gwg,
|
budget_gwg: k.budget_gwg,
|
||||||
budget_anlagen: k.budget_anlagen,
|
budget_anlagen: k.budget_anlagen,
|
||||||
budget_instandhaltung: k.budget_instandhaltung,
|
budget_instandhaltung: k.budget_instandhaltung,
|
||||||
|
budget_typ: k.budget_typ || 'detailliert',
|
||||||
|
budget_gesamt: k.budget_gesamt || 0,
|
||||||
parent_id: k.parent_id ?? undefined,
|
parent_id: k.parent_id ?? undefined,
|
||||||
kategorie_id: k.kategorie_id ?? undefined,
|
kategorie_id: k.kategorie_id ?? undefined,
|
||||||
notizen: k.notizen ?? '',
|
notizen: k.notizen ?? '',
|
||||||
@@ -157,6 +160,8 @@ export default function BuchhaltungKontoManage() {
|
|||||||
budget_gwg: k.budget_gwg,
|
budget_gwg: k.budget_gwg,
|
||||||
budget_anlagen: k.budget_anlagen,
|
budget_anlagen: k.budget_anlagen,
|
||||||
budget_instandhaltung: k.budget_instandhaltung,
|
budget_instandhaltung: k.budget_instandhaltung,
|
||||||
|
budget_typ: k.budget_typ || 'detailliert',
|
||||||
|
budget_gesamt: k.budget_gesamt || 0,
|
||||||
parent_id: k.parent_id ?? undefined,
|
parent_id: k.parent_id ?? undefined,
|
||||||
kategorie_id: k.kategorie_id ?? undefined,
|
kategorie_id: k.kategorie_id ?? undefined,
|
||||||
notizen: k.notizen ?? '',
|
notizen: k.notizen ?? '',
|
||||||
@@ -231,14 +236,43 @@ export default function BuchhaltungKontoManage() {
|
|||||||
<Divider sx={{ mb: 1.5 }} />
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
{!konto.parent_id && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>Budget-Typ</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={form.budget_typ || 'detailliert'}
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
onChange={(_, val) => { if (val) setForm(f => ({ ...f, budget_typ: val as BudgetTyp })); }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="detailliert">Detailliert</ToggleButton>
|
||||||
|
<ToggleButton value="einfach">Einfach</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{(form.budget_typ || 'detailliert') === 'einfach' ? (
|
||||||
|
<TextField label="Budget Gesamt (€)" type="number" value={form.budget_gesamt ?? 0} onChange={e => setForm(f => ({ ...f, budget_gesamt: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<TextField label="Budget GWG (€)" type="number" value={form.budget_gwg ?? 0} onChange={e => setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small"
|
<TextField label="Budget GWG (€)" type="number" value={form.budget_gwg ?? 0} onChange={e => setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small"
|
||||||
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_gwg)}, vergeben: ${fmtEur(siblingBudgets.gwg)}, verfügbar: ${fmtEur(parentKonto.budget_gwg - siblingBudgets.gwg)}` : undefined} />
|
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_gwg)}, vergeben: ${fmtEur(siblingBudgets.gwg)}, verfügbar: ${fmtEur(parentKonto.budget_gwg - siblingBudgets.gwg)}` : undefined} />
|
||||||
<TextField label="Budget Anlagen (€)" type="number" value={form.budget_anlagen ?? 0} onChange={e => setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small"
|
<TextField label="Budget Anlagen (€)" type="number" value={form.budget_anlagen ?? 0} onChange={e => setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small"
|
||||||
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_anlagen)}, vergeben: ${fmtEur(siblingBudgets.anlagen)}, verfügbar: ${fmtEur(parentKonto.budget_anlagen - siblingBudgets.anlagen)}` : undefined} />
|
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_anlagen)}, vergeben: ${fmtEur(siblingBudgets.anlagen)}, verfügbar: ${fmtEur(parentKonto.budget_anlagen - siblingBudgets.anlagen)}` : undefined} />
|
||||||
<TextField label="Budget Instandhaltung (€)" type="number" value={form.budget_instandhaltung ?? 0} onChange={e => setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small"
|
<TextField label="Budget Instandhaltung (€)" type="number" value={form.budget_instandhaltung ?? 0} onChange={e => setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} fullWidth size="small"
|
||||||
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_instandhaltung)}, vergeben: ${fmtEur(siblingBudgets.instandhaltung)}, verfügbar: ${fmtEur(parentKonto.budget_instandhaltung - siblingBudgets.instandhaltung)}` : undefined} />
|
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_instandhaltung)}, vergeben: ${fmtEur(siblingBudgets.instandhaltung)}, verfügbar: ${fmtEur(parentKonto.budget_instandhaltung - siblingBudgets.instandhaltung)}` : undefined} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{saveError && <Alert severity="error" onClose={() => setSaveError(null)}>{saveError}</Alert>}
|
{saveError && <Alert severity="error" onClose={() => setSaveError(null)}>{saveError}</Alert>}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(konto.budget_typ || 'detailliert') === 'einfach' ? (
|
||||||
|
<>
|
||||||
|
<BudgetBar label="Gesamt" budget={konto.budget_gesamt || 0} spent={spentGwg + spentAnlagen + spentInst} />
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<FieldRow label="Budget Gesamt" value={fmtEur(konto.budget_gesamt || 0)} />
|
||||||
|
<FieldRow label="Ausgaben Gesamt" value={fmtEur(spentGwg + spentAnlagen + spentInst)} />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<BudgetBar label="GWG" budget={konto.budget_gwg} spent={spentGwg} />
|
<BudgetBar label="GWG" budget={konto.budget_gwg} spent={spentGwg} />
|
||||||
@@ -249,6 +283,8 @@ export default function BuchhaltungKontoManage() {
|
|||||||
<FieldRow label="Ausgaben Gesamt" value={fmtEur(spentGwg + spentAnlagen + spentInst)} />
|
<FieldRow label="Ausgaben Gesamt" value={fmtEur(spentGwg + spentAnlagen + spentInst)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
WiederkehrendBuchung, WiederkehrendFormData,
|
WiederkehrendBuchung, WiederkehrendFormData,
|
||||||
Freigabe,
|
Freigabe,
|
||||||
Kategorie,
|
Kategorie,
|
||||||
|
ErstattungFormData, ErstattungLinks,
|
||||||
} from '../types/buchhaltung.types';
|
} from '../types/buchhaltung.types';
|
||||||
|
|
||||||
export const buchhaltungApi = {
|
export const buchhaltungApi = {
|
||||||
@@ -196,4 +197,14 @@ export const buchhaltungApi = {
|
|||||||
const r = await api.patch(`/api/buchhaltung/freigaben/${id}/ablehnen`, { kommentar });
|
const r = await api.patch(`/api/buchhaltung/freigaben/${id}/ablehnen`, { kommentar });
|
||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Erstattungen ─────────────────────────────────────────────────────────────
|
||||||
|
createErstattung: async (data: ErstattungFormData): Promise<Transaktion> => {
|
||||||
|
const r = await api.post('/api/buchhaltung/erstattungen', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
getErstattungLinks: async (transaktionId: number): Promise<ErstattungLinks> => {
|
||||||
|
const r = await api.get(`/api/buchhaltung/transaktionen/${transaktionId}/erstattung-links`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt';
|
|||||||
export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
|
export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
|
||||||
export type PlanungStatus = 'entwurf' | 'aktiv' | 'abgeschlossen';
|
export type PlanungStatus = 'entwurf' | 'aktiv' | 'abgeschlossen';
|
||||||
export type AusgabenTyp = 'gwg' | 'anlagen' | 'instandhaltung';
|
export type AusgabenTyp = 'gwg' | 'anlagen' | 'instandhaltung';
|
||||||
|
export type BudgetTyp = 'detailliert' | 'einfach';
|
||||||
|
|
||||||
export const AUSGABEN_TYP_LABELS: Record<AusgabenTyp, string> = {
|
export const AUSGABEN_TYP_LABELS: Record<AusgabenTyp, string> = {
|
||||||
gwg: 'GWG',
|
gwg: 'GWG',
|
||||||
@@ -98,6 +99,8 @@ export interface Konto {
|
|||||||
budget_gwg: number;
|
budget_gwg: number;
|
||||||
budget_anlagen: number;
|
budget_anlagen: number;
|
||||||
budget_instandhaltung: number;
|
budget_instandhaltung: number;
|
||||||
|
budget_typ: BudgetTyp;
|
||||||
|
budget_gesamt: number;
|
||||||
notizen: string | null;
|
notizen: string | null;
|
||||||
aktiv: boolean;
|
aktiv: boolean;
|
||||||
erstellt_von: string | null;
|
erstellt_von: string | null;
|
||||||
@@ -235,6 +238,8 @@ export interface KontoFormData {
|
|||||||
budget_gwg: number;
|
budget_gwg: number;
|
||||||
budget_anlagen: number;
|
budget_anlagen: number;
|
||||||
budget_instandhaltung: number;
|
budget_instandhaltung: number;
|
||||||
|
budget_typ?: BudgetTyp;
|
||||||
|
budget_gesamt?: number;
|
||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
kategorie_id?: number | null;
|
kategorie_id?: number | null;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
@@ -286,3 +291,18 @@ export interface KontoDetailResponse {
|
|||||||
children: KontoTreeNode[];
|
children: KontoTreeNode[];
|
||||||
transaktionen: Transaktion[];
|
transaktionen: Transaktion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ErstattungFormData {
|
||||||
|
konto_id: number;
|
||||||
|
bankkonto_id: number | null;
|
||||||
|
betrag: number;
|
||||||
|
datum: string;
|
||||||
|
beschreibung?: string;
|
||||||
|
empfaenger_auftraggeber?: string;
|
||||||
|
quell_transaktion_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErstattungLinks {
|
||||||
|
erstattung_transaktion_id: number | null;
|
||||||
|
quell_transaktion_ids: number[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user