feat(buchhaltung): add categories, recurring tx scheduling, sub-pot budget validation, and UX polish
This commit is contained in:
@@ -6,6 +6,55 @@ const param = (req: Request, key: string): string => req.params[key] as string;
|
|||||||
|
|
||||||
class BuchhaltungController {
|
class BuchhaltungController {
|
||||||
|
|
||||||
|
// ── Kategorien ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listKategorien(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 data = await buchhaltungService.getKategorien(haushaltsjahrId);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungController.listKategorien', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createKategorie(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.createKategorie(req.body);
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungController.createKategorie', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateKategorie(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.updateKategorie(id, req.body);
|
||||||
|
if (!data) { res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' }); return; }
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungController.updateKategorie', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteKategorie(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.deleteKategorie(id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungController.deleteKategorie', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Haushaltsjahre ──────────────────────────────────────────────────────────
|
// ── Haushaltsjahre ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async listHaushaltsjahre(_req: Request, res: Response): Promise<void> {
|
async listHaushaltsjahre(_req: Request, res: Response): Promise<void> {
|
||||||
@@ -181,9 +230,10 @@ class BuchhaltungController {
|
|||||||
const data = await buchhaltungService.updateKonto(id, req.body);
|
const data = await buchhaltungService.updateKonto(id, req.body);
|
||||||
if (!data) { res.status(404).json({ success: false, message: 'Konto nicht gefunden' }); return; }
|
if (!data) { res.status(404).json({ success: false, message: 'Konto nicht gefunden' }); return; }
|
||||||
res.json({ success: true, data });
|
res.json({ success: true, data });
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
logger.error('BuchhaltungController.updateKonto', { error });
|
logger.error('BuchhaltungController.updateKonto', { error });
|
||||||
res.status(500).json({ success: false, message: 'Konto konnte nicht aktualisiert werden' });
|
const status = error.statusCode || 500;
|
||||||
|
res.status(status).json({ success: false, message: error.message || 'Konto konnte nicht aktualisiert werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Categories for Buchhaltung Konten
|
||||||
|
CREATE TABLE IF NOT EXISTS buchhaltung_kategorien (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id),
|
||||||
|
bezeichnung TEXT NOT NULL,
|
||||||
|
sortierung INT DEFAULT 0,
|
||||||
|
erstellt_am TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE buchhaltung_konten ADD COLUMN IF NOT EXISTS kategorie_id INT REFERENCES buchhaltung_kategorien(id);
|
||||||
|
|
||||||
|
-- Recurring transaction execution day configuration
|
||||||
|
ALTER TABLE buchhaltung_wiederkehrend ADD COLUMN IF NOT EXISTS ausfuehrungstag TEXT DEFAULT 'erster' CHECK (ausfuehrungstag IN ('erster', 'mitte', 'letzter'));
|
||||||
|
ALTER TABLE buchhaltung_wiederkehrend ADD COLUMN IF NOT EXISTS ausfuehrungs_monat INT CHECK (ausfuehrungs_monat BETWEEN 1 AND 12);
|
||||||
@@ -6,6 +6,12 @@ import { uploadBuchhaltung } from '../middleware/upload';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// ── Kategorien ────────────────────────────────────────────────────────────────
|
||||||
|
router.get('/kategorien', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKategorien.bind(buchhaltungController));
|
||||||
|
router.post('/kategorien', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createKategorie.bind(buchhaltungController));
|
||||||
|
router.patch('/kategorien/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateKategorie.bind(buchhaltungController));
|
||||||
|
router.delete('/kategorien/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteKategorie.bind(buchhaltungController));
|
||||||
|
|
||||||
// ── Stats ─────────────────────────────────────────────────────────────────────
|
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController));
|
router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController));
|
||||||
router.get('/stats/pending', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPendingCount.bind(buchhaltungController));
|
router.get('/stats/pending', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPendingCount.bind(buchhaltungController));
|
||||||
|
|||||||
@@ -6,6 +6,68 @@ import pool from '../config/database';
|
|||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kategorien (Categories)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getKategorien(haushaltsjahr_id: number) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM buchhaltung_kategorien WHERE haushaltsjahr_id = $1 ORDER BY sortierung, bezeichnung`,
|
||||||
|
[haushaltsjahr_id]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungService.getKategorien failed', { error });
|
||||||
|
throw new Error('Kategorien konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createKategorie(data: { haushaltsjahr_id: number; bezeichnung: string; sortierung?: number }) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO buchhaltung_kategorien (haushaltsjahr_id, bezeichnung, sortierung)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[data.haushaltsjahr_id, data.bezeichnung, data.sortierung ?? 0]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungService.createKategorie failed', { error });
|
||||||
|
throw new Error('Kategorie konnte nicht erstellt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateKategorie(id: number, data: { bezeichnung?: string; sortierung?: number }) {
|
||||||
|
try {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
|
||||||
|
if (data.sortierung !== undefined) { fields.push(`sortierung = $${idx++}`); values.push(data.sortierung); }
|
||||||
|
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||||
|
values.push(id);
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE buchhaltung_kategorien SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungService.updateKategorie failed', { error, id });
|
||||||
|
throw new Error('Kategorie konnte nicht aktualisiert werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteKategorie(id: number) {
|
||||||
|
try {
|
||||||
|
await pool.query(`UPDATE buchhaltung_konten SET kategorie_id = NULL WHERE kategorie_id = $1`, [id]);
|
||||||
|
await pool.query(`DELETE FROM buchhaltung_kategorien WHERE id = $1`, [id]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BuchhaltungService.deleteKategorie failed', { error, id });
|
||||||
|
throw new Error('Kategorie konnte nicht gelöscht werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Haushaltsjahre (Fiscal Years)
|
// Haushaltsjahre (Fiscal Years)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -285,6 +347,7 @@ async function getKontenTree(haushaltsjahrId: number) {
|
|||||||
`SELECT k.*,
|
`SELECT k.*,
|
||||||
kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art,
|
||||||
pk.bezeichnung AS parent_bezeichnung,
|
pk.bezeichnung AS parent_bezeichnung,
|
||||||
|
k.kategorie_id, kat.bezeichnung AS kategorie_bezeichnung,
|
||||||
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='gwg' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_gwg,
|
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='gwg' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_gwg,
|
||||||
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='anlagen' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_anlagen,
|
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='anlagen' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_anlagen,
|
||||||
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='instandhaltung' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_instandhaltung,
|
COALESCE(SUM(CASE WHEN t.typ='ausgabe' AND t.ausgaben_typ='instandhaltung' AND t.status='gebucht' THEN t.betrag ELSE 0 END), 0) AS spent_instandhaltung,
|
||||||
@@ -292,9 +355,10 @@ async function getKontenTree(haushaltsjahrId: number) {
|
|||||||
FROM buchhaltung_konten k
|
FROM buchhaltung_konten k
|
||||||
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id
|
||||||
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
LEFT JOIN buchhaltung_konten pk ON pk.id = k.parent_id
|
||||||
|
LEFT JOIN buchhaltung_kategorien kat ON kat.id = k.kategorie_id
|
||||||
LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1
|
LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1
|
||||||
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
|
WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE
|
||||||
GROUP BY k.id, kt.bezeichnung, kt.art, pk.bezeichnung
|
GROUP BY k.id, kt.bezeichnung, kt.art, pk.bezeichnung, kat.bezeichnung
|
||||||
ORDER BY k.kontonummer`,
|
ORDER BY k.kontonummer`,
|
||||||
[haushaltsjahrId]
|
[haushaltsjahrId]
|
||||||
);
|
);
|
||||||
@@ -363,11 +427,57 @@ async function getPendingCount(haushaltsjahrId?: number): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateSubPotBudget(
|
||||||
|
parentId: number,
|
||||||
|
budgetGwg: number,
|
||||||
|
budgetAnlagen: number,
|
||||||
|
budgetInstandhaltung: number,
|
||||||
|
excludeKontoId?: number
|
||||||
|
) {
|
||||||
|
const parentResult = await pool.query(
|
||||||
|
`SELECT budget_gwg, budget_anlagen, budget_instandhaltung FROM buchhaltung_konten WHERE id = $1`,
|
||||||
|
[parentId]
|
||||||
|
);
|
||||||
|
if (!parentResult.rows[0]) return; // parent not found, skip validation
|
||||||
|
|
||||||
|
const parent = parentResult.rows[0];
|
||||||
|
const excludeClause = excludeKontoId ? ` AND id != $2` : '';
|
||||||
|
const siblingParams: unknown[] = [parentId];
|
||||||
|
if (excludeKontoId) siblingParams.push(excludeKontoId);
|
||||||
|
|
||||||
|
const siblingResult = await pool.query(
|
||||||
|
`SELECT COALESCE(SUM(budget_gwg), 0) AS sum_gwg,
|
||||||
|
COALESCE(SUM(budget_anlagen), 0) AS sum_anlagen,
|
||||||
|
COALESCE(SUM(budget_instandhaltung), 0) AS sum_instandhaltung
|
||||||
|
FROM buchhaltung_konten WHERE parent_id = $1${excludeClause}`,
|
||||||
|
siblingParams
|
||||||
|
);
|
||||||
|
const sibling = siblingResult.rows[0];
|
||||||
|
|
||||||
|
const checks: { label: string; sum: number; parentBudget: number }[] = [
|
||||||
|
{ label: 'GWG', sum: parseFloat(sibling.sum_gwg) + budgetGwg, parentBudget: parseFloat(parent.budget_gwg) },
|
||||||
|
{ label: 'Anlagen', sum: parseFloat(sibling.sum_anlagen) + budgetAnlagen, parentBudget: parseFloat(parent.budget_anlagen) },
|
||||||
|
{ label: 'Instandhaltung', sum: parseFloat(sibling.sum_instandhaltung) + budgetInstandhaltung, parentBudget: parseFloat(parent.budget_instandhaltung) },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of checks) {
|
||||||
|
if (c.parentBudget > 0 && c.sum > c.parentBudget) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`Budget ${c.label}: Summe der Untertöpfe (${c.sum.toFixed(2)} €) übersteigt das übergeordnete Budget (${c.parentBudget.toFixed(2)} €)`),
|
||||||
|
{ statusCode: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 },
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
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)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
@@ -377,6 +487,7 @@ async function createKonto(
|
|||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('BuchhaltungService.createKonto failed', { error });
|
logger.error('BuchhaltungService.createKonto failed', { error });
|
||||||
|
if (error.statusCode) throw error;
|
||||||
if (error.code === '23505' && error.constraint === 'buchhaltung_konten_haushaltsjahr_id_kontonummer_key') {
|
if (error.code === '23505' && error.constraint === 'buchhaltung_konten_haushaltsjahr_id_kontonummer_key') {
|
||||||
throw Object.assign(new Error(`Kontonummer "${data.kontonummer}" existiert bereits in diesem Haushaltsjahr`), { statusCode: 409 });
|
throw Object.assign(new Error(`Kontonummer "${data.kontonummer}" existiert bereits in diesem Haushaltsjahr`), { statusCode: 409 });
|
||||||
}
|
}
|
||||||
@@ -389,6 +500,22 @@ async function updateKonto(
|
|||||||
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 }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
// Budget validation for sub-pots
|
||||||
|
const hasBudgetChange = data.budget_gwg !== undefined || data.budget_anlagen !== undefined || data.budget_instandhaltung !== undefined;
|
||||||
|
const hasParentChange = data.parent_id !== undefined;
|
||||||
|
if (hasBudgetChange || hasParentChange) {
|
||||||
|
const current = await pool.query(`SELECT parent_id, budget_gwg, budget_anlagen, budget_instandhaltung FROM buchhaltung_konten WHERE id = $1`, [id]);
|
||||||
|
if (current.rows[0]) {
|
||||||
|
const parentId = data.parent_id !== undefined ? data.parent_id : current.rows[0].parent_id;
|
||||||
|
if (parentId) {
|
||||||
|
const bGwg = data.budget_gwg !== undefined ? data.budget_gwg : parseFloat(current.rows[0].budget_gwg);
|
||||||
|
const bAnl = data.budget_anlagen !== undefined ? data.budget_anlagen : parseFloat(current.rows[0].budget_anlagen);
|
||||||
|
const bIns = data.budget_instandhaltung !== undefined ? data.budget_instandhaltung : parseFloat(current.rows[0].budget_instandhaltung);
|
||||||
|
await validateSubPotBudget(parentId, bGwg, bAnl, bIns, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
@@ -407,8 +534,9 @@ async function updateKonto(
|
|||||||
values
|
values
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
logger.error('BuchhaltungService.updateKonto failed', { error, id });
|
logger.error('BuchhaltungService.updateKonto failed', { error, id });
|
||||||
|
if (error.statusCode) throw error;
|
||||||
throw new Error('Konto konnte nicht aktualisiert werden');
|
throw new Error('Konto konnte nicht aktualisiert werden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -953,14 +1081,16 @@ async function createWiederkehrend(
|
|||||||
intervall: string;
|
intervall: string;
|
||||||
naechste_ausfuehrung: string;
|
naechste_ausfuehrung: string;
|
||||||
aktiv?: boolean;
|
aktiv?: boolean;
|
||||||
|
ausfuehrungstag?: string;
|
||||||
|
ausfuehrungs_monat?: number;
|
||||||
},
|
},
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO buchhaltung_wiederkehrend
|
`INSERT INTO buchhaltung_wiederkehrend
|
||||||
(bezeichnung, konto_id, bankkonto_id, typ, betrag, beschreibung, empfaenger_auftraggeber, intervall, naechste_ausfuehrung, aktiv, erstellt_von)
|
(bezeichnung, konto_id, bankkonto_id, typ, betrag, beschreibung, empfaenger_auftraggeber, intervall, naechste_ausfuehrung, aktiv, erstellt_von, ausfuehrungstag, ausfuehrungs_monat)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.bezeichnung,
|
data.bezeichnung,
|
||||||
@@ -974,6 +1104,8 @@ async function createWiederkehrend(
|
|||||||
data.naechste_ausfuehrung,
|
data.naechste_ausfuehrung,
|
||||||
data.aktiv !== false,
|
data.aktiv !== false,
|
||||||
userId,
|
userId,
|
||||||
|
data.ausfuehrungstag || 'erster',
|
||||||
|
data.ausfuehrungs_monat ?? null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
@@ -996,6 +1128,8 @@ async function updateWiederkehrend(
|
|||||||
intervall?: string;
|
intervall?: string;
|
||||||
naechste_ausfuehrung?: string;
|
naechste_ausfuehrung?: string;
|
||||||
aktiv?: boolean;
|
aktiv?: boolean;
|
||||||
|
ausfuehrungstag?: string;
|
||||||
|
ausfuehrungs_monat?: number | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -1012,6 +1146,8 @@ async function updateWiederkehrend(
|
|||||||
if (data.intervall !== undefined) { fields.push(`intervall = $${idx++}`); values.push(data.intervall); }
|
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.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 (data.aktiv !== undefined) { fields.push(`aktiv = $${idx++}`); values.push(data.aktiv); }
|
||||||
|
if (data.ausfuehrungstag !== undefined) { fields.push(`ausfuehrungstag = $${idx++}`); values.push(data.ausfuehrungstag); }
|
||||||
|
if (data.ausfuehrungs_monat !== undefined) { fields.push(`ausfuehrungs_monat = $${idx++}`); values.push(data.ausfuehrungs_monat); }
|
||||||
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||||
values.push(id);
|
values.push(id);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -1100,6 +1236,10 @@ async function exportTransaktionenCsv(haushaltsjahrId: number): Promise<string>
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const buchhaltungService = {
|
const buchhaltungService = {
|
||||||
|
getKategorien,
|
||||||
|
createKategorie,
|
||||||
|
updateKategorie,
|
||||||
|
deleteKategorie,
|
||||||
getAllHaushaltsjahre,
|
getAllHaushaltsjahre,
|
||||||
getHaushaltsjahrById,
|
getHaushaltsjahrById,
|
||||||
getCurrentHaushaltsjahr,
|
getCurrentHaushaltsjahr,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -62,6 +63,7 @@ import type {
|
|||||||
Bankkonto, BankkontoFormData,
|
Bankkonto, BankkontoFormData,
|
||||||
Konto, KontoFormData,
|
Konto, KontoFormData,
|
||||||
KontoTreeNode,
|
KontoTreeNode,
|
||||||
|
Kategorie,
|
||||||
Transaktion, TransaktionFormData, TransaktionFilters,
|
Transaktion, TransaktionFormData, TransaktionFilters,
|
||||||
TransaktionStatus,
|
TransaktionStatus,
|
||||||
AusgabenTyp,
|
AusgabenTyp,
|
||||||
@@ -184,20 +186,35 @@ function KontoDialog({
|
|||||||
haushaltsjahrId,
|
haushaltsjahrId,
|
||||||
existing,
|
existing,
|
||||||
konten,
|
konten,
|
||||||
|
kategorien = [],
|
||||||
onSave,
|
onSave,
|
||||||
|
externalError,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
haushaltsjahrId: number;
|
haushaltsjahrId: number;
|
||||||
existing?: Konto;
|
existing?: Konto;
|
||||||
konten: Konto[];
|
konten: Konto[];
|
||||||
|
kategorien?: Kategorie[];
|
||||||
onSave: (data: KontoFormData) => void;
|
onSave: (data: KontoFormData) => void;
|
||||||
|
externalError?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: 0, bezeichnung: '', budget_gwg: 0, budget_anlagen: 0, budget_instandhaltung: 0, parent_id: null, notizen: '' };
|
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 [form, setForm] = useState<KontoFormData>(empty);
|
const [form, setForm] = useState<KontoFormData>(empty);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
const selectedParent = konten.find(k => k.id === form.parent_id);
|
const selectedParent = konten.find(k => k.id === form.parent_id);
|
||||||
|
|
||||||
|
// Compute sibling budget usage for parent reference
|
||||||
|
const siblingBudgets = selectedParent ? (() => {
|
||||||
|
const siblings = konten.filter(k => k.parent_id === selectedParent.id && k.id !== existing?.id);
|
||||||
|
return {
|
||||||
|
gwg: siblings.reduce((s, k) => s + Number(k.budget_gwg || 0), 0),
|
||||||
|
anlagen: siblings.reduce((s, k) => s + Number(k.budget_anlagen || 0), 0),
|
||||||
|
instandhaltung: siblings.reduce((s, k) => s + Number(k.budget_instandhaltung || 0), 0),
|
||||||
|
};
|
||||||
|
})() : null;
|
||||||
|
|
||||||
// suffix = form.kontonummer - parent.kontonummer (arithmetic)
|
// suffix = form.kontonummer - parent.kontonummer (arithmetic)
|
||||||
const suffixValue = selectedParent ? form.kontonummer - selectedParent.kontonummer : form.kontonummer;
|
const suffixValue = selectedParent ? form.kontonummer - selectedParent.kontonummer : form.kontonummer;
|
||||||
|
|
||||||
@@ -217,7 +234,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, 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, 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 });
|
||||||
}
|
}
|
||||||
@@ -258,10 +275,42 @@ function KontoDialog({
|
|||||||
}
|
}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
{kategorien.length > 0 && (
|
||||||
|
<FormControl fullWidth margin="dense">
|
||||||
|
<InputLabel>Kategorie</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.kategorie_id ?? ''}
|
||||||
|
onChange={e => setForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : null }))}
|
||||||
|
label="Kategorie"
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>Keine Kategorie</em></MenuItem>
|
||||||
|
{kategorien.map(kat => (
|
||||||
|
<MenuItem key={kat.id} value={kat.id}>{kat.bezeichnung}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
<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 && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: -1 }}>
|
||||||
|
Eltern-Budget: {fmtEur(selectedParent.budget_gwg)}, vergeben: {fmtEur(siblingBudgets.gwg)}, verfügbar: {fmtEur(selectedParent.budget_gwg - siblingBudgets.gwg)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
<TextField label="Anlagen Budget (€)" type="number" value={form.budget_anlagen} onChange={e => setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
<TextField label="Anlagen Budget (€)" type="number" value={form.budget_anlagen} onChange={e => setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
||||||
|
{selectedParent && siblingBudgets && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: -1 }}>
|
||||||
|
Eltern-Budget: {fmtEur(selectedParent.budget_anlagen)}, vergeben: {fmtEur(siblingBudgets.anlagen)}, verfügbar: {fmtEur(selectedParent.budget_anlagen - siblingBudgets.anlagen)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
<TextField label="Instandhaltung Budget (€)" type="number" value={form.budget_instandhaltung} onChange={e => setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
<TextField label="Instandhaltung Budget (€)" type="number" value={form.budget_instandhaltung} onChange={e => setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} />
|
||||||
|
{selectedParent && siblingBudgets && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: -1 }}>
|
||||||
|
Eltern-Budget: {fmtEur(selectedParent.budget_instandhaltung)}, vergeben: {fmtEur(siblingBudgets.instandhaltung)}, verfügbar: {fmtEur(selectedParent.budget_instandhaltung - siblingBudgets.instandhaltung)}
|
||||||
|
</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 && externalError && <Alert severity="error">{externalError}</Alert>}
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@@ -493,18 +542,23 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
queryFn: () => buchhaltungApi.getKontenTree(selectedJahrId!),
|
queryFn: () => buchhaltungApi.getKontenTree(selectedJahrId!),
|
||||||
enabled: selectedJahrId != null,
|
enabled: selectedJahrId != null,
|
||||||
});
|
});
|
||||||
|
const { data: kategorien = [] } = useQuery({
|
||||||
|
queryKey: ['buchhaltung-kategorien', selectedJahrId],
|
||||||
|
queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!),
|
||||||
|
enabled: selectedJahrId != null,
|
||||||
|
});
|
||||||
|
|
||||||
const tree = buildTree(treeData);
|
const tree = buildTree(treeData);
|
||||||
|
|
||||||
const sumBudgetGwg = treeData.reduce((s, k) => s + Number(k.budget_gwg), 0);
|
const sumBudgetGwg = treeData.reduce((s, k) => s + Number(k.budget_gwg || 0), 0);
|
||||||
const sumBudgetAnlagen = treeData.reduce((s, k) => s + Number(k.budget_anlagen), 0);
|
const sumBudgetAnlagen = treeData.reduce((s, k) => s + Number(k.budget_anlagen || 0), 0);
|
||||||
const sumBudgetInst = treeData.reduce((s, k) => s + Number(k.budget_instandhaltung), 0);
|
const sumBudgetInst = treeData.reduce((s, k) => s + Number(k.budget_instandhaltung || 0), 0);
|
||||||
const sumBudgetGesamt = sumBudgetGwg + sumBudgetAnlagen + sumBudgetInst;
|
const sumBudgetGesamt = sumBudgetGwg + sumBudgetAnlagen + sumBudgetInst;
|
||||||
const sumSpentGwg = treeData.reduce((s, k) => s + Number(k.spent_gwg), 0);
|
const sumSpentGwg = treeData.reduce((s, k) => s + Number(k.spent_gwg || 0), 0);
|
||||||
const sumSpentAnlagen = treeData.reduce((s, k) => s + Number(k.spent_anlagen), 0);
|
const sumSpentAnlagen = treeData.reduce((s, k) => s + Number(k.spent_anlagen || 0), 0);
|
||||||
const sumSpentInst = treeData.reduce((s, k) => s + Number(k.spent_instandhaltung), 0);
|
const sumSpentInst = treeData.reduce((s, k) => s + Number(k.spent_instandhaltung || 0), 0);
|
||||||
const sumSpentGesamt = sumSpentGwg + sumSpentAnlagen + sumSpentInst;
|
const sumSpentGesamt = sumSpentGwg + sumSpentAnlagen + sumSpentInst;
|
||||||
const sumEinnahmen = treeData.reduce((s, k) => s + Number(k.einnahmen_betrag), 0);
|
const sumEinnahmen = treeData.reduce((s, k) => s + Number(k.einnahmen_betrag || 0), 0);
|
||||||
|
|
||||||
const totalEinnahmen = sumEinnahmen;
|
const totalEinnahmen = sumEinnahmen;
|
||||||
const totalAusgaben = sumSpentGesamt;
|
const totalAusgaben = sumSpentGesamt;
|
||||||
@@ -567,9 +621,54 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
{tree.length === 0 && (
|
{tree.length === 0 && (
|
||||||
<TableRow><TableCell colSpan={11} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>
|
<TableRow><TableCell colSpan={11} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>
|
||||||
)}
|
)}
|
||||||
{tree.map(k => (
|
{(() => {
|
||||||
<KontoRow key={k.id} konto={k} onNavigate={(id) => navigate(`/buchhaltung/konto/${id}`)} />
|
const grouped = new Map<number | null, KontoTreeNode[]>();
|
||||||
))}
|
tree.forEach(k => {
|
||||||
|
const key = k.kategorie_id ?? null;
|
||||||
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
|
grouped.get(key)!.push(k);
|
||||||
|
});
|
||||||
|
const categoryOrder = kategorien.map(kat => kat.id);
|
||||||
|
const sortedKeys = [...grouped.keys()].sort((a, b) => {
|
||||||
|
if (a === null) return 1;
|
||||||
|
if (b === null) return -1;
|
||||||
|
return categoryOrder.indexOf(a) - categoryOrder.indexOf(b);
|
||||||
|
});
|
||||||
|
const hasMultipleGroups = sortedKeys.length > 1 || (sortedKeys.length === 1 && sortedKeys[0] !== null);
|
||||||
|
return sortedKeys.flatMap(key => {
|
||||||
|
const items = grouped.get(key)!;
|
||||||
|
const rows: React.ReactNode[] = [];
|
||||||
|
if (hasMultipleGroups) {
|
||||||
|
const katName = key !== null ? kategorien.find(k => k.id === key)?.bezeichnung : 'Ohne Kategorie';
|
||||||
|
const catBudgetGwg = items.reduce((s, k) => s + Number(k.budget_gwg || 0), 0);
|
||||||
|
const catBudgetAnl = items.reduce((s, k) => s + Number(k.budget_anlagen || 0), 0);
|
||||||
|
const catBudgetInst = items.reduce((s, k) => s + Number(k.budget_instandhaltung || 0), 0);
|
||||||
|
const catSpentGwg = items.reduce((s, k) => s + Number(k.spent_gwg || 0), 0);
|
||||||
|
const catSpentAnl = items.reduce((s, k) => s + Number(k.spent_anlagen || 0), 0);
|
||||||
|
const catSpentInst = items.reduce((s, k) => s + Number(k.spent_instandhaltung || 0), 0);
|
||||||
|
const catEinnahmen = items.reduce((s, k) => s + Number(k.einnahmen_betrag || 0), 0);
|
||||||
|
rows.push(
|
||||||
|
<TableRow key={`cat-${key}`} sx={{ bgcolor: 'grey.100', '& td': { fontWeight: 600, fontSize: '0.8rem' } }}>
|
||||||
|
<TableCell>{katName}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(catBudgetGwg)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(catBudgetAnl)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(catBudgetInst)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(catBudgetGwg + catBudgetAnl + catBudgetInst)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(catSpentGwg)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(catSpentAnl)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(catSpentInst)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(catSpentGwg + catSpentAnl + catSpentInst)}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(catEinnahmen)}</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
items.forEach(k => rows.push(
|
||||||
|
<KontoRow key={k.id} konto={k} onNavigate={(id) => navigate(`/buchhaltung/konto/${id}`)} />
|
||||||
|
));
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
})()}
|
||||||
{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>
|
||||||
@@ -702,7 +801,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
|
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }}>
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<FormControl sx={{ minWidth: 200 }}>
|
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||||
<InputLabel>Haushaltsjahr</InputLabel>
|
<InputLabel>Haushaltsjahr</InputLabel>
|
||||||
<Select size="small" value={filters.haushaltsjahr_id ?? ''} label="Haushaltsjahr"
|
<Select size="small" value={filters.haushaltsjahr_id ?? ''} label="Haushaltsjahr"
|
||||||
onChange={e => { const v = Number(e.target.value); setFilters(f => ({ ...f, haushaltsjahr_id: v || undefined })); onJahrChange(v); }}>
|
onChange={e => { const v = Number(e.target.value); setFilters(f => ({ ...f, haushaltsjahr_id: v || undefined })); onJahrChange(v); }}>
|
||||||
@@ -710,7 +809,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl sx={{ minWidth: 140 }}>
|
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||||
<InputLabel>Status</InputLabel>
|
<InputLabel>Status</InputLabel>
|
||||||
<Select size="small" value={filters.status ?? ''} label="Status"
|
<Select size="small" value={filters.status ?? ''} label="Status"
|
||||||
onChange={e => setFilters(f => ({ ...f, status: (e.target.value as TransaktionStatus) || undefined }))}>
|
onChange={e => setFilters(f => ({ ...f, status: (e.target.value as TransaktionStatus) || undefined }))}>
|
||||||
@@ -718,7 +817,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
{(Object.entries(TRANSAKTION_STATUS_LABELS) as [TransaktionStatus, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
|
{(Object.entries(TRANSAKTION_STATUS_LABELS) as [TransaktionStatus, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl sx={{ minWidth: 130 }}>
|
<FormControl size="small" sx={{ minWidth: 130 }}>
|
||||||
<InputLabel>Typ</InputLabel>
|
<InputLabel>Typ</InputLabel>
|
||||||
<Select size="small" value={filters.typ ?? ''} label="Typ"
|
<Select size="small" value={filters.typ ?? ''} label="Typ"
|
||||||
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
|
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
|
||||||
@@ -903,6 +1002,7 @@ function WiederkehrendDialog({
|
|||||||
empfaenger_auftraggeber: '',
|
empfaenger_auftraggeber: '',
|
||||||
intervall: 'monatlich',
|
intervall: 'monatlich',
|
||||||
naechste_ausfuehrung: today,
|
naechste_ausfuehrung: today,
|
||||||
|
ausfuehrungstag: 'erster',
|
||||||
aktiv: true,
|
aktiv: true,
|
||||||
};
|
};
|
||||||
const [form, setForm] = useState<WiederkehrendFormData>(empty);
|
const [form, setForm] = useState<WiederkehrendFormData>(empty);
|
||||||
@@ -919,6 +1019,8 @@ function WiederkehrendDialog({
|
|||||||
empfaenger_auftraggeber: existing.empfaenger_auftraggeber || '',
|
empfaenger_auftraggeber: existing.empfaenger_auftraggeber || '',
|
||||||
intervall: existing.intervall,
|
intervall: existing.intervall,
|
||||||
naechste_ausfuehrung: existing.naechste_ausfuehrung.slice(0, 10),
|
naechste_ausfuehrung: existing.naechste_ausfuehrung.slice(0, 10),
|
||||||
|
ausfuehrungstag: existing.ausfuehrungstag || 'erster',
|
||||||
|
ausfuehrungs_monat: existing.ausfuehrungs_monat ?? undefined,
|
||||||
aktiv: existing.aktiv,
|
aktiv: existing.aktiv,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -947,6 +1049,25 @@ function WiederkehrendDialog({
|
|||||||
{(Object.entries(INTERVALL_LABELS) as [WiederkehrendIntervall, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
|
{(Object.entries(INTERVALL_LABELS) as [WiederkehrendIntervall, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Ausführungstag</InputLabel>
|
||||||
|
<Select value={form.ausfuehrungstag ?? 'erster'} label="Ausführungstag" onChange={e => setForm(f => ({ ...f, ausfuehrungstag: e.target.value as 'erster' | 'mitte' | 'letzter' }))}>
|
||||||
|
<MenuItem value="erster">Erster des Monats</MenuItem>
|
||||||
|
<MenuItem value="mitte">Mitte des Monats (15.)</MenuItem>
|
||||||
|
<MenuItem value="letzter">Letzter des Monats</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{form.intervall === 'jaehrlich' && (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Ausführungsmonat</InputLabel>
|
||||||
|
<Select value={form.ausfuehrungs_monat ?? ''} label="Ausführungsmonat" onChange={e => setForm(f => ({ ...f, ausfuehrungs_monat: e.target.value ? Number(e.target.value) : undefined }))}>
|
||||||
|
<MenuItem value=""><em>Nicht festgelegt</em></MenuItem>
|
||||||
|
{['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'].map((m, i) => (
|
||||||
|
<MenuItem key={i + 1} value={i + 1}>{m}</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 />
|
<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>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Konto</InputLabel>
|
<InputLabel>Konto</InputLabel>
|
||||||
@@ -989,6 +1110,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
const { hasPermission } = usePermissionContext();
|
const { hasPermission } = usePermissionContext();
|
||||||
const [subTab, setSubTab] = useState(0);
|
const [subTab, setSubTab] = useState(0);
|
||||||
const [kontoDialog, setKontoDialog] = useState<{ open: boolean; existing?: Konto }>({ open: false });
|
const [kontoDialog, setKontoDialog] = useState<{ open: boolean; existing?: Konto }>({ open: false });
|
||||||
|
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 [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false });
|
||||||
@@ -1007,18 +1129,43 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
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: wiederkehrend = [] } = useQuery({ queryKey: ['buchhaltung-wiederkehrend'], queryFn: buchhaltungApi.getWiederkehrend });
|
||||||
|
const { data: kategorien = [] } = useQuery({
|
||||||
|
queryKey: ['buchhaltung-kategorien', selectedJahrId],
|
||||||
|
queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!),
|
||||||
|
enabled: selectedJahrId != null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [newKategorie, setNewKategorie] = useState('');
|
||||||
|
const [addingKategorie, setAddingKategorie] = useState(false);
|
||||||
|
|
||||||
|
const createKategorieMut = useMutation({
|
||||||
|
mutationFn: buchhaltungApi.createKategorie,
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-kategorien'] }); setNewKategorie(''); setAddingKategorie(false); showSuccess('Kategorie erstellt'); },
|
||||||
|
onError: () => showError('Kategorie konnte nicht erstellt werden'),
|
||||||
|
});
|
||||||
|
const deleteKategorieMut = useMutation({
|
||||||
|
mutationFn: buchhaltungApi.deleteKategorie,
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-kategorien'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); showSuccess('Kategorie gelöscht'); },
|
||||||
|
onError: () => showError('Kategorie konnte nicht gelöscht werden'),
|
||||||
|
});
|
||||||
|
|
||||||
const canManage = hasPermission('buchhaltung:manage_accounts');
|
const canManage = hasPermission('buchhaltung:manage_accounts');
|
||||||
|
|
||||||
const createKontoMut = useMutation({
|
const createKontoMut = useMutation({
|
||||||
mutationFn: buchhaltungApi.createKonto,
|
mutationFn: buchhaltungApi.createKonto,
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); setKontoDialog({ open: false }); showSuccess('Konto erstellt'); },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); setKontoDialog({ open: false }); setKontoSaveError(null); showSuccess('Konto erstellt'); },
|
||||||
onError: (err: any) => showError(err?.message || err?.response?.data?.message || 'Konto konnte nicht erstellt werden'),
|
onError: (err: any) => {
|
||||||
|
const msg = err?.response?.data?.message || err?.message || 'Konto konnte nicht erstellt werden';
|
||||||
|
if (err?.response?.status === 400) { setKontoSaveError(msg); } else { showError(msg); }
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const updateKontoMut = useMutation({
|
const updateKontoMut = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: Partial<KontoFormData> }) => buchhaltungApi.updateKonto(id, data),
|
mutationFn: ({ id, data }: { id: number; data: Partial<KontoFormData> }) => buchhaltungApi.updateKonto(id, data),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); setKontoDialog({ open: false }); showSuccess('Konto aktualisiert'); },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); qc.invalidateQueries({ queryKey: ['kontenTree'] }); setKontoDialog({ open: false }); setKontoSaveError(null); showSuccess('Konto aktualisiert'); },
|
||||||
onError: () => showError('Konto konnte nicht aktualisiert werden'),
|
onError: (err: any) => {
|
||||||
|
const msg = err?.response?.data?.message || err?.message || 'Konto konnte nicht aktualisiert werden';
|
||||||
|
if (err?.response?.status === 400) { setKontoSaveError(msg); } else { showError(msg); }
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const createBankMut = useMutation({
|
const createBankMut = useMutation({
|
||||||
mutationFn: buchhaltungApi.createBankkonto,
|
mutationFn: buchhaltungApi.createBankkonto,
|
||||||
@@ -1070,12 +1217,14 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Tabs value={subTab} onChange={(_, v) => setSubTab(v)} sx={{ mb: 2 }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||||
<Tab label="Konten" />
|
<Tabs value={subTab} onChange={(_, v) => setSubTab(v)}>
|
||||||
<Tab label="Bankkonten" />
|
<Tab label="Konten" />
|
||||||
<Tab label="Haushaltsjahre" />
|
<Tab label="Bankkonten" />
|
||||||
<Tab label="Wiederkehrend" />
|
<Tab label="Haushaltsjahre" />
|
||||||
</Tabs>
|
<Tab label="Wiederkehrend" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Sub-Tab 0: Konten */}
|
{/* Sub-Tab 0: Konten */}
|
||||||
{subTab === 0 && (
|
{subTab === 0 && (
|
||||||
@@ -1089,36 +1238,92 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
{canManage && <Button variant="contained" startIcon={<AddIcon />} disabled={!selectedJahrId} onClick={() => setKontoDialog({ open: true })}>Konto anlegen</Button>}
|
{canManage && <Button variant="contained" startIcon={<AddIcon />} disabled={!selectedJahrId} onClick={() => setKontoDialog({ open: true })}>Konto anlegen</Button>}
|
||||||
</Box>
|
</Box>
|
||||||
<TableContainer component={Paper}>
|
{selectedJahrId && canManage && (
|
||||||
<Table size="small">
|
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<TableHead>
|
{kategorien.map(kat => (
|
||||||
<TableRow>
|
<Chip key={kat.id} label={kat.bezeichnung} onDelete={() => deleteKategorieMut.mutate(kat.id)} size="small" />
|
||||||
<TableCell>Konto</TableCell>
|
))}
|
||||||
<TableCell align="right">GWG</TableCell>
|
{addingKategorie ? (
|
||||||
<TableCell align="right">Anlagen</TableCell>
|
<TextField
|
||||||
<TableCell align="right">Instandh.</TableCell>
|
size="small"
|
||||||
<TableCell align="right">Gesamt</TableCell>
|
placeholder="Name..."
|
||||||
<TableCell sx={{ width: 40 }} />
|
value={newKategorie}
|
||||||
</TableRow>
|
onChange={e => setNewKategorie(e.target.value)}
|
||||||
</TableHead>
|
onKeyDown={e => {
|
||||||
<TableBody>
|
if (e.key === 'Enter' && newKategorie.trim() && selectedJahrId) {
|
||||||
{kontenTree.length === 0 && <TableRow><TableCell colSpan={6} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>}
|
createKategorieMut.mutate({ haushaltsjahr_id: selectedJahrId, bezeichnung: newKategorie.trim() });
|
||||||
{kontenTree.map(k => (
|
} else if (e.key === 'Escape') {
|
||||||
<KontoManageRow
|
setAddingKategorie(false); setNewKategorie('');
|
||||||
key={k.id}
|
}
|
||||||
konto={k}
|
}}
|
||||||
onNavigate={(id) => navigate('/buchhaltung/konto/' + id + '/verwalten')}
|
onBlur={() => { setAddingKategorie(false); setNewKategorie(''); }}
|
||||||
/>
|
autoFocus
|
||||||
))}
|
sx={{ width: 160 }}
|
||||||
</TableBody>
|
/>
|
||||||
</Table>
|
) : (
|
||||||
</TableContainer>
|
<Chip label="+ Kategorie" variant="outlined" size="small" onClick={() => setAddingKategorie(true)} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const grouped = new Map<number | null, KontoTreeNode[]>();
|
||||||
|
kontenTree.forEach(k => {
|
||||||
|
const key = k.kategorie_id ?? null;
|
||||||
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
|
grouped.get(key)!.push(k);
|
||||||
|
});
|
||||||
|
const categoryOrder = kategorien.map(kat => kat.id);
|
||||||
|
const sortedKeys = [...grouped.keys()].sort((a, b) => {
|
||||||
|
if (a === null) return 1;
|
||||||
|
if (b === null) return -1;
|
||||||
|
return categoryOrder.indexOf(a) - categoryOrder.indexOf(b);
|
||||||
|
});
|
||||||
|
return sortedKeys.map(key => {
|
||||||
|
const katName = key !== null ? kategorien.find(k => k.id === key)?.bezeichnung : 'Ohne Kategorie';
|
||||||
|
const items = grouped.get(key)!;
|
||||||
|
return (
|
||||||
|
<Box key={key ?? 'none'} sx={{ mb: 2 }}>
|
||||||
|
{(sortedKeys.length > 1 || key !== null) && (
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 0.5 }}>{katName}</Typography>
|
||||||
|
)}
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Konto</TableCell>
|
||||||
|
<TableCell align="right">GWG</TableCell>
|
||||||
|
<TableCell align="right">Anlagen</TableCell>
|
||||||
|
<TableCell align="right">Instandh.</TableCell>
|
||||||
|
<TableCell align="right">Gesamt</TableCell>
|
||||||
|
<TableCell sx={{ width: 40 }} />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.map(k => (
|
||||||
|
<KontoManageRow
|
||||||
|
key={k.id}
|
||||||
|
konto={k}
|
||||||
|
onNavigate={(id) => navigate('/buchhaltung/konto/' + id + '/verwalten')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
{kontenTree.length === 0 && selectedJahrId && (
|
||||||
|
<Paper><Box sx={{ p: 2, textAlign: 'center' }}><Typography color="text.secondary">Keine Konten</Typography></Box></Paper>
|
||||||
|
)}
|
||||||
{selectedJahrId && <KontoDialog
|
{selectedJahrId && <KontoDialog
|
||||||
open={kontoDialog.open}
|
open={kontoDialog.open}
|
||||||
onClose={() => setKontoDialog({ open: false })}
|
onClose={() => { setKontoDialog({ open: false }); setKontoSaveError(null); }}
|
||||||
haushaltsjahrId={selectedJahrId}
|
haushaltsjahrId={selectedJahrId}
|
||||||
existing={kontoDialog.existing}
|
existing={kontoDialog.existing}
|
||||||
konten={konten}
|
konten={konten}
|
||||||
|
kategorien={kategorien}
|
||||||
|
externalError={kontoSaveError}
|
||||||
onSave={data => kontoDialog.existing
|
onSave={data => kontoDialog.existing
|
||||||
? updateKontoMut.mutate({ id: kontoDialog.existing.id, data })
|
? updateKontoMut.mutate({ id: kontoDialog.existing.id, data })
|
||||||
: createKontoMut.mutate(data)
|
: createKontoMut.mutate(data)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { buchhaltungApi } from '../services/buchhaltung';
|
|||||||
import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types';
|
import { AUSGABEN_TYP_LABELS } from '../types/buchhaltung.types';
|
||||||
import type { AusgabenTyp } from '../types/buchhaltung.types';
|
import type { AusgabenTyp } from '../types/buchhaltung.types';
|
||||||
|
|
||||||
|
const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
|
||||||
|
|
||||||
function BudgetCard({ label, budget, spent }: { label: string; budget: number; spent: number }) {
|
function BudgetCard({ label, budget, spent }: { label: string; budget: number; spent: number }) {
|
||||||
const utilization = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
|
const utilization = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
|
||||||
const over = spent > budget && budget > 0;
|
const over = spent > budget && budget > 0;
|
||||||
@@ -18,10 +20,10 @@ function BudgetCard({ label, budget, spent }: { label: string; budget: number; s
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="subtitle2" color="text.secondary">{label}</Typography>
|
<Typography variant="subtitle2" color="text.secondary">{label}</Typography>
|
||||||
<Typography variant="h6">{Number(spent).toFixed(2).replace('.', ',')} €</Typography>
|
<Typography variant="h6">{fmtEur(Number(spent))}</Typography>
|
||||||
{budget > 0 && (
|
{budget > 0 && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2" color="text.secondary">Budget: {Number(budget).toFixed(2).replace('.', ',')} €</Typography>
|
<Typography variant="body2" color="text.secondary">Budget: {fmtEur(Number(budget))}</Typography>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={utilization}
|
value={utilization}
|
||||||
@@ -86,7 +88,7 @@ export default function BuchhaltungKontoDetail() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="subtitle2" color="text.secondary">Einnahmen</Typography>
|
<Typography variant="subtitle2" color="text.secondary">Einnahmen</Typography>
|
||||||
<Typography variant="h6" color="success.main">{totalEinnahmen.toFixed(2).replace('.', ',')} €</Typography>
|
<Typography variant="h6" color="success.main">{fmtEur(totalEinnahmen)}</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -115,11 +117,11 @@ export default function BuchhaltungKontoDetail() {
|
|||||||
onClick={() => navigate(`/buchhaltung/konto/${child.id}`)}
|
onClick={() => navigate(`/buchhaltung/konto/${child.id}`)}
|
||||||
>
|
>
|
||||||
<TableCell>{child.kontonummer} — {child.bezeichnung}</TableCell>
|
<TableCell>{child.kontonummer} — {child.bezeichnung}</TableCell>
|
||||||
<TableCell align="right">{Number(child.budget_gwg).toFixed(2)} €</TableCell>
|
<TableCell align="right">{fmtEur(Number(child.budget_gwg))}</TableCell>
|
||||||
<TableCell align="right">{Number(child.budget_anlagen).toFixed(2)} €</TableCell>
|
<TableCell align="right">{fmtEur(Number(child.budget_anlagen))}</TableCell>
|
||||||
<TableCell align="right">{Number(child.budget_instandhaltung).toFixed(2)} €</TableCell>
|
<TableCell align="right">{fmtEur(Number(child.budget_instandhaltung))}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{(Number(child.budget_gwg) + Number(child.budget_anlagen) + Number(child.budget_instandhaltung)).toFixed(2)} €
|
{fmtEur(Number(child.budget_gwg) + Number(child.budget_anlagen) + Number(child.budget_instandhaltung))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -158,7 +160,7 @@ export default function BuchhaltungKontoDetail() {
|
|||||||
<TableCell>{t.ausgaben_typ ? AUSGABEN_TYP_LABELS[t.ausgaben_typ as AusgabenTyp] : '—'}</TableCell>
|
<TableCell>{t.ausgaben_typ ? AUSGABEN_TYP_LABELS[t.ausgaben_typ as AusgabenTyp] : '—'}</TableCell>
|
||||||
<TableCell align="right"
|
<TableCell align="right"
|
||||||
sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main' }}>
|
sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main' }}>
|
||||||
{t.typ === 'einnahme' ? '+' : '-'}{Number(t.betrag).toFixed(2).replace('.', ',')} €
|
{t.typ === 'einnahme' ? '+' : '-'}{fmtEur(Number(t.betrag))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{t.status}</TableCell>
|
<TableCell>{t.status}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -3,15 +3,49 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select,
|
Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select,
|
||||||
FormControl, InputLabel, CircularProgress, Alert, Dialog, DialogTitle,
|
FormControl, InputLabel, Alert, Dialog, DialogTitle,
|
||||||
DialogContent, DialogActions, Skeleton,
|
DialogContent, DialogActions, Skeleton, Divider, LinearProgress, Grid,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ArrowBack, Delete, Save } 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 } from '../types/buchhaltung.types';
|
||||||
|
|
||||||
|
const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
|
||||||
|
|
||||||
|
function FieldRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2} sx={{ py: 0.75 }}>
|
||||||
|
<Grid item xs={5}><Typography variant="body2" color="text.secondary">{label}</Typography></Grid>
|
||||||
|
<Grid item xs={7}><Typography variant="body2">{value}</Typography></Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BudgetBar({ label, budget, spent }: { label: string; budget: number; spent: number }) {
|
||||||
|
const utilization = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
|
||||||
|
const over = spent > budget && budget > 0;
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{fmtEur(spent)} / {fmtEur(budget)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{budget > 0 && (
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={utilization}
|
||||||
|
color={over ? 'error' : utilization > 80 ? 'warning' : 'primary'}
|
||||||
|
sx={{ height: 6, borderRadius: 3 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BuchhaltungKontoManage() {
|
export default function BuchhaltungKontoManage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -19,8 +53,10 @@ export default function BuchhaltungKontoManage() {
|
|||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
const kontoId = Number(id);
|
const kontoId = Number(id);
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [form, setForm] = useState<Partial<KontoFormData>>({});
|
const [form, setForm] = useState<Partial<KontoFormData>>({});
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ['kontoDetail', kontoId],
|
queryKey: ['kontoDetail', kontoId],
|
||||||
@@ -28,13 +64,18 @@ export default function BuchhaltungKontoManage() {
|
|||||||
enabled: !!kontoId,
|
enabled: !!kontoId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load sibling konten for parent selector (exclude self)
|
|
||||||
const { data: alleKonten = [] } = useQuery({
|
const { data: alleKonten = [] } = useQuery({
|
||||||
queryKey: ['buchhaltung-konten', data?.konto.haushaltsjahr_id],
|
queryKey: ['buchhaltung-konten', data?.konto.haushaltsjahr_id],
|
||||||
queryFn: () => buchhaltungApi.getKonten(data!.konto.haushaltsjahr_id),
|
queryFn: () => buchhaltungApi.getKonten(data!.konto.haushaltsjahr_id),
|
||||||
enabled: !!data?.konto.haushaltsjahr_id,
|
enabled: !!data?.konto.haushaltsjahr_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: kategorien = [] } = useQuery({
|
||||||
|
queryKey: ['buchhaltung-kategorien', data?.konto.haushaltsjahr_id],
|
||||||
|
queryFn: () => buchhaltungApi.getKategorien(data!.konto.haushaltsjahr_id),
|
||||||
|
enabled: !!data?.konto.haushaltsjahr_id,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.konto) {
|
if (data?.konto) {
|
||||||
const k = data.konto;
|
const k = data.konto;
|
||||||
@@ -46,6 +87,7 @@ export default function BuchhaltungKontoManage() {
|
|||||||
budget_anlagen: k.budget_anlagen,
|
budget_anlagen: k.budget_anlagen,
|
||||||
budget_instandhaltung: k.budget_instandhaltung,
|
budget_instandhaltung: k.budget_instandhaltung,
|
||||||
parent_id: k.parent_id ?? undefined,
|
parent_id: k.parent_id ?? undefined,
|
||||||
|
kategorie_id: k.kategorie_id ?? undefined,
|
||||||
notizen: k.notizen ?? '',
|
notizen: k.notizen ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -58,8 +100,13 @@ export default function BuchhaltungKontoManage() {
|
|||||||
qc.invalidateQueries({ queryKey: ['kontenTree'] });
|
qc.invalidateQueries({ queryKey: ['kontenTree'] });
|
||||||
qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] });
|
qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] });
|
||||||
showSuccess('Konto gespeichert');
|
showSuccess('Konto gespeichert');
|
||||||
|
setIsEditing(false);
|
||||||
|
setSaveError(null);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
const msg = err?.response?.data?.message || err?.message || 'Speichern fehlgeschlagen';
|
||||||
|
if (err?.response?.status === 400) { setSaveError(msg); } else { showError(msg); }
|
||||||
},
|
},
|
||||||
onError: () => showError('Speichern fehlgeschlagen'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
const deleteMut = useMutation({
|
||||||
@@ -77,13 +124,47 @@ export default function BuchhaltungKontoManage() {
|
|||||||
if (isError || !data) return <DashboardLayout><Box sx={{ p: 3 }}><Alert severity="error">Konto nicht gefunden.</Alert></Box></DashboardLayout>;
|
if (isError || !data) return <DashboardLayout><Box sx={{ p: 3 }}><Alert severity="error">Konto nicht gefunden.</Alert></Box></DashboardLayout>;
|
||||||
|
|
||||||
const konto = data.konto;
|
const konto = data.konto;
|
||||||
|
const { transaktionen } = data;
|
||||||
const otherKonten = alleKonten.filter(k => k.id !== kontoId);
|
const otherKonten = alleKonten.filter(k => k.id !== kontoId);
|
||||||
|
const parentKonto = alleKonten.find(k => k.id === konto.parent_id);
|
||||||
|
const kategorie = kategorien.find(k => k.id === konto.kategorie_id);
|
||||||
|
|
||||||
|
const spentGwg = transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'gwg').reduce((s, t) => s + Number(t.betrag), 0);
|
||||||
|
const spentAnlagen = transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'anlagen').reduce((s, t) => s + Number(t.betrag), 0);
|
||||||
|
const spentInst = transaktionen.filter(t => t.typ === 'ausgabe' && t.status === 'gebucht' && t.ausgaben_typ === 'instandhaltung').reduce((s, t) => s + Number(t.betrag), 0);
|
||||||
|
|
||||||
|
// Sibling budget usage for parent reference
|
||||||
|
const siblingBudgets = parentKonto ? (() => {
|
||||||
|
const siblings = alleKonten.filter(k => k.parent_id === parentKonto.id && k.id !== kontoId);
|
||||||
|
return {
|
||||||
|
gwg: siblings.reduce((s, k) => s + Number(k.budget_gwg || 0), 0),
|
||||||
|
anlagen: siblings.reduce((s, k) => s + Number(k.budget_anlagen || 0), 0),
|
||||||
|
instandhaltung: siblings.reduce((s, k) => s + Number(k.budget_instandhaltung || 0), 0),
|
||||||
|
};
|
||||||
|
})() : null;
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!form.bezeichnung) return;
|
if (!form.bezeichnung) return;
|
||||||
updateMut.mutate(form);
|
updateMut.mutate(form);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
const k = konto;
|
||||||
|
setForm({
|
||||||
|
haushaltsjahr_id: k.haushaltsjahr_id,
|
||||||
|
kontonummer: k.kontonummer,
|
||||||
|
bezeichnung: k.bezeichnung,
|
||||||
|
budget_gwg: k.budget_gwg,
|
||||||
|
budget_anlagen: k.budget_anlagen,
|
||||||
|
budget_instandhaltung: k.budget_instandhaltung,
|
||||||
|
parent_id: k.parent_id ?? undefined,
|
||||||
|
kategorie_id: k.kategorie_id ?? undefined,
|
||||||
|
notizen: k.notizen ?? '',
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
setSaveError(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
@@ -93,88 +174,83 @@ export default function BuchhaltungKontoManage() {
|
|||||||
<Typography variant="h5" sx={{ flexGrow: 1, ml: 1 }}>
|
<Typography variant="h5" sx={{ flexGrow: 1, ml: 1 }}>
|
||||||
{konto.kontonummer} — {konto.bezeichnung}
|
{konto.kontonummer} — {konto.bezeichnung}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
{isEditing ? (
|
||||||
variant="outlined"
|
<>
|
||||||
color="error"
|
<Button variant="outlined" startIcon={<Cancel />} onClick={handleCancel}>Abbrechen</Button>
|
||||||
startIcon={<Delete />}
|
<Button variant="contained" startIcon={<Save />} onClick={handleSave} disabled={updateMut.isPending || !form.bezeichnung}>Speichern</Button>
|
||||||
onClick={() => setDeleteOpen(true)}
|
</>
|
||||||
>
|
) : (
|
||||||
Löschen
|
<>
|
||||||
</Button>
|
<Button variant="outlined" color="error" size="small" startIcon={<Delete />} onClick={() => setDeleteOpen(true)}>Löschen</Button>
|
||||||
<Button
|
<Button variant="outlined" size="small" startIcon={<Edit />} onClick={() => setIsEditing(true)}>Bearbeiten</Button>
|
||||||
variant="contained"
|
</>
|
||||||
startIcon={<Save />}
|
)}
|
||||||
onClick={handleSave}
|
|
||||||
disabled={updateMut.isPending || !form.bezeichnung}
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Paper sx={{ p: 3, maxWidth: 600 }}>
|
<Stack spacing={3} sx={{ maxWidth: 640 }}>
|
||||||
<Stack spacing={2.5}>
|
{/* Section: Allgemein */}
|
||||||
<TextField
|
<Paper sx={{ p: 2.5 }}>
|
||||||
label="Kontonummer"
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>Allgemein</Typography>
|
||||||
type="number"
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
value={form.kontonummer ?? ''}
|
{isEditing ? (
|
||||||
onChange={e => setForm(f => ({ ...f, kontonummer: Number(e.target.value) }))}
|
<Stack spacing={2}>
|
||||||
required
|
<TextField label="Kontonummer" type="number" value={form.kontonummer ?? ''} onChange={e => setForm(f => ({ ...f, kontonummer: Number(e.target.value) }))} required fullWidth size="small" />
|
||||||
fullWidth
|
<TextField label="Bezeichnung" value={form.bezeichnung ?? ''} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} required fullWidth size="small" />
|
||||||
/>
|
<FormControl fullWidth size="small">
|
||||||
<TextField
|
<InputLabel>Übergeordnetes Konto</InputLabel>
|
||||||
label="Bezeichnung"
|
<Select value={form.parent_id ?? ''} label="Übergeordnetes Konto" onChange={e => setForm(f => ({ ...f, parent_id: e.target.value ? Number(e.target.value) : undefined }))}>
|
||||||
value={form.bezeichnung ?? ''}
|
<MenuItem value=""><em>Kein übergeordnetes Konto</em></MenuItem>
|
||||||
onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))}
|
{otherKonten.map(k => <MenuItem key={k.id} value={k.id}>{k.kontonummer} – {k.bezeichnung}</MenuItem>)}
|
||||||
required
|
</Select>
|
||||||
fullWidth
|
</FormControl>
|
||||||
/>
|
{kategorien.length > 0 && (
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth size="small">
|
||||||
<InputLabel>Übergeordnetes Konto</InputLabel>
|
<InputLabel>Kategorie</InputLabel>
|
||||||
<Select
|
<Select value={form.kategorie_id ?? ''} label="Kategorie" onChange={e => setForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : undefined }))}>
|
||||||
value={form.parent_id ?? ''}
|
<MenuItem value=""><em>Keine Kategorie</em></MenuItem>
|
||||||
label="Übergeordnetes Konto"
|
{kategorien.map(k => <MenuItem key={k.id} value={k.id}>{k.bezeichnung}</MenuItem>)}
|
||||||
onChange={e => setForm(f => ({ ...f, parent_id: e.target.value ? Number(e.target.value) : undefined }))}
|
</Select>
|
||||||
>
|
</FormControl>
|
||||||
<MenuItem value=""><em>Kein übergeordnetes Konto</em></MenuItem>
|
)}
|
||||||
{otherKonten.map(k => (
|
<TextField label="Notizen" value={form.notizen ?? ''} onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={3} fullWidth size="small" />
|
||||||
<MenuItem key={k.id} value={k.id}>{k.kontonummer} – {k.bezeichnung}</MenuItem>
|
</Stack>
|
||||||
))}
|
) : (
|
||||||
</Select>
|
<>
|
||||||
</FormControl>
|
<FieldRow label="Kontonummer" value={konto.kontonummer} />
|
||||||
<TextField
|
<FieldRow label="Bezeichnung" value={konto.bezeichnung} />
|
||||||
label="Budget GWG (€)"
|
<FieldRow label="Übergeordnetes Konto" value={parentKonto ? `${parentKonto.kontonummer} – ${parentKonto.bezeichnung}` : '—'} />
|
||||||
type="number"
|
<FieldRow label="Kategorie" value={kategorie?.bezeichnung || '—'} />
|
||||||
value={form.budget_gwg ?? 0}
|
<FieldRow label="Notizen" value={konto.notizen || '—'} />
|
||||||
onChange={e => setForm(f => ({ ...f, budget_gwg: parseFloat(e.target.value) || 0 }))}
|
</>
|
||||||
inputProps={{ step: '0.01', min: '0' }}
|
)}
|
||||||
fullWidth
|
</Paper>
|
||||||
/>
|
|
||||||
<TextField
|
{/* Section: Budget */}
|
||||||
label="Budget Anlagen (€)"
|
<Paper sx={{ p: 2.5 }}>
|
||||||
type="number"
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>Budget</Typography>
|
||||||
value={form.budget_anlagen ?? 0}
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
onChange={e => setForm(f => ({ ...f, budget_anlagen: parseFloat(e.target.value) || 0 }))}
|
{isEditing ? (
|
||||||
inputProps={{ step: '0.01', min: '0' }}
|
<Stack spacing={2}>
|
||||||
fullWidth
|
<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} />
|
||||||
<TextField
|
<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"
|
||||||
label="Budget Instandhaltung (€)"
|
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_anlagen)}, vergeben: ${fmtEur(siblingBudgets.anlagen)}, verfügbar: ${fmtEur(parentKonto.budget_anlagen - siblingBudgets.anlagen)}` : undefined} />
|
||||||
type="number"
|
<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"
|
||||||
value={form.budget_instandhaltung ?? 0}
|
helperText={parentKonto && siblingBudgets ? `Eltern: ${fmtEur(parentKonto.budget_instandhaltung)}, vergeben: ${fmtEur(siblingBudgets.instandhaltung)}, verfügbar: ${fmtEur(parentKonto.budget_instandhaltung - siblingBudgets.instandhaltung)}` : undefined} />
|
||||||
onChange={e => setForm(f => ({ ...f, budget_instandhaltung: parseFloat(e.target.value) || 0 }))}
|
{saveError && <Alert severity="error" onClose={() => setSaveError(null)}>{saveError}</Alert>}
|
||||||
inputProps={{ step: '0.01', min: '0' }}
|
</Stack>
|
||||||
fullWidth
|
) : (
|
||||||
/>
|
<>
|
||||||
<TextField
|
<BudgetBar label="GWG" budget={konto.budget_gwg} spent={spentGwg} />
|
||||||
label="Notizen"
|
<BudgetBar label="Anlagen" budget={konto.budget_anlagen} spent={spentAnlagen} />
|
||||||
value={form.notizen ?? ''}
|
<BudgetBar label="Instandhaltung" budget={konto.budget_instandhaltung} spent={spentInst} />
|
||||||
onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))}
|
<Divider sx={{ my: 1 }} />
|
||||||
multiline
|
<FieldRow label="Budget Gesamt" value={fmtEur(konto.budget_gwg + konto.budget_anlagen + konto.budget_instandhaltung)} />
|
||||||
rows={3}
|
<FieldRow label="Ausgaben Gesamt" value={fmtEur(spentGwg + spentAnlagen + spentInst)} />
|
||||||
fullWidth
|
</>
|
||||||
/>
|
)}
|
||||||
</Stack>
|
</Paper>
|
||||||
</Paper>
|
</Stack>
|
||||||
|
|
||||||
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
|
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
|
||||||
<DialogTitle>Konto löschen</DialogTitle>
|
<DialogTitle>Konto löschen</DialogTitle>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
BuchhaltungStats,
|
BuchhaltungStats,
|
||||||
WiederkehrendBuchung, WiederkehrendFormData,
|
WiederkehrendBuchung, WiederkehrendFormData,
|
||||||
Freigabe,
|
Freigabe,
|
||||||
|
Kategorie,
|
||||||
} from '../types/buchhaltung.types';
|
} from '../types/buchhaltung.types';
|
||||||
|
|
||||||
export const buchhaltungApi = {
|
export const buchhaltungApi = {
|
||||||
@@ -165,6 +166,23 @@ export const buchhaltungApi = {
|
|||||||
return r.data;
|
return r.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Kategorien ──────────────────────────────────────────────────────────────
|
||||||
|
getKategorien: async (haushaltsjahrId: number): Promise<Kategorie[]> => {
|
||||||
|
const r = await api.get(`/api/buchhaltung/kategorien?haushaltsjahr_id=${haushaltsjahrId}`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createKategorie: async (data: { haushaltsjahr_id: number; bezeichnung: string; sortierung?: number }): Promise<Kategorie> => {
|
||||||
|
const r = await api.post('/api/buchhaltung/kategorien', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateKategorie: async (id: number, data: Partial<{ bezeichnung: string; sortierung: number }>): Promise<Kategorie> => {
|
||||||
|
const r = await api.patch(`/api/buchhaltung/kategorien/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteKategorie: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/buchhaltung/kategorien/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
// ── Freigaben ─────────────────────────────────────────────────────────────────
|
// ── Freigaben ─────────────────────────────────────────────────────────────────
|
||||||
requestFreigabe: async (transaktionId: number): Promise<Freigabe> => {
|
requestFreigabe: async (transaktionId: number): Promise<Freigabe> => {
|
||||||
const r = await api.post(`/api/buchhaltung/${transaktionId}/freigabe`);
|
const r = await api.post(`/api/buchhaltung/${transaktionId}/freigabe`);
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ export const INTERVALL_LABELS: Record<WiederkehrendIntervall, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Entities
|
// Entities
|
||||||
|
export interface Kategorie {
|
||||||
|
id: number;
|
||||||
|
haushaltsjahr_id: number;
|
||||||
|
bezeichnung: string;
|
||||||
|
sortierung: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KontoTyp {
|
export interface KontoTyp {
|
||||||
id: number;
|
id: number;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
@@ -87,6 +94,7 @@ export interface Konto {
|
|||||||
kontonummer: number;
|
kontonummer: number;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
|
kategorie_id: number | null;
|
||||||
budget_gwg: number;
|
budget_gwg: number;
|
||||||
budget_anlagen: number;
|
budget_anlagen: number;
|
||||||
budget_instandhaltung: number;
|
budget_instandhaltung: number;
|
||||||
@@ -106,6 +114,7 @@ export interface KontoTreeNode extends Konto {
|
|||||||
spent_anlagen: number;
|
spent_anlagen: number;
|
||||||
spent_instandhaltung: number;
|
spent_instandhaltung: number;
|
||||||
einnahmen_betrag: number;
|
einnahmen_betrag: number;
|
||||||
|
kategorie_bezeichnung?: string;
|
||||||
children: KontoTreeNode[];
|
children: KontoTreeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +186,8 @@ export interface WiederkehrendBuchung {
|
|||||||
empfaenger_auftraggeber: string | null;
|
empfaenger_auftraggeber: string | null;
|
||||||
intervall: WiederkehrendIntervall;
|
intervall: WiederkehrendIntervall;
|
||||||
naechste_ausfuehrung: string;
|
naechste_ausfuehrung: string;
|
||||||
|
ausfuehrungstag: 'erster' | 'mitte' | 'letzter';
|
||||||
|
ausfuehrungs_monat: number | null;
|
||||||
aktiv: boolean;
|
aktiv: boolean;
|
||||||
erstellt_von: string | null;
|
erstellt_von: string | null;
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
@@ -225,6 +236,7 @@ export interface KontoFormData {
|
|||||||
budget_anlagen: number;
|
budget_anlagen: number;
|
||||||
budget_instandhaltung: number;
|
budget_instandhaltung: number;
|
||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
|
kategorie_id?: number | null;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +276,8 @@ export interface WiederkehrendFormData {
|
|||||||
empfaenger_auftraggeber?: string;
|
empfaenger_auftraggeber?: string;
|
||||||
intervall: WiederkehrendIntervall;
|
intervall: WiederkehrendIntervall;
|
||||||
naechste_ausfuehrung: string;
|
naechste_ausfuehrung: string;
|
||||||
|
ausfuehrungstag?: 'erster' | 'mitte' | 'letzter';
|
||||||
|
ausfuehrungs_monat?: number;
|
||||||
aktiv?: boolean;
|
aktiv?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user