feat(buchhaltung): add transfers, bank statements, Haushaltsplan, and PDF export
This commit is contained in:
@@ -201,6 +201,23 @@ class BuchhaltungController {
|
||||
}
|
||||
}
|
||||
|
||||
async getBankkontoStatement(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 filters = {
|
||||
von: req.query.von as string | undefined,
|
||||
bis: req.query.bis as string | undefined,
|
||||
};
|
||||
const data = await buchhaltungService.getBankkontoStatement(id, filters);
|
||||
if (!data) { res.status(404).json({ success: false, message: 'Bankkonto nicht gefunden' }); return; }
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.getBankkontoStatement', { error });
|
||||
res.status(500).json({ success: false, message: 'Kontoauszug konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Konten ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async listKonten(req: Request, res: Response): Promise<void> {
|
||||
@@ -411,6 +428,18 @@ class BuchhaltungController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transfers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async createTransfer(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await buchhaltungService.createTransfer(req.body, req.user!.id);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.createTransfer', { error });
|
||||
res.status(500).json({ success: false, message: 'Umbuchung konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Belege ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async uploadBeleg(req: Request, res: Response): Promise<void> {
|
||||
@@ -566,6 +595,128 @@ class BuchhaltungController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Planung ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async listPlanungen(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await buchhaltungService.listPlanungen();
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.listPlanungen', { error });
|
||||
res.status(500).json({ success: false, message: 'Planungen konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getPlanung(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.getPlanungById(id);
|
||||
if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; }
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.getPlanung', { error });
|
||||
res.status(500).json({ success: false, message: 'Planung konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async createPlanung(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await buchhaltungService.createPlanung(req.body, req.user!.id);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.createPlanung', { error });
|
||||
res.status(500).json({ success: false, message: 'Planung konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async updatePlanung(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.updatePlanung(id, req.body);
|
||||
if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; }
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.updatePlanung', { error });
|
||||
res.status(500).json({ success: false, message: 'Planung konnte nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async deletePlanung(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.deletePlanung(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.deletePlanung', { error });
|
||||
res.status(500).json({ success: false, message: 'Planung konnte nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async createPlanposition(req: Request, res: Response): Promise<void> {
|
||||
const planungId = parseInt(param(req, 'id'), 10);
|
||||
if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||
try {
|
||||
const data = await buchhaltungService.createPlanposition(planungId, req.body);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.createPlanposition', { error });
|
||||
res.status(500).json({ success: false, message: 'Planposition konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async updatePlanposition(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.updatePlanposition(id, req.body);
|
||||
if (!data) { res.status(404).json({ success: false, message: 'Planposition nicht gefunden' }); return; }
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.updatePlanposition', { error });
|
||||
res.status(500).json({ success: false, message: 'Planposition konnte nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async deletePlanposition(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.deletePlanposition(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.deletePlanposition', { error });
|
||||
res.status(500).json({ success: false, message: 'Planposition konnte nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async listPlanpositionen(req: Request, res: Response): Promise<void> {
|
||||
const planungId = parseInt(param(req, 'id'), 10);
|
||||
if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||
try {
|
||||
const data = await buchhaltungService.getPlanpositionenByPlanung(planungId);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungController.listPlanpositionen', { error });
|
||||
res.status(500).json({ success: false, message: 'Planpositionen konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async createHaushaltsjahrFromPlan(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.createHaushaltsjahrFromPlan(id, req.user!.id);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error('BuchhaltungController.createHaushaltsjahrFromPlan', { error });
|
||||
const status = error.statusCode || 500;
|
||||
res.status(status).json({ success: false, message: error.message || 'Haushaltsjahr konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Freigaben ────────────────────────────────────────────────────────────────
|
||||
|
||||
async requestFreigabe(req: Request, res: Response): Promise<void> {
|
||||
|
||||
18
backend/src/database/migrations/082_buchhaltung_transfer.sql
Normal file
18
backend/src/database/migrations/082_buchhaltung_transfer.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Migration 082: Add 'transfer' transaction type + target bank account column
|
||||
|
||||
-- 1. Drop and recreate the typ CHECK constraint to include 'transfer'
|
||||
ALTER TABLE buchhaltung_transaktionen
|
||||
DROP CONSTRAINT IF EXISTS buchhaltung_transaktionen_typ_check;
|
||||
|
||||
ALTER TABLE buchhaltung_transaktionen
|
||||
ADD CONSTRAINT buchhaltung_transaktionen_typ_check
|
||||
CHECK (typ IN ('einnahme', 'ausgabe', 'transfer'));
|
||||
|
||||
-- 2. Add target bank account column for transfers
|
||||
ALTER TABLE buchhaltung_transaktionen
|
||||
ADD COLUMN IF NOT EXISTS transfer_ziel_bankkonto_id INT
|
||||
REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_buch_trans_transfer_ziel
|
||||
ON buchhaltung_transaktionen(transfer_ziel_bankkonto_id)
|
||||
WHERE transfer_ziel_bankkonto_id IS NOT NULL;
|
||||
@@ -33,6 +33,7 @@ router.get('/bankkonten', authenticate, requirePermission('buchhaltung:vie
|
||||
router.post('/bankkonten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createBankkonto.bind(buchhaltungController));
|
||||
router.patch('/bankkonten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateBankkonto.bind(buchhaltungController));
|
||||
router.delete('/bankkonten/:id',authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteBankkonto.bind(buchhaltungController));
|
||||
router.get('/bankkonten/:id/transaktionen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getBankkontoStatement.bind(buchhaltungController));
|
||||
|
||||
// ── Konten ────────────────────────────────────────────────────────────────────
|
||||
router.get('/konten/tree', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontenTree.bind(buchhaltungController));
|
||||
@@ -60,6 +61,18 @@ router.delete('/wiederkehrend/:id', authenticate, requirePermission('buchhaltung
|
||||
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));
|
||||
|
||||
// ── Planung ──────────────────────────────────────────────────────────────────
|
||||
router.get('/planung', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listPlanungen.bind(buchhaltungController));
|
||||
router.post('/planung', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createPlanung.bind(buchhaltungController));
|
||||
router.get('/planung/:id', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getPlanung.bind(buchhaltungController));
|
||||
router.put('/planung/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updatePlanung.bind(buchhaltungController));
|
||||
router.delete('/planung/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deletePlanung.bind(buchhaltungController));
|
||||
router.get('/planung/:id/positionen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listPlanpositionen.bind(buchhaltungController));
|
||||
router.post('/planung/:id/positionen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createPlanposition.bind(buchhaltungController));
|
||||
router.put('/planung/positionen/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updatePlanposition.bind(buchhaltungController));
|
||||
router.delete('/planung/positionen/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deletePlanposition.bind(buchhaltungController));
|
||||
router.post('/planung/:id/create-haushaltsjahr', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createHaushaltsjahrFromPlan.bind(buchhaltungController));
|
||||
|
||||
// ── Audit ──────────────────────────────────────────────────────────────────────
|
||||
router.get('/audit/:transaktionId', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getAudit.bind(buchhaltungController));
|
||||
|
||||
@@ -70,6 +83,9 @@ router.get('/export/csv', authenticate, requirePermission('buchhaltung:export'),
|
||||
router.patch('/freigaben/:id/genehmigen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.approveFreigabe.bind(buchhaltungController));
|
||||
router.patch('/freigaben/:id/ablehnen', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.rejectFreigabe.bind(buchhaltungController));
|
||||
|
||||
// ── Transfers ─────────────────────────────────────────────────────────────────
|
||||
router.post('/transfers', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransfer.bind(buchhaltungController));
|
||||
|
||||
// ── Transaktionen (list/create — before /:id) ─────────────────────────────────
|
||||
router.get('/', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listTransaktionen.bind(buchhaltungController));
|
||||
router.post('/', authenticate, requirePermission('buchhaltung:create'), buchhaltungController.createTransaktion.bind(buchhaltungController));
|
||||
|
||||
@@ -8,6 +8,7 @@ import fs from 'fs';
|
||||
import notificationService from './notification.service';
|
||||
import { permissionService } from './permission.service';
|
||||
import ausruestungsanfrageService from './ausruestungsanfrage.service';
|
||||
import buchhaltungService from './buchhaltung.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog (shared ausruestung_artikel via ausruestungsanfrageService)
|
||||
@@ -381,6 +382,17 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
|
||||
}
|
||||
}
|
||||
|
||||
// Block transition to abgeschlossen if positions have missing prices
|
||||
if (status === 'abgeschlossen') {
|
||||
const posCheck = await pool.query(
|
||||
`SELECT COUNT(*) FROM bestellpositionen WHERE bestellung_id = $1 AND (einzelpreis IS NULL OR einzelpreis = 0)`,
|
||||
[id]
|
||||
);
|
||||
if (parseInt(posCheck.rows[0].count) > 0) {
|
||||
throw new Error('Alle Positionen müssen einen Einzelpreis haben, bevor die Bestellung abgeschlossen werden kann.');
|
||||
}
|
||||
}
|
||||
|
||||
const updates: string[] = ['status = $1', 'aktualisiert_am = NOW()'];
|
||||
const params: unknown[] = [status];
|
||||
let paramIndex = 2;
|
||||
@@ -452,6 +464,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
|
||||
});
|
||||
}
|
||||
|
||||
// Create pending buchhaltung transaction when order is completed
|
||||
if (status === 'abgeschlossen') {
|
||||
buchhaltungService.createPendingFromBestellung(id, userId).catch(err =>
|
||||
logger.error('createPendingFromBestellung failed (non-fatal)', { err, orderId: id })
|
||||
);
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('BestellungService.updateOrderStatus failed', { error, id });
|
||||
|
||||
@@ -354,6 +354,98 @@ async function deactivateBankkonto(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getBankkontoStatement(
|
||||
bankkontoId: number,
|
||||
filters?: { von?: string; bis?: string }
|
||||
) {
|
||||
try {
|
||||
// 1. Get the bank account
|
||||
const kontoResult = await pool.query(
|
||||
`SELECT * FROM buchhaltung_bankkonten WHERE id = $1`,
|
||||
[bankkontoId]
|
||||
);
|
||||
if (!kontoResult.rows[0]) return null;
|
||||
|
||||
// 2. Fetch all transactions involving this bank account (both directions for transfers)
|
||||
const conditions: string[] = [
|
||||
`(t.bankkonto_id = $1 OR t.transfer_ziel_bankkonto_id = $1)`
|
||||
];
|
||||
const values: unknown[] = [bankkontoId];
|
||||
let idx = 2;
|
||||
|
||||
if (filters?.von) { conditions.push(`t.datum >= $${idx++}`); values.push(filters.von); }
|
||||
if (filters?.bis) { conditions.push(`t.datum <= $${idx++}`); values.push(filters.bis); }
|
||||
|
||||
// Only include booked/approved transactions for the statement
|
||||
conditions.push(`t.status IN ('gebucht', 'freigegeben')`);
|
||||
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const txResult = await pool.query(
|
||||
`SELECT t.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung,
|
||||
bk2.bezeichnung AS transfer_ziel_bezeichnung
|
||||
FROM buchhaltung_transaktionen t
|
||||
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
|
||||
${where}
|
||||
ORDER BY t.datum ASC, t.id ASC`,
|
||||
values
|
||||
);
|
||||
|
||||
// 3. Compute running balance
|
||||
let laufenderSaldo = 0;
|
||||
let gesamteinnahmen = 0;
|
||||
let gesamtausgaben = 0;
|
||||
|
||||
const rows = txResult.rows.map(row => {
|
||||
const betrag = parseFloat(row.betrag);
|
||||
let aenderung = 0;
|
||||
|
||||
if (row.typ === 'transfer') {
|
||||
if (row.bankkonto_id === bankkontoId) {
|
||||
// Money leaving this account
|
||||
aenderung = -betrag;
|
||||
gesamtausgaben += betrag;
|
||||
} else {
|
||||
// Money arriving at this account (transfer_ziel_bankkonto_id matches)
|
||||
aenderung = betrag;
|
||||
gesamteinnahmen += betrag;
|
||||
}
|
||||
} else if (row.typ === 'einnahme') {
|
||||
aenderung = betrag;
|
||||
gesamteinnahmen += betrag;
|
||||
} else if (row.typ === 'ausgabe') {
|
||||
aenderung = -betrag;
|
||||
gesamtausgaben += betrag;
|
||||
}
|
||||
|
||||
laufenderSaldo += aenderung;
|
||||
|
||||
return {
|
||||
...row,
|
||||
aenderung,
|
||||
laufender_saldo: Math.round(laufenderSaldo * 100) / 100,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
konto: kontoResult.rows[0],
|
||||
rows,
|
||||
summary: {
|
||||
gesamteinnahmen: Math.round(gesamteinnahmen * 100) / 100,
|
||||
gesamtausgaben: Math.round(gesamtausgaben * 100) / 100,
|
||||
saldo: Math.round(laufenderSaldo * 100) / 100,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getBankkontoStatement failed', { error, bankkontoId });
|
||||
throw new Error('Kontoauszug konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Konten (Budget Accounts)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -445,10 +537,12 @@ async function getKontoDetail(kontoId: number) {
|
||||
`SELECT t.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung
|
||||
bk.bezeichnung as bankkonto_bezeichnung,
|
||||
bk2.bezeichnung AS transfer_ziel_bezeichnung
|
||||
FROM buchhaltung_transaktionen t
|
||||
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
|
||||
WHERE t.konto_id = $1
|
||||
ORDER BY t.datum DESC, t.id DESC`,
|
||||
[kontoId]
|
||||
@@ -702,10 +796,12 @@ async function listTransaktionen(filters: {
|
||||
`SELECT t.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung
|
||||
bk.bezeichnung as bankkonto_bezeichnung,
|
||||
bk2.bezeichnung AS transfer_ziel_bezeichnung
|
||||
FROM buchhaltung_transaktionen t
|
||||
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
|
||||
${where}
|
||||
ORDER BY t.datum DESC, t.id DESC`,
|
||||
values
|
||||
@@ -723,10 +819,12 @@ async function getTransaktionById(id: number) {
|
||||
`SELECT t.*,
|
||||
k.bezeichnung as konto_bezeichnung,
|
||||
k.kontonummer as konto_kontonummer,
|
||||
bk.bezeichnung as bankkonto_bezeichnung
|
||||
bk.bezeichnung as bankkonto_bezeichnung,
|
||||
bk2.bezeichnung AS transfer_ziel_bezeichnung
|
||||
FROM buchhaltung_transaktionen t
|
||||
LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id
|
||||
LEFT JOIN buchhaltung_bankkonten bk2 ON t.transfer_ziel_bankkonto_id = bk2.id
|
||||
WHERE t.id = $1`,
|
||||
[id]
|
||||
);
|
||||
@@ -909,6 +1007,59 @@ async function deleteTransaktion(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transfers (Umbuchungen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createTransfer(
|
||||
data: {
|
||||
haushaltsjahr_id: number;
|
||||
bankkonto_id: number;
|
||||
transfer_ziel_bankkonto_id: number;
|
||||
betrag: number;
|
||||
datum: string;
|
||||
beschreibung?: string;
|
||||
beleg_nr?: string;
|
||||
},
|
||||
userId: string
|
||||
) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Debit row (money leaves source account)
|
||||
const debitResult = await client.query(
|
||||
`INSERT INTO buchhaltung_transaktionen
|
||||
(haushaltsjahr_id, bankkonto_id, transfer_ziel_bankkonto_id, typ, betrag, datum, beschreibung, beleg_nr, erstellt_von, status)
|
||||
VALUES ($1, $2, $3, 'transfer', $4, $5, $6, $7, $8, 'gebucht')
|
||||
RETURNING *`,
|
||||
[data.haushaltsjahr_id, data.bankkonto_id, data.transfer_ziel_bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.beleg_nr || null, userId]
|
||||
);
|
||||
|
||||
// Credit row (money arrives at target account)
|
||||
const creditResult = await client.query(
|
||||
`INSERT INTO buchhaltung_transaktionen
|
||||
(haushaltsjahr_id, bankkonto_id, transfer_ziel_bankkonto_id, typ, betrag, datum, beschreibung, beleg_nr, erstellt_von, status)
|
||||
VALUES ($1, $2, $3, 'transfer', $4, $5, $6, $7, $8, 'gebucht')
|
||||
RETURNING *`,
|
||||
[data.haushaltsjahr_id, data.transfer_ziel_bankkonto_id, data.bankkonto_id, data.betrag, data.datum, data.beschreibung || null, data.beleg_nr || null, userId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
await logAudit(debitResult.rows[0].id, 'transfer_erstellt', { betrag: data.betrag, von: data.bankkonto_id, nach: data.transfer_ziel_bankkonto_id }, userId);
|
||||
await logAudit(creditResult.rows[0].id, 'transfer_erstellt', { betrag: data.betrag, von: data.bankkonto_id, nach: data.transfer_ziel_bankkonto_id }, userId);
|
||||
|
||||
return { debit: debitResult.rows[0], credit: creditResult.rows[0] };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('BuchhaltungService.createTransfer failed', { error });
|
||||
throw new Error('Umbuchung konnte nicht erstellt werden');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Belege (Receipts)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1435,6 +1586,331 @@ async function getErstattungLinks(transaktionId: number): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bestellung → Buchhaltung Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createPendingFromBestellung(bestellungId: number, userId: string) {
|
||||
try {
|
||||
// 1. Fetch the bestellung with total
|
||||
const bestResult = await pool.query(
|
||||
`SELECT b.*, l.name AS lieferant_name, COALESCE(SUM(bp.menge * bp.einzelpreis), 0) AS gesamtbetrag
|
||||
FROM bestellungen b
|
||||
LEFT JOIN lieferanten l ON l.id = b.lieferant_id
|
||||
LEFT JOIN bestellpositionen bp ON bp.bestellung_id = b.id
|
||||
WHERE b.id = $1
|
||||
GROUP BY b.id, l.name`,
|
||||
[bestellungId]
|
||||
);
|
||||
if (!bestResult.rows[0]) {
|
||||
logger.warn('createPendingFromBestellung: Bestellung not found', { bestellungId });
|
||||
return;
|
||||
}
|
||||
const bestellung = bestResult.rows[0];
|
||||
const gesamtbetrag = parseFloat(bestellung.gesamtbetrag);
|
||||
if (gesamtbetrag <= 0) {
|
||||
logger.info('createPendingFromBestellung: Gesamtbetrag is 0, skipping', { bestellungId });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Find active Haushaltsjahr
|
||||
const hjResult = await pool.query(
|
||||
`SELECT id FROM buchhaltung_haushaltsjahre WHERE abgeschlossen = FALSE ORDER BY jahr DESC LIMIT 1`
|
||||
);
|
||||
if (!hjResult.rows[0]) {
|
||||
logger.info('createPendingFromBestellung: No active Haushaltsjahr found, skipping', { bestellungId });
|
||||
return;
|
||||
}
|
||||
const haushaltsjahrId = hjResult.rows[0].id;
|
||||
|
||||
// 3. Find default bank account
|
||||
const bkResult = await pool.query(
|
||||
`SELECT id FROM buchhaltung_bankkonten WHERE aktiv = TRUE ORDER BY ist_standard DESC LIMIT 1`
|
||||
);
|
||||
const bankkontoId = bkResult.rows[0]?.id || null;
|
||||
|
||||
// 4. Insert pending transaction
|
||||
const label = bestellung.laufende_nummer ? `Bestellung #${bestellung.laufende_nummer}` : `Bestellung #${bestellung.id}`;
|
||||
await pool.query(
|
||||
`INSERT INTO buchhaltung_transaktionen
|
||||
(haushaltsjahr_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, bestellung_id, erstellt_von, status)
|
||||
VALUES ($1, $2, 'ausgabe', $3, NOW(), $4, $5, $6, $7, 'entwurf')`,
|
||||
[haushaltsjahrId, bankkontoId, gesamtbetrag, label, bestellung.lieferant_name || null, bestellungId, userId]
|
||||
);
|
||||
|
||||
logger.info('createPendingFromBestellung: Created pending transaction', { bestellungId, gesamtbetrag });
|
||||
} catch (error) {
|
||||
logger.error('createPendingFromBestellung failed (non-fatal)', { error, bestellungId });
|
||||
// Non-fatal: don't throw
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Planung (Budget Plans)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listPlanungen() {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT p.*,
|
||||
hj.bezeichnung AS haushaltsjahr_bezeichnung,
|
||||
hj.jahr AS haushaltsjahr_jahr,
|
||||
COALESCE(pos.positionen_count, 0) AS positionen_count,
|
||||
COALESCE(pos.total_gwg, 0) AS total_gwg,
|
||||
COALESCE(pos.total_anlagen, 0) AS total_anlagen,
|
||||
COALESCE(pos.total_instandhaltung, 0) AS total_instandhaltung
|
||||
FROM buchhaltung_planung p
|
||||
LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS positionen_count,
|
||||
SUM(budget_gwg) AS total_gwg,
|
||||
SUM(budget_anlagen) AS total_anlagen,
|
||||
SUM(budget_instandhaltung) AS total_instandhaltung
|
||||
FROM buchhaltung_planpositionen
|
||||
WHERE planung_id = p.id
|
||||
) pos ON true
|
||||
ORDER BY p.erstellt_am DESC`
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.listPlanungen failed', { error });
|
||||
throw new Error('Planungen konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function getPlanungById(id: number) {
|
||||
try {
|
||||
const planungResult = await pool.query(
|
||||
`SELECT p.*,
|
||||
hj.bezeichnung AS haushaltsjahr_bezeichnung,
|
||||
hj.jahr AS haushaltsjahr_jahr
|
||||
FROM buchhaltung_planung p
|
||||
LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id
|
||||
WHERE p.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!planungResult.rows[0]) return null;
|
||||
|
||||
const positionenResult = await pool.query(
|
||||
`SELECT pp.*,
|
||||
k.bezeichnung AS konto_bezeichnung,
|
||||
k.kontonummer AS konto_kontonummer
|
||||
FROM buchhaltung_planpositionen pp
|
||||
LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id
|
||||
WHERE pp.planung_id = $1
|
||||
ORDER BY pp.sort_order, pp.id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return {
|
||||
...planungResult.rows[0],
|
||||
positionen: positionenResult.rows,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getPlanungById failed', { error, id });
|
||||
throw new Error('Planung konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlanung(data: { haushaltsjahr_id: number; bezeichnung: string }, userId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO buchhaltung_planung (haushaltsjahr_id, bezeichnung, erstellt_von)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[data.haushaltsjahr_id, data.bezeichnung, userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.createPlanung failed', { error });
|
||||
throw new Error('Planung konnte nicht erstellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlanung(id: number, data: { bezeichnung?: string; status?: string }) {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
|
||||
if (data.status !== undefined) { fields.push(`status = $${idx++}`); values.push(data.status); }
|
||||
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE buchhaltung_planung SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.updatePlanung failed', { error, id });
|
||||
throw new Error('Planung konnte nicht aktualisiert werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlanung(id: number) {
|
||||
try {
|
||||
await pool.query(`DELETE FROM buchhaltung_planung WHERE id = $1`, [id]);
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.deletePlanung failed', { error, id });
|
||||
throw new Error('Planung konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlanposition(planungId: number, data: { konto_id?: number | null; bezeichnung: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO buchhaltung_planpositionen (planung_id, konto_id, bezeichnung, budget_gwg, budget_anlagen, budget_instandhaltung, notizen, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[planungId, data.konto_id || null, data.bezeichnung, data.budget_gwg || 0, data.budget_anlagen || 0, data.budget_instandhaltung || 0, data.notizen || null, data.sort_order || 0]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.createPlanposition failed', { error, planungId });
|
||||
throw new Error('Planposition konnte nicht erstellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlanposition(id: number, data: { konto_id?: number | null; bezeichnung?: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }) {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (data.konto_id !== undefined) { fields.push(`konto_id = $${idx++}`); values.push(data.konto_id); }
|
||||
if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); }
|
||||
if (data.budget_gwg !== undefined) { fields.push(`budget_gwg = $${idx++}`); values.push(data.budget_gwg); }
|
||||
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.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); }
|
||||
if (data.sort_order !== undefined) { fields.push(`sort_order = $${idx++}`); values.push(data.sort_order); }
|
||||
if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren');
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE buchhaltung_planpositionen SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.updatePlanposition failed', { error, id });
|
||||
throw new Error('Planposition konnte nicht aktualisiert werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlanposition(id: number) {
|
||||
try {
|
||||
await pool.query(`DELETE FROM buchhaltung_planpositionen WHERE id = $1`, [id]);
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.deletePlanposition failed', { error, id });
|
||||
throw new Error('Planposition konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function getPlanpositionenByPlanung(planungId: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT pp.*,
|
||||
k.bezeichnung AS konto_bezeichnung,
|
||||
k.kontonummer AS konto_kontonummer
|
||||
FROM buchhaltung_planpositionen pp
|
||||
LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id
|
||||
WHERE pp.planung_id = $1
|
||||
ORDER BY pp.sort_order, pp.id`,
|
||||
[planungId]
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungService.getPlanpositionenByPlanung failed', { error, planungId });
|
||||
throw new Error('Planpositionen konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createHaushaltsjahrFromPlan(planungId: number, userId: string) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const planResult = await client.query(
|
||||
`SELECT p.*, hj.jahr, hj.beginn, hj.ende
|
||||
FROM buchhaltung_planung p
|
||||
LEFT JOIN buchhaltung_haushaltsjahre hj ON hj.id = p.haushaltsjahr_id
|
||||
WHERE p.id = $1`,
|
||||
[planungId]
|
||||
);
|
||||
if (!planResult.rows[0]) throw new Error('Planung nicht gefunden');
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
const nextYear = plan.jahr + 1;
|
||||
|
||||
// Check if Haushaltsjahr for next year already exists
|
||||
const existingResult = await client.query(
|
||||
`SELECT id FROM buchhaltung_haushaltsjahre WHERE jahr = $1`,
|
||||
[nextYear]
|
||||
);
|
||||
if (existingResult.rows.length > 0) {
|
||||
throw Object.assign(new Error(`Haushaltsjahr ${nextYear} existiert bereits`), { statusCode: 409 });
|
||||
}
|
||||
|
||||
// Create new Haushaltsjahr
|
||||
const hjResult = await client.query(
|
||||
`INSERT INTO buchhaltung_haushaltsjahre (jahr, bezeichnung, beginn, ende, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[nextYear, `Haushaltsjahr ${nextYear}`, `${nextYear}-01-01`, `${nextYear}-12-31`, userId]
|
||||
);
|
||||
const newHj = hjResult.rows[0];
|
||||
|
||||
// Get all positions from the plan
|
||||
const posResult = await client.query(
|
||||
`SELECT pp.*, k.kontonummer, k.bezeichnung AS konto_bezeichnung, k.konto_typ_id, k.parent_id, k.kategorie_id, k.budget_typ
|
||||
FROM buchhaltung_planpositionen pp
|
||||
LEFT JOIN buchhaltung_konten k ON pp.konto_id = k.id
|
||||
WHERE pp.planung_id = $1
|
||||
ORDER BY pp.sort_order, pp.id`,
|
||||
[planungId]
|
||||
);
|
||||
|
||||
// Create konten in the new Haushaltsjahr from plan positions
|
||||
const kontoIdMap = new Map<number, number>(); // old konto_id -> new konto_id
|
||||
for (const pos of posResult.rows) {
|
||||
if (!pos.konto_id) continue;
|
||||
|
||||
// Map parent_id to new parent if it was already created
|
||||
const newParentId = pos.parent_id ? (kontoIdMap.get(pos.parent_id) || null) : null;
|
||||
|
||||
const kontoResult = await client.query(
|
||||
`INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, parent_id, budget_gwg, budget_anlagen, budget_instandhaltung, erstellt_von, kategorie_id, budget_typ)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (haushaltsjahr_id, kontonummer) DO UPDATE SET
|
||||
budget_gwg = EXCLUDED.budget_gwg,
|
||||
budget_anlagen = EXCLUDED.budget_anlagen,
|
||||
budget_instandhaltung = EXCLUDED.budget_instandhaltung
|
||||
RETURNING id`,
|
||||
[newHj.id, pos.konto_typ_id, pos.kontonummer, pos.konto_bezeichnung || pos.bezeichnung, newParentId, pos.budget_gwg, pos.budget_anlagen, pos.budget_instandhaltung, userId, pos.kategorie_id, pos.budget_typ || 'detailliert']
|
||||
);
|
||||
kontoIdMap.set(pos.konto_id, kontoResult.rows[0].id);
|
||||
}
|
||||
|
||||
// Mark plan as abgeschlossen
|
||||
await client.query(
|
||||
`UPDATE buchhaltung_planung SET status = 'abgeschlossen' WHERE id = $1`,
|
||||
[planungId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
await logAudit(null, 'haushaltsjahr_aus_plan_erstellt', { planung_id: planungId, haushaltsjahr_id: newHj.id, jahr: nextYear }, userId);
|
||||
|
||||
return { haushaltsjahr: newHj };
|
||||
} catch (error: any) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('BuchhaltungService.createHaushaltsjahrFromPlan failed', { error, planungId });
|
||||
if (error.statusCode) throw error;
|
||||
throw new Error('Haushaltsjahr konnte nicht aus Planung erstellt werden');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1459,6 +1935,7 @@ const buchhaltungService = {
|
||||
createBankkonto,
|
||||
updateBankkonto,
|
||||
deactivateBankkonto,
|
||||
getBankkontoStatement,
|
||||
getAllKonten,
|
||||
getKontoById,
|
||||
getKontenTree,
|
||||
@@ -1475,6 +1952,7 @@ const buchhaltungService = {
|
||||
bookTransaktion,
|
||||
stornoTransaktion,
|
||||
deleteTransaktion,
|
||||
createTransfer,
|
||||
getBelegeByTransaktion,
|
||||
uploadBeleg,
|
||||
deleteBeleg,
|
||||
@@ -1495,6 +1973,17 @@ const buchhaltungService = {
|
||||
exportTransaktionenCsv,
|
||||
createErstattung,
|
||||
getErstattungLinks,
|
||||
createPendingFromBestellung,
|
||||
listPlanungen,
|
||||
getPlanungById,
|
||||
createPlanung,
|
||||
updatePlanung,
|
||||
deletePlanung,
|
||||
createPlanposition,
|
||||
updatePlanposition,
|
||||
deletePlanposition,
|
||||
getPlanpositionenByPlanung,
|
||||
createHaushaltsjahrFromPlan,
|
||||
};
|
||||
|
||||
export default buchhaltungService;
|
||||
|
||||
@@ -41,6 +41,9 @@ import Checklisten from './pages/Checklisten';
|
||||
import Buchhaltung from './pages/Buchhaltung';
|
||||
import BuchhaltungKontoDetail from './pages/BuchhaltungKontoDetail';
|
||||
import BuchhaltungKontoManage from './pages/BuchhaltungKontoManage';
|
||||
import BuchhaltungBankkontoDetail from './pages/BuchhaltungBankkontoDetail';
|
||||
import Haushaltsplan from './pages/Haushaltsplan';
|
||||
import HaushaltsplanDetail from './pages/HaushaltsplanDetail';
|
||||
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
|
||||
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
|
||||
import Issues from './pages/Issues';
|
||||
@@ -397,6 +400,30 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/buchhaltung/bankkonto/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BuchhaltungBankkontoDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/haushaltsplan"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Haushaltsplan />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/haushaltsplan/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HaushaltsplanDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/checklisten/ausfuehrung/:id"
|
||||
element={
|
||||
|
||||
@@ -152,6 +152,7 @@ const baseNavigationItems: NavigationItem[] = [
|
||||
{ text: 'Übersicht', path: '/buchhaltung?tab=0' },
|
||||
{ text: 'Transaktionen', path: '/buchhaltung?tab=1' },
|
||||
{ text: 'Konten', path: '/buchhaltung?tab=2' },
|
||||
{ text: 'Haushaltspläne', path: '/haushaltsplan' },
|
||||
],
|
||||
permission: 'buchhaltung:view',
|
||||
},
|
||||
|
||||
@@ -1356,6 +1356,11 @@ export default function BestellungDetail() {
|
||||
Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs.
|
||||
</Typography>
|
||||
)}
|
||||
{statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern.
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>Abbrechen</Button>
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
@@ -51,6 +50,9 @@ import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
HowToReg,
|
||||
Lock,
|
||||
Save,
|
||||
SwapHoriz,
|
||||
PictureAsPdf as PdfIcon,
|
||||
ThumbDown,
|
||||
ThumbUp,
|
||||
} from '@mui/icons-material';
|
||||
@@ -58,6 +60,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { bestellungApi } from '../services/bestellung';
|
||||
import { configApi, type PdfSettings } from '../services/config';
|
||||
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
@@ -66,20 +70,24 @@ import type {
|
||||
Bankkonto, BankkontoFormData,
|
||||
Konto, KontoFormData,
|
||||
KontoTreeNode,
|
||||
KontoTyp,
|
||||
Kategorie,
|
||||
Transaktion, TransaktionFormData, TransaktionFilters,
|
||||
TransaktionTyp,
|
||||
TransaktionStatus,
|
||||
AusgabenTyp,
|
||||
WiederkehrendBuchung, WiederkehrendFormData,
|
||||
WiederkehrendIntervall,
|
||||
BudgetTyp,
|
||||
ErstattungFormData,
|
||||
TransferFormData,
|
||||
} from '../types/buchhaltung.types';
|
||||
import {
|
||||
TRANSAKTION_STATUS_LABELS,
|
||||
TRANSAKTION_STATUS_COLORS,
|
||||
TRANSAKTION_TYP_LABELS,
|
||||
INTERVALL_LABELS,
|
||||
KONTO_ART_LABELS,
|
||||
} from '../types/buchhaltung.types';
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -98,6 +106,149 @@ function TabPanel({ children, value, index }: { children: React.ReactNode; value
|
||||
return value === index ? <Box role="tabpanel" sx={{ pt: 3 }}>{children}</Box> : null;
|
||||
}
|
||||
|
||||
// ─── PDF Export ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let _pdfSettingsCache: PdfSettings | null = null;
|
||||
let _pdfSettingsCacheTime = 0;
|
||||
|
||||
async function fetchPdfSettings(): Promise<PdfSettings> {
|
||||
if (_pdfSettingsCache && Date.now() - _pdfSettingsCacheTime < 30_000) {
|
||||
return _pdfSettingsCache;
|
||||
}
|
||||
try {
|
||||
_pdfSettingsCache = await configApi.getPdfSettings();
|
||||
_pdfSettingsCacheTime = Date.now();
|
||||
return _pdfSettingsCache;
|
||||
} catch {
|
||||
return { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBuchhaltungPdf(
|
||||
jahrBezeichnung: string,
|
||||
totalEinnahmen: number,
|
||||
totalAusgaben: number,
|
||||
saldo: number,
|
||||
treeData: KontoTreeNode[],
|
||||
transaktionen: Transaktion[],
|
||||
) {
|
||||
const { jsPDF } = await import('jspdf');
|
||||
const autoTable = (await import('jspdf-autotable')).default;
|
||||
const pdfSettings = await fetchPdfSettings();
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const pageWidth = 210;
|
||||
|
||||
// ── Page 1: Summary ──
|
||||
let y = await addPdfHeader(doc, pdfSettings, pageWidth);
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Buchhaltung \u2014 ${jahrBezeichnung}`, 10, y);
|
||||
y += 12;
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Einnahmen:`, 10, y);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(46, 125, 50);
|
||||
doc.text(fmtEur(totalEinnahmen), 50, y);
|
||||
y += 7;
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text(`Ausgaben:`, 10, y);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(211, 47, 47);
|
||||
doc.text(fmtEur(totalAusgaben), 50, y);
|
||||
y += 7;
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text(`Saldo:`, 10, y);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(saldo >= 0 ? 46 : 211, saldo >= 0 ? 125 : 47, saldo >= 0 ? 50 : 47);
|
||||
doc.text(fmtEur(saldo), 50, y);
|
||||
y += 14;
|
||||
|
||||
doc.setTextColor(0, 0, 0);
|
||||
|
||||
// ── Page 2+: Konten tree table ──
|
||||
doc.setFontSize(13);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Konten', 10, y);
|
||||
y += 6;
|
||||
|
||||
const kontenRows = treeData.map(k => [
|
||||
`${k.kontonummer} ${k.bezeichnung}`,
|
||||
fmtEur(Number(k.budget_gwg || 0)),
|
||||
fmtEur(Number(k.budget_anlagen || 0)),
|
||||
fmtEur(Number(k.budget_instandhaltung || 0)),
|
||||
fmtEur(Number(k.budget_gwg || 0) + Number(k.budget_anlagen || 0) + Number(k.budget_instandhaltung || 0)),
|
||||
fmtEur(Number(k.spent_gwg || 0) + Number(k.spent_anlagen || 0) + Number(k.spent_instandhaltung || 0)),
|
||||
fmtEur(Number(k.einnahmen_betrag || 0)),
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
head: [['Konto', 'Budget GWG', 'Budget Anl.', 'Budget Inst.', 'Budget Ges.', 'Ausgaben Ges.', 'Einnahmen']],
|
||||
body: kontenRows,
|
||||
startY: y,
|
||||
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold', fontSize: 7 },
|
||||
styles: { fontSize: 7, cellPadding: 1.5 },
|
||||
alternateRowStyles: { fillColor: [250, 235, 235] },
|
||||
margin: { left: 10, right: 10 },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 50 },
|
||||
1: { halign: 'right' },
|
||||
2: { halign: 'right' },
|
||||
3: { halign: 'right' },
|
||||
4: { halign: 'right' },
|
||||
5: { halign: 'right' },
|
||||
6: { halign: 'right' },
|
||||
},
|
||||
didDrawPage: addPdfFooter(doc, pdfSettings),
|
||||
});
|
||||
|
||||
// ── Next page(s): Transactions ──
|
||||
doc.addPage();
|
||||
let txY = await addPdfHeader(doc, pdfSettings, pageWidth);
|
||||
|
||||
doc.setFontSize(13);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Transaktionen', 10, txY);
|
||||
txY += 6;
|
||||
|
||||
const txRows = transaktionen.map(t => [
|
||||
fmtDate(t.datum),
|
||||
t.typ === 'transfer' ? 'Transfer' : t.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe',
|
||||
t.beschreibung || t.empfaenger_auftraggeber || '',
|
||||
t.beleg_nr || '',
|
||||
t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung || ''}` : '',
|
||||
`${t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}${fmtEur(t.betrag)}`,
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
head: [['Datum', 'Typ', 'Beschreibung', 'Beleg-Nr.', 'Konto', 'Betrag']],
|
||||
body: txRows,
|
||||
startY: txY,
|
||||
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold', fontSize: 7 },
|
||||
styles: { fontSize: 7, cellPadding: 1.5 },
|
||||
alternateRowStyles: { fillColor: [250, 235, 235] },
|
||||
margin: { left: 10, right: 10 },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 22 },
|
||||
1: { cellWidth: 18 },
|
||||
2: { cellWidth: 55 },
|
||||
3: { cellWidth: 22 },
|
||||
4: { cellWidth: 40 },
|
||||
5: { halign: 'right', cellWidth: 25 },
|
||||
},
|
||||
didDrawPage: addPdfFooter(doc, pdfSettings),
|
||||
});
|
||||
|
||||
doc.save(`buchhaltung_${jahrBezeichnung.replace(/\s+/g, '_')}.pdf`);
|
||||
}
|
||||
|
||||
// ─── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function HaushaltsjahrDialog({
|
||||
@@ -566,6 +717,8 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
onJahrChange: (id: number) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const { showError } = useNotification();
|
||||
const { data: treeData = [], isLoading } = useQuery({
|
||||
queryKey: ['kontenTree', selectedJahrId],
|
||||
queryFn: () => buchhaltungApi.getKontenTree(selectedJahrId!),
|
||||
@@ -576,6 +729,11 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
queryFn: () => buchhaltungApi.getKategorien(selectedJahrId!),
|
||||
enabled: selectedJahrId != null,
|
||||
});
|
||||
const { data: transaktionenForPdf = [] } = useQuery({
|
||||
queryKey: ['buchhaltung-transaktionen', { haushaltsjahr_id: selectedJahrId }],
|
||||
queryFn: () => buchhaltungApi.getTransaktionen({ haushaltsjahr_id: selectedJahrId! }),
|
||||
enabled: selectedJahrId != null,
|
||||
});
|
||||
|
||||
const tree = buildTree(treeData);
|
||||
|
||||
@@ -602,6 +760,23 @@ function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}{hj.abgeschlossen ? ' (abgeschlossen)' : ''}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{hasPermission('buchhaltung:export') && selectedJahrId && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<PdfIcon />}
|
||||
onClick={async () => {
|
||||
const hj = haushaltsjahre.find(h => h.id === selectedJahrId);
|
||||
if (!hj) return;
|
||||
try {
|
||||
await generateBuchhaltungPdf(hj.bezeichnung, totalEinnahmen, totalAusgaben, saldo, treeData, transaktionenForPdf);
|
||||
} catch {
|
||||
showError('PDF-Export fehlgeschlagen');
|
||||
}
|
||||
}}
|
||||
>
|
||||
PDF exportieren
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isLoading && <CircularProgress />}
|
||||
@@ -860,6 +1035,82 @@ function ErstattungDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function TransferDialog({
|
||||
open,
|
||||
onClose,
|
||||
haushaltsjahre,
|
||||
selectedJahrId,
|
||||
bankkonten,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
haushaltsjahre: Haushaltsjahr[];
|
||||
selectedJahrId: number | null;
|
||||
bankkonten: Bankkonto[];
|
||||
onSave: (data: TransferFormData) => void;
|
||||
}) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [form, setForm] = useState<TransferFormData>({
|
||||
haushaltsjahr_id: selectedJahrId || 0,
|
||||
bankkonto_id: 0,
|
||||
transfer_ziel_bankkonto_id: 0,
|
||||
betrag: 0,
|
||||
datum: today,
|
||||
beschreibung: '',
|
||||
beleg_nr: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setForm(f => ({ ...f, haushaltsjahr_id: selectedJahrId || 0, bankkonto_id: 0, transfer_ziel_bankkonto_id: 0, betrag: 0, datum: today, beschreibung: '', beleg_nr: '' }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const zielOptions = bankkonten.filter(bk => bk.id !== form.bankkonto_id);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Transfer zwischen Bankkonten</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Haushaltsjahr</InputLabel>
|
||||
<Select value={form.haushaltsjahr_id || ''} label="Haushaltsjahr" onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: Number(e.target.value) }))}>
|
||||
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Quell-Bankkonto</InputLabel>
|
||||
<Select value={form.bankkonto_id || ''} label="Quell-Bankkonto" onChange={e => setForm(f => ({ ...f, bankkonto_id: Number(e.target.value), transfer_ziel_bankkonto_id: f.transfer_ziel_bankkonto_id === Number(e.target.value) ? 0 : f.transfer_ziel_bankkonto_id }))}>
|
||||
{bankkonten.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Ziel-Bankkonto</InputLabel>
|
||||
<Select value={form.transfer_ziel_bankkonto_id || ''} label="Ziel-Bankkonto" onChange={e => setForm(f => ({ ...f, transfer_ziel_bankkonto_id: Number(e.target.value) }))}>
|
||||
{zielOptions.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Betrag (EUR)" type="number" value={form.betrag} onChange={e => setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required />
|
||||
<TextField label="Datum" type="date" value={form.datum} onChange={e => setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required />
|
||||
<TextField label="Beschreibung" value={form.beschreibung} onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))} />
|
||||
<TextField label="Beleg-Nr." value={form.beleg_nr} onChange={e => setForm(f => ({ ...f, beleg_nr: e.target.value }))} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!form.haushaltsjahr_id || !form.bankkonto_id || !form.transfer_ziel_bankkonto_id || !form.betrag || !form.datum}
|
||||
onClick={() => onSave(form)}
|
||||
>
|
||||
Transfer erstellen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab 1: Transaktionen ─────────────────────────────────────────────────────
|
||||
|
||||
function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
@@ -878,6 +1129,9 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
// ── Erstattung state ──
|
||||
const [erstattungOpen, setErstattungOpen] = useState(false);
|
||||
|
||||
// ── Transfer state ──
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
|
||||
// ── Wiederkehrend state ──
|
||||
const [wiederkehrendDialog, setWiederkehrendDialog] = useState<{ open: boolean; existing?: WiederkehrendBuchung }>({ open: false });
|
||||
|
||||
@@ -962,6 +1216,13 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
onError: () => showError('Erstattung konnte nicht erstellt werden'),
|
||||
});
|
||||
|
||||
// ── Transfer mutation ──
|
||||
const createTransferMut = useMutation({
|
||||
mutationFn: buchhaltungApi.createTransfer,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); setTransferOpen(false); showSuccess('Transfer erstellt'); },
|
||||
onError: () => showError('Transfer konnte nicht erstellt werden'),
|
||||
});
|
||||
|
||||
const handleExportCsv = async () => {
|
||||
if (!filters.haushaltsjahr_id) { showError('Bitte ein Haushaltsjahr auswählen'); return; }
|
||||
try {
|
||||
@@ -1034,10 +1295,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
<FormControl size="small" sx={{ minWidth: 130 }}>
|
||||
<InputLabel>Typ</InputLabel>
|
||||
<Select size="small" value={filters.typ ?? ''} label="Typ"
|
||||
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
|
||||
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as TransaktionTyp) || undefined }))}>
|
||||
<MenuItem value=""><em>Alle</em></MenuItem>
|
||||
<MenuItem value="einnahme">Einnahme</MenuItem>
|
||||
<MenuItem value="ausgabe">Ausgabe</MenuItem>
|
||||
<MenuItem value="transfer">Transfer</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField size="small" label="Suche" value={filters.search ?? ''}
|
||||
@@ -1067,6 +1329,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
Erstattung erfassen
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('buchhaltung:create') && (
|
||||
<Button size="small" variant="outlined" startIcon={<SwapHoriz />} onClick={() => setTransferOpen(true)}>
|
||||
Transfer
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
@@ -1106,7 +1373,11 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
|
||||
<TableCell>{fmtDate(t.datum)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={TRANSAKTION_TYP_LABELS[t.typ]} size="small" color={t.typ === 'einnahme' ? 'success' : 'error'} />
|
||||
<Chip
|
||||
label={t.typ === 'transfer' ? `Transfer${t.transfer_ziel_bezeichnung ? ' \u2192 ' + t.transfer_ziel_bezeichnung : ''}` : TRANSAKTION_TYP_LABELS[t.typ]}
|
||||
size="small"
|
||||
color={t.typ === 'einnahme' ? 'success' : t.typ === 'transfer' ? 'info' : 'error'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
@@ -1117,8 +1388,8 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung}` : '–'}</TableCell>
|
||||
<TableCell align="right" sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main', fontWeight: 600 }}>
|
||||
{t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)}
|
||||
<TableCell align="right" sx={{ color: t.typ === 'einnahme' ? 'success.main' : t.typ === 'transfer' ? 'info.main' : 'error.main', fontWeight: 600 }}>
|
||||
{t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={TRANSAKTION_STATUS_LABELS[t.status]} size="small" color={TRANSAKTION_STATUS_COLORS[t.status]} />
|
||||
@@ -1198,6 +1469,15 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
transaktionen={transaktionen}
|
||||
onSave={data => createErstattungMut.mutate(data)}
|
||||
/>
|
||||
|
||||
<TransferDialog
|
||||
open={transferOpen}
|
||||
onClose={() => setTransferOpen(false)}
|
||||
haushaltsjahre={haushaltsjahre}
|
||||
selectedJahrId={selectedJahrId}
|
||||
bankkonten={bankkonten}
|
||||
onSave={data => createTransferMut.mutate(data)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1423,6 +1703,45 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
const [newKategorie, setNewKategorie] = useState('');
|
||||
const [addingKategorie, setAddingKategorie] = useState(false);
|
||||
|
||||
// ── Einstellungen state ────────────────────────────────────────────────────
|
||||
const [kontoTypDialog, setKontoTypDialog] = useState<{ open: boolean; existing?: KontoTyp }>({ open: false });
|
||||
const [kontoTypForm, setKontoTypForm] = useState<{ bezeichnung: string; art: string; sort_order: number }>({ bezeichnung: '', art: 'ausgabe', sort_order: 0 });
|
||||
const [alertThreshold, setAlertThreshold] = useState<string>('80');
|
||||
|
||||
const { data: kontoTypen = [] } = useQuery({ queryKey: ['konto-typen'], queryFn: buchhaltungApi.getKontoTypen });
|
||||
const { data: einstellungen } = useQuery({
|
||||
queryKey: ['buchhaltung-einstellungen'],
|
||||
queryFn: buchhaltungApi.getEinstellungen,
|
||||
enabled: hasPermission('buchhaltung:manage_settings'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (einstellungen?.default_alert_threshold) {
|
||||
setAlertThreshold(String(einstellungen.default_alert_threshold));
|
||||
}
|
||||
}, [einstellungen]);
|
||||
|
||||
const createKontoTypMut = useMutation({
|
||||
mutationFn: buchhaltungApi.createKontoTyp,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['konto-typen'] }); setKontoTypDialog({ open: false }); showSuccess('Konto-Typ erstellt'); },
|
||||
onError: () => showError('Konto-Typ konnte nicht erstellt werden'),
|
||||
});
|
||||
const updateKontoTypMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<{ bezeichnung: string; art: string; sort_order: number }> }) => buchhaltungApi.updateKontoTyp(id, data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['konto-typen'] }); setKontoTypDialog({ open: false }); showSuccess('Konto-Typ aktualisiert'); },
|
||||
onError: () => showError('Konto-Typ konnte nicht aktualisiert werden'),
|
||||
});
|
||||
const deleteKontoTypMut = useMutation({
|
||||
mutationFn: buchhaltungApi.deleteKontoTyp,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['konto-typen'] }); showSuccess('Konto-Typ gelöscht'); },
|
||||
onError: (err: any) => showError(err?.response?.data?.message || 'Konto-Typ konnte nicht gelöscht werden'),
|
||||
});
|
||||
const saveEinstellungenMut = useMutation({
|
||||
mutationFn: buchhaltungApi.setEinstellungen,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-einstellungen'] }); showSuccess('Einstellungen gespeichert'); },
|
||||
onError: () => showError('Einstellungen konnten nicht gespeichert werden'),
|
||||
});
|
||||
|
||||
const createKategorieMut = useMutation({
|
||||
mutationFn: buchhaltungApi.createKategorie,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-kategorien'] }); setNewKategorie(''); setAddingKategorie(false); showSuccess('Kategorie erstellt'); },
|
||||
@@ -1491,6 +1810,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
<Tab label="Konten" />
|
||||
<Tab label="Bankkonten" />
|
||||
<Tab label="Haushaltsjahre" />
|
||||
{hasPermission('buchhaltung:manage_settings') && <Tab label="Einstellungen" />}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
@@ -1620,7 +1940,7 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
<TableBody>
|
||||
{bankkonten.length === 0 && <TableRow><TableCell colSpan={5} align="center"><Typography color="text.secondary">Keine Bankkonten</Typography></TableCell></TableRow>}
|
||||
{bankkonten.map((bk: Bankkonto) => (
|
||||
<TableRow key={bk.id} hover>
|
||||
<TableRow key={bk.id} hover onClick={() => navigate(`/buchhaltung/bankkonto/${bk.id}`)} sx={{ cursor: 'pointer' }}>
|
||||
<TableCell>{bk.bezeichnung}</TableCell>
|
||||
<TableCell>{bk.iban || '–'}</TableCell>
|
||||
<TableCell>{bk.institut || '–'}</TableCell>
|
||||
@@ -1701,6 +2021,97 @@ function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Sub-Tab 3: Einstellungen */}
|
||||
{subTab === 3 && hasPermission('buchhaltung:manage_settings') && (
|
||||
<Box>
|
||||
{/* Konto-Typen CRUD */}
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>Konto-Typen</Typography>
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setKontoTypForm({ bezeichnung: '', art: 'ausgabe', sort_order: 0 }); setKontoTypDialog({ open: true }); }}>Konto-Typ anlegen</Button>
|
||||
</Box>
|
||||
<TableContainer component={Paper} sx={{ mb: 4 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Art</TableCell>
|
||||
<TableCell align="right">Sortierung</TableCell>
|
||||
<TableCell>Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{kontoTypen.length === 0 && <TableRow><TableCell colSpan={4} align="center"><Typography color="text.secondary">Keine Konto-Typen</Typography></TableCell></TableRow>}
|
||||
{kontoTypen.map((kt: KontoTyp) => (
|
||||
<TableRow key={kt.id} hover>
|
||||
<TableCell>{kt.bezeichnung}</TableCell>
|
||||
<TableCell>{KONTO_ART_LABELS[kt.art] || kt.art}</TableCell>
|
||||
<TableCell align="right">{kt.sort_order}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => { setKontoTypForm({ bezeichnung: kt.bezeichnung, art: kt.art, sort_order: kt.sort_order }); setKontoTypDialog({ open: true, existing: kt }); }}><Edit fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => deleteKontoTypMut.mutate(kt.id)}><Delete fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Alert-Schwellwert */}
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>Budget-Warnung</Typography>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
label="Standard-Schwellwert"
|
||||
type="number"
|
||||
value={alertThreshold}
|
||||
onChange={e => setAlertThreshold(e.target.value)}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">%</InputAdornment> }}
|
||||
helperText="Budget-Warnung wird ausgelöst, wenn die Auslastung diesen Wert erreicht"
|
||||
sx={{ width: 280 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
onClick={() => saveEinstellungenMut.mutate({ default_alert_threshold: alertThreshold })}
|
||||
disabled={saveEinstellungenMut.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Konto-Typ Dialog */}
|
||||
<Dialog open={kontoTypDialog.open} onClose={() => setKontoTypDialog({ open: false })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{kontoTypDialog.existing ? 'Konto-Typ bearbeiten' : 'Neuer Konto-Typ'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Bezeichnung" value={kontoTypForm.bezeichnung} onChange={e => setKontoTypForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Art</InputLabel>
|
||||
<Select value={kontoTypForm.art} label="Art" onChange={e => setKontoTypForm(f => ({ ...f, art: e.target.value }))}>
|
||||
<MenuItem value="einnahme">Einnahmen</MenuItem>
|
||||
<MenuItem value="ausgabe">Ausgaben</MenuItem>
|
||||
<MenuItem value="vermoegen">Vermögen</MenuItem>
|
||||
<MenuItem value="verbindlichkeit">Verbindlichkeiten</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Sortierung" type="number" value={kontoTypForm.sort_order} onChange={e => setKontoTypForm(f => ({ ...f, sort_order: parseInt(e.target.value, 10) || 0 }))} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setKontoTypDialog({ open: false })}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={() => {
|
||||
if (kontoTypDialog.existing) {
|
||||
updateKontoTypMut.mutate({ id: kontoTypDialog.existing.id, data: kontoTypForm });
|
||||
} else {
|
||||
createKontoTypMut.mutate(kontoTypForm);
|
||||
}
|
||||
}}>Speichern</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
190
frontend/src/pages/BuchhaltungBankkontoDetail.tsx
Normal file
190
frontend/src/pages/BuchhaltungBankkontoDetail.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { TRANSAKTION_TYP_LABELS } from '../types/buchhaltung.types';
|
||||
import type { BankkontoStatementRow } from '../types/buchhaltung.types';
|
||||
|
||||
function fmtEur(val: number) {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
||||
}
|
||||
|
||||
function fmtDate(val: string) {
|
||||
return new Date(val).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
export default function BuchhaltungBankkontoDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const bankkontoId = Number(id);
|
||||
|
||||
const [von, setVon] = useState('');
|
||||
const [bis, setBis] = useState('');
|
||||
const [appliedVon, setAppliedVon] = useState('');
|
||||
const [appliedBis, setAppliedBis] = useState('');
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['bankkonto-statement', bankkontoId, appliedVon, appliedBis],
|
||||
queryFn: () => buchhaltungApi.getBankkontoStatement(bankkontoId, {
|
||||
von: appliedVon || undefined,
|
||||
bis: appliedBis || undefined,
|
||||
}),
|
||||
enabled: !!bankkontoId,
|
||||
});
|
||||
|
||||
const handleApply = () => {
|
||||
setAppliedVon(von);
|
||||
setAppliedBis(bis);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Tooltip title="Zurueck">
|
||||
<IconButton onClick={() => navigate('/buchhaltung?tab=2')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
{data?.bankkonto?.bezeichnung ?? 'Bankkonto'}
|
||||
{data?.bankkonto?.iban && (
|
||||
<Typography component="span" variant="body1" color="text.secondary" sx={{ ml: 2 }}>
|
||||
{data.bankkonto.iban}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Date range filter */}
|
||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
size="small"
|
||||
label="Von"
|
||||
type="date"
|
||||
value={von}
|
||||
onChange={e => setVon(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Bis"
|
||||
type="date"
|
||||
value={bis}
|
||||
onChange={e => setBis(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="contained" size="small" onClick={handleApply}>
|
||||
Anwenden
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{isLoading && <CircularProgress />}
|
||||
{isError && <Typography color="error">Fehler beim Laden der Kontodaten.</Typography>}
|
||||
|
||||
{data && !isLoading && (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mb: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" variant="body2">Einnahmen</Typography>
|
||||
<Typography variant="h5" color="success.main">{fmtEur(data.einnahmen)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" variant="body2">Ausgaben</Typography>
|
||||
<Typography variant="h5" color="error.main">{fmtEur(data.ausgaben)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" variant="body2">Saldo</Typography>
|
||||
<Typography variant="h5" color={data.saldo >= 0 ? 'success.main' : 'error.main'}>{fmtEur(data.saldo)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Statement table */}
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Datum</TableCell>
|
||||
<TableCell>Typ</TableCell>
|
||||
<TableCell>Beschreibung</TableCell>
|
||||
<TableCell>Beleg-Nr.</TableCell>
|
||||
<TableCell align="right">Betrag</TableCell>
|
||||
<TableCell align="right">Laufender Saldo</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
<Typography color="text.secondary">Keine Transaktionen im Zeitraum</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{data.rows.map((row: BankkontoStatementRow) => {
|
||||
const isEinnahme = row.typ === 'einnahme';
|
||||
const isTransfer = row.typ === 'transfer';
|
||||
return (
|
||||
<TableRow key={row.id} hover>
|
||||
<TableCell>{fmtDate(row.datum)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={isTransfer ? `Transfer${row.transfer_ziel_bezeichnung ? ' \u2192 ' + row.transfer_ziel_bezeichnung : ''}` : TRANSAKTION_TYP_LABELS[row.typ]}
|
||||
size="small"
|
||||
color={isEinnahme ? 'success' : isTransfer ? 'info' : 'error'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{row.beschreibung || '\u2013'}</TableCell>
|
||||
<TableCell>{row.beleg_nr || '\u2013'}</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
color: isEinnahme ? 'success.main' : isTransfer ? 'info.main' : 'error.main',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{row.typ === 'ausgabe' ? '-' : row.typ === 'einnahme' ? '+' : ''}{fmtEur(row.betrag)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>
|
||||
{fmtEur(row.laufender_saldo)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
163
frontend/src/pages/Haushaltsplan.tsx
Normal file
163
frontend/src/pages/Haushaltsplan.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Delete, Visibility } from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import type { Planung, PlanungStatus, Haushaltsjahr } from '../types/buchhaltung.types';
|
||||
|
||||
const STATUS_LABELS: Record<PlanungStatus, string> = {
|
||||
entwurf: 'Entwurf',
|
||||
aktiv: 'Aktiv',
|
||||
abgeschlossen: 'Abgeschlossen',
|
||||
};
|
||||
const STATUS_COLORS: Record<PlanungStatus, 'default' | 'success' | 'info'> = {
|
||||
entwurf: 'default',
|
||||
aktiv: 'success',
|
||||
abgeschlossen: 'info',
|
||||
};
|
||||
|
||||
function fmtDate(val: string) {
|
||||
return new Date(val).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
function fmtEur(val: number) {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
||||
}
|
||||
|
||||
export default function Haushaltsplan() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const canManage = hasPermission('buchhaltung:manage_accounts');
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState<{ bezeichnung: string; haushaltsjahr_id: string }>({ bezeichnung: '', haushaltsjahr_id: '' });
|
||||
|
||||
const { data: planungen = [], isLoading } = useQuery({
|
||||
queryKey: ['planungen'],
|
||||
queryFn: buchhaltungApi.listPlanungen,
|
||||
});
|
||||
const { data: haushaltsjahre = [] } = useQuery({
|
||||
queryKey: ['haushaltsjahre'],
|
||||
queryFn: buchhaltungApi.getHaushaltsjahre,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: buchhaltungApi.createPlanung,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planungen'] }); setDialogOpen(false); showSuccess('Haushaltsplan erstellt'); },
|
||||
onError: () => showError('Haushaltsplan konnte nicht erstellt werden'),
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: buchhaltungApi.deletePlanung,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planungen'] }); showSuccess('Haushaltsplan gelöscht'); },
|
||||
onError: () => showError('Haushaltsplan konnte nicht gelöscht werden'),
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>Haushaltspläne</Typography>
|
||||
{canManage && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setForm({ bezeichnung: '', haushaltsjahr_id: '' }); setDialogOpen(true); }}>
|
||||
Neuer Haushaltsplan
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Haushaltsjahr</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Positionen</TableCell>
|
||||
<TableCell align="right">Gesamt (GWG + Anl. + Inst.)</TableCell>
|
||||
<TableCell>Erstellt am</TableCell>
|
||||
<TableCell>Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{!isLoading && planungen.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} align="center"><Typography color="text.secondary">Keine Haushaltspläne vorhanden</Typography></TableCell></TableRow>
|
||||
)}
|
||||
{planungen.map((p: Planung) => {
|
||||
const total = Number(p.total_gwg) + Number(p.total_anlagen) + Number(p.total_instandhaltung);
|
||||
return (
|
||||
<TableRow key={p.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/haushaltsplan/${p.id}`)}>
|
||||
<TableCell>{p.bezeichnung}</TableCell>
|
||||
<TableCell>{p.haushaltsjahr_bezeichnung || '–'}</TableCell>
|
||||
<TableCell><Chip label={STATUS_LABELS[p.status] || p.status} size="small" color={STATUS_COLORS[p.status] || 'default'} /></TableCell>
|
||||
<TableCell align="right">{p.positionen_count}</TableCell>
|
||||
<TableCell align="right">{fmtEur(total)}</TableCell>
|
||||
<TableCell>{fmtDate(p.erstellt_am)}</TableCell>
|
||||
<TableCell onClick={e => e.stopPropagation()}>
|
||||
<IconButton size="small" onClick={() => navigate(`/haushaltsplan/${p.id}`)}><Visibility fontSize="small" /></IconButton>
|
||||
{canManage && <IconButton size="small" color="error" onClick={() => deleteMut.mutate(p.id)}><Delete fontSize="small" /></IconButton>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Neuer Haushaltsplan</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Haushaltsjahr (optional)</InputLabel>
|
||||
<Select value={form.haushaltsjahr_id} label="Haushaltsjahr (optional)" onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: e.target.value as string }))}>
|
||||
<MenuItem value="">Kein Haushaltsjahr</MenuItem>
|
||||
{haushaltsjahre.map((hj: Haushaltsjahr) => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!form.bezeichnung.trim()}
|
||||
onClick={() => createMut.mutate({
|
||||
bezeichnung: form.bezeichnung.trim(),
|
||||
haushaltsjahr_id: form.haushaltsjahr_id ? Number(form.haushaltsjahr_id) : undefined,
|
||||
})}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
271
frontend/src/pages/HaushaltsplanDetail.tsx
Normal file
271
frontend/src/pages/HaushaltsplanDetail.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useState } from 'react';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, ArrowBack, Delete, Edit, PlaylistAdd } from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { buchhaltungApi } from '../services/buchhaltung';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import type { Konto, Planposition, PlanungStatus } from '../types/buchhaltung.types';
|
||||
|
||||
const STATUS_LABELS: Record<PlanungStatus, string> = {
|
||||
entwurf: 'Entwurf',
|
||||
aktiv: 'Aktiv',
|
||||
abgeschlossen: 'Abgeschlossen',
|
||||
};
|
||||
const STATUS_COLORS: Record<PlanungStatus, 'default' | 'success' | 'info'> = {
|
||||
entwurf: 'default',
|
||||
aktiv: 'success',
|
||||
abgeschlossen: 'info',
|
||||
};
|
||||
|
||||
function fmtEur(val: number) {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
||||
}
|
||||
|
||||
export default function HaushaltsplanDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const planungId = Number(id);
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
const canManage = hasPermission('buchhaltung:manage_accounts');
|
||||
|
||||
const [posDialog, setPosDialog] = useState<{ open: boolean; existing?: Planposition }>({ open: false });
|
||||
const [posForm, setPosForm] = useState<{ konto_id: string; bezeichnung: string; budget_gwg: string; budget_anlagen: string; budget_instandhaltung: string; notizen: string }>({
|
||||
konto_id: '', bezeichnung: '', budget_gwg: '0', budget_anlagen: '0', budget_instandhaltung: '0', notizen: '',
|
||||
});
|
||||
|
||||
const { data: planung, isLoading, isError } = useQuery({
|
||||
queryKey: ['planung', planungId],
|
||||
queryFn: () => buchhaltungApi.getPlanung(planungId),
|
||||
enabled: !isNaN(planungId),
|
||||
});
|
||||
|
||||
// Load konten for the linked haushaltsjahr (for Konto dropdown in position dialog)
|
||||
const { data: konten = [] } = useQuery({
|
||||
queryKey: ['buchhaltung-konten', planung?.haushaltsjahr_id],
|
||||
queryFn: () => buchhaltungApi.getKonten(planung!.haushaltsjahr_id!),
|
||||
enabled: !!planung?.haushaltsjahr_id,
|
||||
});
|
||||
|
||||
const createPosMut = useMutation({
|
||||
mutationFn: (data: Parameters<typeof buchhaltungApi.createPlanposition>[1]) => buchhaltungApi.createPlanposition(planungId, data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); setPosDialog({ open: false }); showSuccess('Position hinzugefügt'); },
|
||||
onError: () => showError('Position konnte nicht erstellt werden'),
|
||||
});
|
||||
const updatePosMut = useMutation({
|
||||
mutationFn: ({ posId, data }: { posId: number; data: Parameters<typeof buchhaltungApi.updatePlanposition>[1] }) => buchhaltungApi.updatePlanposition(posId, data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); setPosDialog({ open: false }); showSuccess('Position aktualisiert'); },
|
||||
onError: () => showError('Position konnte nicht aktualisiert werden'),
|
||||
});
|
||||
const deletePosMut = useMutation({
|
||||
mutationFn: buchhaltungApi.deletePlanposition,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['planung', planungId] }); showSuccess('Position gelöscht'); },
|
||||
onError: () => showError('Position konnte nicht gelöscht werden'),
|
||||
});
|
||||
const createHjMut = useMutation({
|
||||
mutationFn: () => buchhaltungApi.createHaushaltsjahrFromPlan(planungId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['planung', planungId] });
|
||||
qc.invalidateQueries({ queryKey: ['haushaltsjahre'] });
|
||||
qc.invalidateQueries({ queryKey: ['planungen'] });
|
||||
showSuccess('Haushaltsjahr aus Plan erstellt');
|
||||
},
|
||||
onError: (err: any) => showError(err?.response?.data?.message || 'Haushaltsjahr konnte nicht erstellt werden'),
|
||||
});
|
||||
|
||||
const openAddDialog = () => {
|
||||
setPosForm({ konto_id: '', bezeichnung: '', budget_gwg: '0', budget_anlagen: '0', budget_instandhaltung: '0', notizen: '' });
|
||||
setPosDialog({ open: true });
|
||||
};
|
||||
const openEditDialog = (pos: Planposition) => {
|
||||
setPosForm({
|
||||
konto_id: pos.konto_id ? String(pos.konto_id) : '',
|
||||
bezeichnung: pos.bezeichnung,
|
||||
budget_gwg: String(pos.budget_gwg),
|
||||
budget_anlagen: String(pos.budget_anlagen),
|
||||
budget_instandhaltung: String(pos.budget_instandhaltung),
|
||||
notizen: pos.notizen || '',
|
||||
});
|
||||
setPosDialog({ open: true, existing: pos });
|
||||
};
|
||||
const handlePosSave = () => {
|
||||
const data = {
|
||||
konto_id: posForm.konto_id ? Number(posForm.konto_id) : null,
|
||||
bezeichnung: posForm.bezeichnung.trim(),
|
||||
budget_gwg: parseFloat(posForm.budget_gwg) || 0,
|
||||
budget_anlagen: parseFloat(posForm.budget_anlagen) || 0,
|
||||
budget_instandhaltung: parseFloat(posForm.budget_instandhaltung) || 0,
|
||||
notizen: posForm.notizen.trim() || undefined,
|
||||
};
|
||||
if (posDialog.existing) {
|
||||
updatePosMut.mutate({ posId: posDialog.existing.id, data });
|
||||
} else {
|
||||
createPosMut.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <DashboardLayout><Typography>Laden...</Typography></DashboardLayout>;
|
||||
if (isError || !planung) return <DashboardLayout><Alert severity="error">Haushaltsplan nicht gefunden</Alert></DashboardLayout>;
|
||||
|
||||
const positionen = planung.positionen || [];
|
||||
const totalGwg = positionen.reduce((s, p) => s + Number(p.budget_gwg), 0);
|
||||
const totalAnlagen = positionen.reduce((s, p) => s + Number(p.budget_anlagen), 0);
|
||||
const totalInstandhaltung = positionen.reduce((s, p) => s + Number(p.budget_instandhaltung), 0);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/haushaltsplan')}><ArrowBack /></IconButton>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>{planung.bezeichnung}</Typography>
|
||||
<Chip label={STATUS_LABELS[planung.status] || planung.status} color={STATUS_COLORS[planung.status] || 'default'} />
|
||||
</Box>
|
||||
|
||||
{planung.haushaltsjahr_bezeichnung && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Haushaltsjahr: {planung.haushaltsjahr_bezeichnung}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!planung.haushaltsjahr_id && canManage && (
|
||||
<Alert severity="info" sx={{ mb: 2 }} action={
|
||||
<Button color="inherit" size="small" startIcon={<PlaylistAdd />} onClick={() => createHjMut.mutate()} disabled={createHjMut.isPending}>
|
||||
Haushaltsjahr erstellen
|
||||
</Button>
|
||||
}>
|
||||
Diesem Plan ist noch kein Haushaltsjahr zugeordnet. Sie können aus diesem Plan ein Haushaltsjahr mit Konten erstellen.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Positionen ({positionen.length})</Typography>
|
||||
{canManage && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAddDialog}>Position hinzufügen</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Konto</TableCell>
|
||||
<TableCell align="right">GWG</TableCell>
|
||||
<TableCell align="right">Anlagen</TableCell>
|
||||
<TableCell align="right">Instandh.</TableCell>
|
||||
<TableCell align="right">Gesamt</TableCell>
|
||||
{canManage && <TableCell>Aktionen</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{positionen.length === 0 && (
|
||||
<TableRow><TableCell colSpan={canManage ? 7 : 6} align="center"><Typography color="text.secondary">Keine Positionen</Typography></TableCell></TableRow>
|
||||
)}
|
||||
{positionen.map((pos: Planposition) => {
|
||||
const rowTotal = Number(pos.budget_gwg) + Number(pos.budget_anlagen) + Number(pos.budget_instandhaltung);
|
||||
return (
|
||||
<TableRow key={pos.id} hover>
|
||||
<TableCell>{pos.bezeichnung}</TableCell>
|
||||
<TableCell>{pos.konto_bezeichnung ? `${pos.konto_kontonummer} – ${pos.konto_bezeichnung}` : '–'}</TableCell>
|
||||
<TableCell align="right">{fmtEur(Number(pos.budget_gwg))}</TableCell>
|
||||
<TableCell align="right">{fmtEur(Number(pos.budget_anlagen))}</TableCell>
|
||||
<TableCell align="right">{fmtEur(Number(pos.budget_instandhaltung))}</TableCell>
|
||||
<TableCell align="right">{fmtEur(rowTotal)}</TableCell>
|
||||
{canManage && (
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => openEditDialog(pos)}><Edit fontSize="small" /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => deletePosMut.mutate(pos.id)}><Delete fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{positionen.length > 0 && (
|
||||
<TableRow sx={{ '& td': { fontWeight: 'bold' } }}>
|
||||
<TableCell colSpan={2}>Summe</TableCell>
|
||||
<TableCell align="right">{fmtEur(totalGwg)}</TableCell>
|
||||
<TableCell align="right">{fmtEur(totalAnlagen)}</TableCell>
|
||||
<TableCell align="right">{fmtEur(totalInstandhaltung)}</TableCell>
|
||||
<TableCell align="right">{fmtEur(totalGwg + totalAnlagen + totalInstandhaltung)}</TableCell>
|
||||
{canManage && <TableCell />}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Position Add/Edit Dialog */}
|
||||
<Dialog open={posDialog.open} onClose={() => setPosDialog({ open: false })} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{posDialog.existing ? 'Position bearbeiten' : 'Neue Position'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Bezeichnung" value={posForm.bezeichnung} onChange={e => setPosForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
|
||||
{konten.length > 0 && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Konto (optional)</InputLabel>
|
||||
<Select value={posForm.konto_id} label="Konto (optional)" onChange={e => setPosForm(f => ({ ...f, konto_id: e.target.value as string }))}>
|
||||
<MenuItem value="">Kein Konto</MenuItem>
|
||||
{konten.map((k: Konto) => <MenuItem key={k.id} value={k.id}>{k.kontonummer} – {k.bezeichnung}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<TextField
|
||||
label="Budget GWG"
|
||||
type="number"
|
||||
value={posForm.budget_gwg}
|
||||
onChange={e => setPosForm(f => ({ ...f, budget_gwg: e.target.value }))}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Budget Anlagen"
|
||||
type="number"
|
||||
value={posForm.budget_anlagen}
|
||||
onChange={e => setPosForm(f => ({ ...f, budget_anlagen: e.target.value }))}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
label="Budget Instandhaltung"
|
||||
type="number"
|
||||
value={posForm.budget_instandhaltung}
|
||||
onChange={e => setPosForm(f => ({ ...f, budget_instandhaltung: e.target.value }))}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">EUR</InputAdornment> }}
|
||||
/>
|
||||
<TextField label="Notizen" value={posForm.notizen} onChange={e => setPosForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPosDialog({ open: false })}>Abbrechen</Button>
|
||||
<Button variant="contained" disabled={!posForm.bezeichnung.trim()} onClick={handlePosSave}>Speichern</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,11 @@ import type {
|
||||
Freigabe,
|
||||
Kategorie,
|
||||
ErstattungFormData, ErstattungLinks,
|
||||
TransferFormData,
|
||||
BuchhaltungAudit,
|
||||
BuchhaltungEinstellungen,
|
||||
BankkontoStatement,
|
||||
Planung, PlanungDetail, Planposition,
|
||||
} from '../types/buchhaltung.types';
|
||||
|
||||
export const buchhaltungApi = {
|
||||
@@ -67,6 +71,20 @@ export const buchhaltungApi = {
|
||||
deleteBankkonto: async (id: number): Promise<void> => {
|
||||
await api.delete(`/api/buchhaltung/bankkonten/${id}`);
|
||||
},
|
||||
getBankkontoStatement: async (id: number, params?: { von?: string; bis?: string }): Promise<BankkontoStatement> => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.von) qs.set('von', params.von);
|
||||
if (params?.bis) qs.set('bis', params.bis);
|
||||
const r = await api.get(`/api/buchhaltung/bankkonten/${id}/transaktionen?${qs.toString()}`);
|
||||
const d = r.data;
|
||||
return {
|
||||
bankkonto: d.konto,
|
||||
einnahmen: d.summary.gesamteinnahmen,
|
||||
ausgaben: d.summary.gesamtausgaben,
|
||||
saldo: d.summary.saldo,
|
||||
rows: d.rows,
|
||||
};
|
||||
},
|
||||
|
||||
// ── Konten ───────────────────────────────────────────────────────────────────
|
||||
getKonten: async (haushaltsjahrId: number): Promise<Konto[]> => {
|
||||
@@ -220,9 +238,60 @@ export const buchhaltungApi = {
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
// ── Transfers ──────────────────────────────────────────────────────────────
|
||||
createTransfer: async (data: TransferFormData): Promise<{ debit: Transaktion; credit: Transaktion }> => {
|
||||
const r = await api.post('/api/buchhaltung/transfers', data);
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
// ── Audit ─────────────────────────────────────────────────────────────────
|
||||
getAudit: async (transaktionId: number): Promise<BuchhaltungAudit[]> => {
|
||||
const r = await api.get(`/api/buchhaltung/audit/${transaktionId}`);
|
||||
return r.data.data;
|
||||
},
|
||||
|
||||
// ── Einstellungen ─────────────────────────────────────────────────────────
|
||||
getEinstellungen: async (): Promise<BuchhaltungEinstellungen> => {
|
||||
const r = await api.get('/api/buchhaltung/einstellungen');
|
||||
return r.data.data;
|
||||
},
|
||||
setEinstellungen: async (data: Record<string, unknown>): Promise<void> => {
|
||||
await api.put('/api/buchhaltung/einstellungen', data);
|
||||
},
|
||||
|
||||
// ── Planung ───────────────────────────────────────────────────────────────
|
||||
listPlanungen: async (): Promise<Planung[]> => {
|
||||
const r = await api.get('/api/buchhaltung/planung');
|
||||
return r.data.data;
|
||||
},
|
||||
getPlanung: async (id: number): Promise<PlanungDetail> => {
|
||||
const r = await api.get(`/api/buchhaltung/planung/${id}`);
|
||||
return r.data.data;
|
||||
},
|
||||
createPlanung: async (data: { haushaltsjahr_id?: number; bezeichnung: string }): Promise<Planung> => {
|
||||
const r = await api.post('/api/buchhaltung/planung', data);
|
||||
return r.data.data;
|
||||
},
|
||||
updatePlanung: async (id: number, data: { bezeichnung?: string; status?: string }): Promise<Planung> => {
|
||||
const r = await api.put(`/api/buchhaltung/planung/${id}`, data);
|
||||
return r.data.data;
|
||||
},
|
||||
deletePlanung: async (id: number): Promise<void> => {
|
||||
await api.delete(`/api/buchhaltung/planung/${id}`);
|
||||
},
|
||||
createPlanposition: async (planungId: number, data: { konto_id?: number | null; bezeichnung: string; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; sort_order?: number }): Promise<Planposition> => {
|
||||
const r = await api.post(`/api/buchhaltung/planung/${planungId}/positionen`, data);
|
||||
return r.data.data;
|
||||
},
|
||||
updatePlanposition: async (id: number, data: Partial<{ konto_id: number | null; bezeichnung: string; budget_gwg: number; budget_anlagen: number; budget_instandhaltung: number; notizen: string; sort_order: number }>): Promise<Planposition> => {
|
||||
const r = await api.put(`/api/buchhaltung/planung/positionen/${id}`, data);
|
||||
return r.data.data;
|
||||
},
|
||||
deletePlanposition: async (id: number): Promise<void> => {
|
||||
await api.delete(`/api/buchhaltung/planung/positionen/${id}`);
|
||||
},
|
||||
createHaushaltsjahrFromPlan: async (planungId: number): Promise<Haushaltsjahr> => {
|
||||
const r = await api.post(`/api/buchhaltung/planung/${planungId}/create-haushaltsjahr`);
|
||||
return r.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Lookup types
|
||||
export type KontoArt = 'einnahme' | 'ausgabe' | 'vermoegen' | 'verbindlichkeit';
|
||||
export type TransaktionTyp = 'einnahme' | 'ausgabe';
|
||||
export type TransaktionTyp = 'einnahme' | 'ausgabe' | 'transfer';
|
||||
export type TransaktionStatus = 'entwurf' | 'gebucht' | 'freigegeben' | 'storniert';
|
||||
export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt';
|
||||
export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
|
||||
@@ -32,6 +32,7 @@ export const TRANSAKTION_STATUS_COLORS: Record<TransaktionStatus, 'default' | 'w
|
||||
export const TRANSAKTION_TYP_LABELS: Record<TransaktionTyp, string> = {
|
||||
einnahme: 'Einnahme',
|
||||
ausgabe: 'Ausgabe',
|
||||
transfer: 'Transfer',
|
||||
};
|
||||
|
||||
export const KONTO_ART_LABELS: Record<KontoArt, string> = {
|
||||
@@ -154,6 +155,8 @@ export interface Transaktion {
|
||||
konto_bezeichnung?: string;
|
||||
konto_kontonummer?: number;
|
||||
bankkonto_bezeichnung?: string;
|
||||
transfer_ziel_bankkonto_id?: number;
|
||||
transfer_ziel_bezeichnung?: string;
|
||||
belege?: Beleg[];
|
||||
}
|
||||
|
||||
@@ -306,3 +309,71 @@ export interface ErstattungLinks {
|
||||
erstattung_transaktion_id: number | null;
|
||||
quell_transaktion_ids: number[];
|
||||
}
|
||||
|
||||
export interface TransferFormData {
|
||||
haushaltsjahr_id: number;
|
||||
bankkonto_id: number;
|
||||
transfer_ziel_bankkonto_id: number;
|
||||
betrag: number;
|
||||
datum: string;
|
||||
beschreibung?: string;
|
||||
beleg_nr?: string;
|
||||
}
|
||||
|
||||
export interface BuchhaltungEinstellungen {
|
||||
default_alert_threshold?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Planung {
|
||||
id: number;
|
||||
bezeichnung: string;
|
||||
haushaltsjahr_id: number | null;
|
||||
haushaltsjahr_bezeichnung?: string;
|
||||
haushaltsjahr_jahr?: number;
|
||||
beschreibung?: string;
|
||||
status: PlanungStatus;
|
||||
positionen_count: number;
|
||||
total_gwg: number;
|
||||
total_anlagen: number;
|
||||
total_instandhaltung: number;
|
||||
erstellt_von: string | null;
|
||||
erstellt_am: string;
|
||||
}
|
||||
|
||||
export interface Planposition {
|
||||
id: number;
|
||||
planung_id: number;
|
||||
konto_id: number | null;
|
||||
konto_bezeichnung?: string;
|
||||
konto_kontonummer?: number;
|
||||
bezeichnung: string;
|
||||
budget_gwg: number;
|
||||
budget_anlagen: number;
|
||||
budget_instandhaltung: number;
|
||||
notizen: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface PlanungDetail extends Planung {
|
||||
positionen: Planposition[];
|
||||
}
|
||||
|
||||
export interface BankkontoStatementRow {
|
||||
id: number;
|
||||
datum: string;
|
||||
typ: TransaktionTyp;
|
||||
beschreibung: string | null;
|
||||
beleg_nr: string | null;
|
||||
betrag: number;
|
||||
laufender_saldo: number;
|
||||
transfer_ziel_bezeichnung?: string;
|
||||
}
|
||||
|
||||
export interface BankkontoStatement {
|
||||
bankkonto: Bankkonto;
|
||||
einnahmen: number;
|
||||
ausgaben: number;
|
||||
saldo: number;
|
||||
rows: BankkontoStatementRow[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user