From 18b1300de86e9d174a5397c42dc2cb0cb81dcec5 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Sat, 28 Mar 2026 19:48:32 +0100 Subject: [PATCH] feat: add Buchhaltung module with fiscal years, budget accounts, transactions, and approval workflow --- backend/src/app.ts | 2 + .../src/controllers/buchhaltung.controller.ts | 339 +++++++ .../migrations/075_buchhaltung_schema.sql | 231 +++++ .../076_buchhaltung_permissions.sql | 33 + backend/src/middleware/upload.ts | 40 +- backend/src/routes/buchhaltung.routes.ts | 53 ++ backend/src/services/buchhaltung.service.ts | 857 ++++++++++++++++++ backend/src/services/permission.service.ts | 7 + frontend/src/App.tsx | 9 + .../components/admin/PermissionMatrixTab.tsx | 5 + frontend/src/components/shared/Sidebar.tsx | 12 + frontend/src/pages/Buchhaltung.tsx | 846 +++++++++++++++++ frontend/src/services/buchhaltung.ts | 129 +++ frontend/src/types/buchhaltung.types.ts | 229 +++++ 14 files changed, 2791 insertions(+), 1 deletion(-) create mode 100644 backend/src/controllers/buchhaltung.controller.ts create mode 100644 backend/src/database/migrations/075_buchhaltung_schema.sql create mode 100644 backend/src/database/migrations/076_buchhaltung_permissions.sql create mode 100644 backend/src/routes/buchhaltung.routes.ts create mode 100644 backend/src/services/buchhaltung.service.ts create mode 100644 frontend/src/pages/Buchhaltung.tsx create mode 100644 frontend/src/services/buchhaltung.ts create mode 100644 frontend/src/types/buchhaltung.types.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 48404d0..a588407 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -107,6 +107,7 @@ import buchungskategorieRoutes from './routes/buchungskategorie.routes'; import checklistRoutes from './routes/checklist.routes'; import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes'; import ausruestungTypRoutes from './routes/ausruestungTyp.routes'; +import buchhaltungRoutes from './routes/buchhaltung.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -136,6 +137,7 @@ app.use('/api/buchungskategorien', buchungskategorieRoutes); app.use('/api/checklisten', checklistRoutes); app.use('/api/fahrzeug-typen', fahrzeugTypRoutes); app.use('/api/ausruestung-typen', ausruestungTypRoutes); +app.use('/api/buchhaltung', buchhaltungRoutes); // Static file serving for uploads (authenticated) const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads'); diff --git a/backend/src/controllers/buchhaltung.controller.ts b/backend/src/controllers/buchhaltung.controller.ts new file mode 100644 index 0000000..1965097 --- /dev/null +++ b/backend/src/controllers/buchhaltung.controller.ts @@ -0,0 +1,339 @@ +import { Request, Response } from 'express'; +import buchhaltungService from '../services/buchhaltung.service'; +import logger from '../utils/logger'; + +const param = (req: Request, key: string): string => req.params[key] as string; + +class BuchhaltungController { + + // ── Haushaltsjahre ────────────────────────────────────────────────────────── + + async listHaushaltsjahre(_req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.getAllHaushaltsjahre(); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.listHaushaltsjahre', { error }); + res.status(500).json({ success: false, message: 'Haushaltsjahre konnten nicht geladen werden' }); + } + } + + async createHaushaltsjahr(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createHaushaltsjahr(req.body, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createHaushaltsjahr', { error }); + res.status(500).json({ success: false, message: 'Haushaltsjahr konnte nicht erstellt werden' }); + } + } + + async updateHaushaltsjahr(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.updateHaushaltsjahr(id, req.body); + if (!data) { res.status(404).json({ success: false, message: 'Haushaltsjahr nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.updateHaushaltsjahr', { error }); + res.status(500).json({ success: false, message: String(error) }); + } + } + + async closeHaushaltsjahr(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.closeHaushaltsjahr(id); + if (!data) { res.status(404).json({ success: false, message: 'Haushaltsjahr nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.closeHaushaltsjahr', { error }); + res.status(400).json({ success: false, message: String(error) }); + } + } + + // ── Konto-Typen ───────────────────────────────────────────────────────────── + + async listKontoTypen(_req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.getAllKontoTypen(); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.listKontoTypen', { error }); + res.status(500).json({ success: false, message: 'Kontotypen konnten nicht geladen werden' }); + } + } + + // ── Bankkonten ─────────────────────────────────────────────────────────────── + + async listBankkonten(_req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.getAllBankkonten(); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.listBankkonten', { error }); + res.status(500).json({ success: false, message: 'Bankkonten konnten nicht geladen werden' }); + } + } + + async createBankkonto(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createBankkonto(req.body, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createBankkonto', { error }); + res.status(500).json({ success: false, message: 'Bankkonto konnte nicht erstellt werden' }); + } + } + + async updateBankkonto(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.updateBankkonto(id, req.body); + if (!data) { res.status(404).json({ success: false, message: 'Bankkonto nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.updateBankkonto', { error }); + res.status(500).json({ success: false, message: 'Bankkonto konnte nicht aktualisiert werden' }); + } + } + + async deleteBankkonto(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + await buchhaltungService.deactivateBankkonto(id); + res.json({ success: true }); + } catch (error) { + logger.error('BuchhaltungController.deleteBankkonto', { error }); + res.status(500).json({ success: false, message: 'Bankkonto konnte nicht deaktiviert werden' }); + } + } + + // ── Konten ─────────────────────────────────────────────────────────────────── + + async listKonten(req: Request, res: Response): Promise { + const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10); + if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; } + try { + const data = await buchhaltungService.getAllKonten(haushaltsjahrId); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.listKonten', { error }); + res.status(500).json({ success: false, message: 'Konten konnten nicht geladen werden' }); + } + } + + async createKonto(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createKonto(req.body, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createKonto', { error }); + res.status(500).json({ success: false, message: 'Konto konnte nicht erstellt werden' }); + } + } + + async updateKonto(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.updateKonto(id, req.body); + if (!data) { res.status(404).json({ success: false, message: 'Konto nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.updateKonto', { error }); + res.status(500).json({ success: false, message: 'Konto konnte nicht aktualisiert werden' }); + } + } + + async deleteKonto(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + await buchhaltungService.deleteKonto(id); + res.json({ success: true }); + } catch (error) { + logger.error('BuchhaltungController.deleteKonto', { error }); + res.status(500).json({ success: false, message: 'Konto konnte nicht gelöscht werden' }); + } + } + + async getKontoBudget(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.getBudgetUtilisation(id); + if (!data) { res.status(404).json({ success: false, message: 'Konto nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.getKontoBudget', { error }); + res.status(500).json({ success: false, message: 'Budgetauslastung konnte nicht geladen werden' }); + } + } + + // ── Stats ──────────────────────────────────────────────────────────────────── + + async getStats(req: Request, res: Response): Promise { + const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10); + if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; } + try { + const data = await buchhaltungService.getOverview(haushaltsjahrId); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.getStats', { error }); + res.status(500).json({ success: false, message: 'Statistik konnte nicht geladen werden' }); + } + } + + // ── Transaktionen ──────────────────────────────────────────────────────────── + + async listTransaktionen(req: Request, res: Response): Promise { + try { + const filters = { + haushaltsjahr_id: req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined, + konto_id: req.query.konto_id ? parseInt(req.query.konto_id as string, 10) : undefined, + status: req.query.status as string | undefined, + typ: req.query.typ as string | undefined, + datum_von: req.query.datum_von as string | undefined, + datum_bis: req.query.datum_bis as string | undefined, + search: req.query.search as string | undefined, + }; + const data = await buchhaltungService.listTransaktionen(filters); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.listTransaktionen', { error }); + res.status(500).json({ success: false, message: 'Transaktionen konnten nicht geladen werden' }); + } + } + + async getTransaktion(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.getTransaktionById(id); + if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.getTransaktion', { error }); + res.status(500).json({ success: false, message: 'Transaktion konnte nicht geladen werden' }); + } + } + + async createTransaktion(req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.createTransaktion(req.body, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.createTransaktion', { error }); + res.status(500).json({ success: false, message: 'Transaktion konnte nicht erstellt werden' }); + } + } + + async updateTransaktion(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.updateTransaktion(id, req.body, req.user!.id); + if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden oder nicht mehr bearbeitbar' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.updateTransaktion', { error }); + res.status(500).json({ success: false, message: 'Transaktion konnte nicht aktualisiert werden' }); + } + } + + async deleteTransaktion(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const deleted = await buchhaltungService.deleteTransaktion(id); + if (!deleted) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden oder nicht löschbar' }); return; } + res.json({ success: true }); + } catch (error) { + logger.error('BuchhaltungController.deleteTransaktion', { error }); + res.status(500).json({ success: false, message: 'Transaktion konnte nicht gelöscht werden' }); + } + } + + async buchenTransaktion(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.bookTransaktion(id, req.user!.id); + if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden oder bereits gebucht' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.buchenTransaktion', { error }); + res.status(500).json({ success: false, message: 'Transaktion konnte nicht gebucht werden' }); + } + } + + async stornoTransaktion(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const data = await buchhaltungService.stornoTransaktion(id, req.user!.id); + if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht stornierbar' }); return; } + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.stornoTransaktion', { error }); + res.status(500).json({ success: false, message: 'Transaktion konnte nicht storniert werden' }); + } + } + + // ── Belege ─────────────────────────────────────────────────────────────────── + + async uploadBeleg(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + if (!req.file) { res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' }); return; } + try { + const data = await buchhaltungService.uploadBeleg(id, req.file, req.user!.id); + res.status(201).json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.uploadBeleg', { error }); + res.status(500).json({ success: false, message: 'Beleg konnte nicht hochgeladen werden' }); + } + } + + async deleteBeleg(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'id'), 10); + if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } + try { + const deleted = await buchhaltungService.deleteBeleg(id); + if (!deleted) { res.status(404).json({ success: false, message: 'Beleg nicht gefunden' }); return; } + res.json({ success: true }); + } catch (error) { + logger.error('BuchhaltungController.deleteBeleg', { error }); + res.status(500).json({ success: false, message: 'Beleg konnte nicht gelöscht werden' }); + } + } + + // ── Einstellungen ──────────────────────────────────────────────────────────── + + async getEinstellungen(_req: Request, res: Response): Promise { + try { + const data = await buchhaltungService.getEinstellungen(); + res.json({ success: true, data }); + } catch (error) { + logger.error('BuchhaltungController.getEinstellungen', { error }); + res.status(500).json({ success: false, message: 'Einstellungen konnten nicht geladen werden' }); + } + } + + async setEinstellungen(req: Request, res: Response): Promise { + try { + await buchhaltungService.setEinstellungen(req.body); + res.json({ success: true }); + } catch (error) { + logger.error('BuchhaltungController.setEinstellungen', { error }); + res.status(500).json({ success: false, message: 'Einstellungen konnten nicht gespeichert werden' }); + } + } +} + +export default new BuchhaltungController(); diff --git a/backend/src/database/migrations/075_buchhaltung_schema.sql b/backend/src/database/migrations/075_buchhaltung_schema.sql new file mode 100644 index 0000000..edb10be --- /dev/null +++ b/backend/src/database/migrations/075_buchhaltung_schema.sql @@ -0,0 +1,231 @@ +-- ============================================================================= +-- Migration 075: Buchhaltung (Accounting) Schema +-- ============================================================================= + +-- 1. Account types (lookup table) +CREATE TABLE IF NOT EXISTS buchhaltung_konto_typen ( + id SERIAL PRIMARY KEY, + bezeichnung TEXT NOT NULL UNIQUE, + art TEXT NOT NULL CHECK (art IN ('einnahme', 'ausgabe', 'vermoegen', 'verbindlichkeit')), + sort_order INT NOT NULL DEFAULT 0 +); + +INSERT INTO buchhaltung_konto_typen (bezeichnung, art, sort_order) VALUES + ('Einnahmen', 'einnahme', 1), + ('Ausgaben', 'ausgabe', 2), + ('Vermögen', 'vermoegen', 3), + ('Verbindlichkeiten','verbindlichkeit', 4) +ON CONFLICT (bezeichnung) DO NOTHING; + +-- 2. Bank accounts +CREATE TABLE IF NOT EXISTS buchhaltung_bankkonten ( + id SERIAL PRIMARY KEY, + bezeichnung TEXT NOT NULL, + iban TEXT, + bic TEXT, + institut TEXT, + ist_standard BOOLEAN NOT NULL DEFAULT FALSE, + aktiv BOOLEAN NOT NULL DEFAULT TRUE, + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 3. Fiscal years +CREATE TABLE IF NOT EXISTS buchhaltung_haushaltsjahre ( + id SERIAL PRIMARY KEY, + jahr INT NOT NULL UNIQUE, + bezeichnung TEXT NOT NULL, + beginn DATE NOT NULL, + ende DATE NOT NULL, + abgeschlossen BOOLEAN NOT NULL DEFAULT FALSE, + erstellt_von UUID, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 4. Budget accounts +CREATE TABLE IF NOT EXISTS buchhaltung_konten ( + id SERIAL PRIMARY KEY, + haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id) ON DELETE CASCADE, + konto_typ_id INT REFERENCES buchhaltung_konto_typen(id) ON DELETE SET NULL, + kontonummer TEXT NOT NULL, + bezeichnung TEXT NOT NULL, + budget_betrag NUMERIC(12,2) NOT NULL DEFAULT 0, + notizen TEXT, + aktiv BOOLEAN NOT NULL DEFAULT TRUE, + erstellt_von UUID, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (haushaltsjahr_id, kontonummer) +); + +-- 5. Transactions +CREATE TABLE IF NOT EXISTS buchhaltung_transaktionen ( + id SERIAL PRIMARY KEY, + haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id) ON DELETE RESTRICT, + konto_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL, + bankkonto_id INT REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL, + laufende_nummer INT, + typ TEXT NOT NULL CHECK (typ IN ('einnahme', 'ausgabe')), + betrag NUMERIC(12,2) NOT NULL, + datum DATE NOT NULL, + buchungsdatum DATE, + beschreibung TEXT, + empfaenger_auftraggeber TEXT, + verwendungszweck TEXT, + beleg_nr TEXT, + status TEXT NOT NULL CHECK (status IN ('entwurf', 'gebucht', 'freigegeben', 'storniert')) DEFAULT 'entwurf', + bestellung_id INT REFERENCES bestellungen(id) ON DELETE SET NULL, + erstellt_von UUID, + gebucht_von UUID, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 6. Receipts / attachments +CREATE TABLE IF NOT EXISTS buchhaltung_belege ( + id SERIAL PRIMARY KEY, + transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE, + dateiname TEXT NOT NULL, + original_name TEXT NOT NULL, + dateityp TEXT NOT NULL, + dateigroesse INT NOT NULL, + erstellt_von UUID, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7. Approvals +CREATE TABLE IF NOT EXISTS buchhaltung_freigaben ( + id SERIAL PRIMARY KEY, + transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')) DEFAULT 'ausstehend', + kommentar TEXT, + freigegeben_von UUID REFERENCES users(id) ON DELETE SET NULL, + freigegeben_am TIMESTAMPTZ, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 8. Recurring bookings +CREATE TABLE IF NOT EXISTS buchhaltung_wiederkehrend ( + id SERIAL PRIMARY KEY, + bezeichnung TEXT NOT NULL, + konto_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL, + bankkonto_id INT REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL, + typ TEXT NOT NULL CHECK (typ IN ('einnahme', 'ausgabe')), + betrag NUMERIC(12,2) NOT NULL, + beschreibung TEXT, + empfaenger_auftraggeber TEXT, + intervall TEXT NOT NULL CHECK (intervall IN ('monatlich', 'quartalsweise', 'halbjaehrlich', 'jaehrlich')), + naechste_ausfuehrung DATE NOT NULL, + aktiv BOOLEAN NOT NULL DEFAULT TRUE, + erstellt_von UUID, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 9. Audit log +CREATE TABLE IF NOT EXISTS buchhaltung_audit ( + id SERIAL PRIMARY KEY, + transaktion_id INT REFERENCES buchhaltung_transaktionen(id) ON DELETE SET NULL, + aktion TEXT NOT NULL, + details JSONB, + erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 10. Settings +CREATE TABLE IF NOT EXISTS buchhaltung_einstellungen ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 11. Budget planning (Phase 7 prep) +CREATE TABLE IF NOT EXISTS buchhaltung_planung ( + id SERIAL PRIMARY KEY, + haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id) ON DELETE CASCADE, + bezeichnung TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('entwurf', 'aktiv', 'abgeschlossen')) DEFAULT 'entwurf', + erstellt_von UUID, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 12. Planning line items +CREATE TABLE IF NOT EXISTS buchhaltung_planpositionen ( + id SERIAL PRIMARY KEY, + planung_id INT NOT NULL REFERENCES buchhaltung_planung(id) ON DELETE CASCADE, + konto_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL, + bezeichnung TEXT NOT NULL, + plan_betrag NUMERIC(12,2) NOT NULL DEFAULT 0, + notizen TEXT, + sort_order INT NOT NULL DEFAULT 0 +); + +-- ============================================================================= +-- Indexes +-- ============================================================================= + +CREATE INDEX IF NOT EXISTS idx_buch_trans_haushaltsjahr ON buchhaltung_transaktionen(haushaltsjahr_id); +CREATE INDEX IF NOT EXISTS idx_buch_trans_konto ON buchhaltung_transaktionen(konto_id); +CREATE INDEX IF NOT EXISTS idx_buch_trans_bankkonto ON buchhaltung_transaktionen(bankkonto_id); +CREATE INDEX IF NOT EXISTS idx_buch_trans_status ON buchhaltung_transaktionen(status); +CREATE INDEX IF NOT EXISTS idx_buch_trans_datum ON buchhaltung_transaktionen(datum); +CREATE INDEX IF NOT EXISTS idx_buch_trans_bestellung ON buchhaltung_transaktionen(bestellung_id); +CREATE INDEX IF NOT EXISTS idx_buch_konten_haushaltsjahr ON buchhaltung_konten(haushaltsjahr_id); + +-- ============================================================================= +-- Triggers — aktualisiert_am (reuse existing update_aktualisiert_am function) +-- ============================================================================= + +CREATE TRIGGER trg_buch_bankkonten_updated + BEFORE UPDATE ON buchhaltung_bankkonten + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + +CREATE TRIGGER trg_buch_haushaltsjahre_updated + BEFORE UPDATE ON buchhaltung_haushaltsjahre + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + +CREATE TRIGGER trg_buch_konten_updated + BEFORE UPDATE ON buchhaltung_konten + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + +CREATE TRIGGER trg_buch_transaktionen_updated + BEFORE UPDATE ON buchhaltung_transaktionen + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + +CREATE TRIGGER trg_buch_wiederkehrend_updated + BEFORE UPDATE ON buchhaltung_wiederkehrend + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + +CREATE TRIGGER trg_buch_planung_updated + BEFORE UPDATE ON buchhaltung_planung + FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am(); + +-- ============================================================================= +-- Trigger — sequential laufende_nummer per fiscal year +-- ============================================================================= + +CREATE OR REPLACE FUNCTION buchhaltung_assign_laufende_nummer() +RETURNS TRIGGER AS $$ +DECLARE + next_num INT; +BEGIN + -- Assign laufende_nummer when status becomes 'gebucht' and not yet assigned + IF NEW.status = 'gebucht' AND NEW.laufende_nummer IS NULL THEN + IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND (OLD.status IS NULL OR OLD.status != 'gebucht')) THEN + SELECT COALESCE(MAX(laufende_nummer), 0) + 1 + INTO next_num + FROM buchhaltung_transaktionen + WHERE haushaltsjahr_id = NEW.haushaltsjahr_id; + NEW.laufende_nummer := next_num; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_buch_transaktionen_laufende_nummer + BEFORE INSERT OR UPDATE ON buchhaltung_transaktionen + FOR EACH ROW EXECUTE FUNCTION buchhaltung_assign_laufende_nummer(); diff --git a/backend/src/database/migrations/076_buchhaltung_permissions.sql b/backend/src/database/migrations/076_buchhaltung_permissions.sql new file mode 100644 index 0000000..442985b --- /dev/null +++ b/backend/src/database/migrations/076_buchhaltung_permissions.sql @@ -0,0 +1,33 @@ +-- ============================================================================= +-- Migration 076: Buchhaltung Permissions +-- ============================================================================= + +-- Feature group +INSERT INTO feature_groups (id, label, sort_order) +VALUES ('buchhaltung', 'Buchhaltung', 13) +ON CONFLICT (id) DO NOTHING; + +-- 8 permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('buchhaltung:view', 'buchhaltung', 'Ansehen', 'Buchhaltungsdaten einsehen', 1), + ('buchhaltung:create', 'buchhaltung', 'Erstellen', 'Transaktionen anlegen', 2), + ('buchhaltung:edit', 'buchhaltung', 'Bearbeiten', 'Transaktionen bearbeiten', 3), + ('buchhaltung:delete', 'buchhaltung', 'Löschen', 'Transaktionen löschen', 4), + ('buchhaltung:manage_accounts', 'buchhaltung', 'Konten verwalten', 'Konten und Bankkonten verwalten', 5), + ('buchhaltung:manage_settings', 'buchhaltung', 'Einstellungen', 'Buchhaltungs-Einstellungen verwalten', 6), + ('buchhaltung:export', 'buchhaltung', 'Exportieren', 'Daten exportieren (CSV/PDF)', 7), + ('buchhaltung:widget', 'buchhaltung', 'Widget', 'Dashboard-Widget anzeigen', 8) +ON CONFLICT (id) DO NOTHING; + +-- Grant all permissions to dashboard_kommando +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT 'dashboard_kommando', id FROM permissions WHERE feature_group_id = 'buchhaltung' +ON CONFLICT DO NOTHING; + +-- Grant view, create, edit, widget to dashboard_chargen +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_chargen', 'buchhaltung:view'), + ('dashboard_chargen', 'buchhaltung:create'), + ('dashboard_chargen', 'buchhaltung:edit'), + ('dashboard_chargen', 'buchhaltung:widget') +ON CONFLICT DO NOTHING; diff --git a/backend/src/middleware/upload.ts b/backend/src/middleware/upload.ts index 17c054a..d0d60b5 100644 --- a/backend/src/middleware/upload.ts +++ b/backend/src/middleware/upload.ts @@ -132,4 +132,42 @@ const issueOptions: any = { // eslint-disable-next-line @typescript-eslint/no-explicit-any export const uploadIssue: any = multer(issueOptions); -export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR, ISSUE_DIR }; +// ── Buchhaltung uploads (accounting receipts / Belege) ─────────────────────── + +const BUCHHALTUNG_DIR = path.join(APP_ROOT, 'uploads', 'buchhaltung'); +try { + if (!fs.existsSync(BUCHHALTUNG_DIR)) { + fs.mkdirSync(BUCHHALTUNG_DIR, { recursive: true }); + } +} catch (err) { + logger.warn(`Could not create buchhaltung upload directory`, { err }); +} + +const buchhaltungStorage = multer.diskStorage({ + destination(_req: any, _file: any, cb: any) { + cb(null, BUCHHALTUNG_DIR); + }, + filename(_req: any, file: any, cb: any) { + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const ext = path.extname(file.originalname); + cb(null, `${uniqueSuffix}${ext}`); + }, +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const buchhaltungOptions: any = { + storage: buchhaltungStorage, + fileFilter(_req: any, file: any, cb: any) { + if (ALLOWED_TYPES.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error(`Dateityp ${file.mimetype} ist nicht erlaubt.`)); + } + }, + limits: { fileSize: 20 * 1024 * 1024 }, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const uploadBuchhaltung: any = multer(buchhaltungOptions); + +export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR, ISSUE_DIR, BUCHHALTUNG_DIR }; diff --git a/backend/src/routes/buchhaltung.routes.ts b/backend/src/routes/buchhaltung.routes.ts new file mode 100644 index 0000000..a36dc88 --- /dev/null +++ b/backend/src/routes/buchhaltung.routes.ts @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import buchhaltungController from '../controllers/buchhaltung.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; +import { uploadBuchhaltung } from '../middleware/upload'; + +const router = Router(); + +// ── Stats ───────────────────────────────────────────────────────────────────── +router.get('/stats', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getStats.bind(buchhaltungController)); + +// ── Haushaltsjahre ───────────────────────────────────────────────────────────── +router.get('/haushaltsjahre', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listHaushaltsjahre.bind(buchhaltungController)); +router.post('/haushaltsjahre', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createHaushaltsjahr.bind(buchhaltungController)); +router.patch('/haushaltsjahre/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateHaushaltsjahr.bind(buchhaltungController)); +router.post('/haushaltsjahre/:id/close', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.closeHaushaltsjahr.bind(buchhaltungController)); + +// ── Konto-Typen ─────────────────────────────────────────────────────────────── +router.get('/konto-typen', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKontoTypen.bind(buchhaltungController)); + +// ── Bankkonten ──────────────────────────────────────────────────────────────── +router.get('/bankkonten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listBankkonten.bind(buchhaltungController)); +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)); + +// ── Konten ──────────────────────────────────────────────────────────────────── +router.get('/konten', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.listKonten.bind(buchhaltungController)); +router.post('/konten', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.createKonto.bind(buchhaltungController)); +router.patch('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.updateKonto.bind(buchhaltungController)); +router.delete('/konten/:id', authenticate, requirePermission('buchhaltung:manage_accounts'), buchhaltungController.deleteKonto.bind(buchhaltungController)); +router.get('/konten/:id/budget', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getKontoBudget.bind(buchhaltungController)); + +// ── Belege (specific routes BEFORE /:id to prevent param capture) ───────────── +router.delete('/belege/:id', authenticate, requirePermission('buchhaltung:delete'), buchhaltungController.deleteBeleg.bind(buchhaltungController)); + +// ── Einstellungen ───────────────────────────────────────────────────────────── +router.get('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.getEinstellungen.bind(buchhaltungController)); +router.put('/einstellungen', authenticate, requirePermission('buchhaltung:manage_settings'), buchhaltungController.setEinstellungen.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)); + +// ── Transaktionen (detail — /:id must come last) ────────────────────────────── +router.get('/:id', authenticate, requirePermission('buchhaltung:view'), buchhaltungController.getTransaktion.bind(buchhaltungController)); +router.patch('/:id', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.updateTransaktion.bind(buchhaltungController)); +router.delete('/:id', authenticate, requirePermission('buchhaltung:delete'), buchhaltungController.deleteTransaktion.bind(buchhaltungController)); +router.post('/:id/buchen', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.buchenTransaktion.bind(buchhaltungController)); +router.post('/:id/storno', authenticate, requirePermission('buchhaltung:edit'), buchhaltungController.stornoTransaktion.bind(buchhaltungController)); +router.post('/:id/belege', authenticate, requirePermission('buchhaltung:create'), uploadBuchhaltung.single('datei'), buchhaltungController.uploadBeleg.bind(buchhaltungController)); + +export default router; diff --git a/backend/src/services/buchhaltung.service.ts b/backend/src/services/buchhaltung.service.ts new file mode 100644 index 0000000..4037d99 --- /dev/null +++ b/backend/src/services/buchhaltung.service.ts @@ -0,0 +1,857 @@ +// ============================================================================= +// Buchhaltung (Accounting) Service +// ============================================================================= + +import pool from '../config/database'; +import logger from '../utils/logger'; +import fs from 'fs'; + +// --------------------------------------------------------------------------- +// Haushaltsjahre (Fiscal Years) +// --------------------------------------------------------------------------- + +async function getAllHaushaltsjahre() { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_haushaltsjahre ORDER BY jahr DESC` + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getAllHaushaltsjahre failed', { error }); + throw new Error('Haushaltsjahre konnten nicht geladen werden'); + } +} + +async function getHaushaltsjahrById(id: number) { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_haushaltsjahre WHERE id = $1`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.getHaushaltsjahrById failed', { error, id }); + throw new Error('Haushaltsjahr konnte nicht geladen werden'); + } +} + +async function getCurrentHaushaltsjahr() { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_haushaltsjahre + WHERE abgeschlossen = FALSE + ORDER BY jahr DESC + LIMIT 1` + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.getCurrentHaushaltsjahr failed', { error }); + throw new Error('Aktuelles Haushaltsjahr konnte nicht geladen werden'); + } +} + +async function createHaushaltsjahr( + data: { jahr: number; bezeichnung: string; beginn: string; ende: string }, + userId: string +) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_haushaltsjahre (jahr, bezeichnung, beginn, ende, erstellt_von) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [data.jahr, data.bezeichnung, data.beginn, data.ende, userId] + ); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.createHaushaltsjahr failed', { error }); + throw new Error('Haushaltsjahr konnte nicht erstellt werden'); + } +} + +async function updateHaushaltsjahr( + id: number, + data: { bezeichnung?: string; beginn?: string; ende?: 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.beginn !== undefined) { fields.push(`beginn = $${idx++}`); values.push(data.beginn); } + if (data.ende !== undefined) { fields.push(`ende = $${idx++}`); values.push(data.ende); } + if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); + values.push(id); + const result = await pool.query( + `UPDATE buchhaltung_haushaltsjahre SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.updateHaushaltsjahr failed', { error, id }); + throw new Error('Haushaltsjahr konnte nicht aktualisiert werden'); + } +} + +async function closeHaushaltsjahr(id: number) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + // Check no open entwurf transactions + const check = await client.query( + `SELECT COUNT(*) FROM buchhaltung_transaktionen WHERE haushaltsjahr_id = $1 AND status = 'entwurf'`, + [id] + ); + if (parseInt(check.rows[0].count, 10) > 0) { + throw new Error('Es gibt noch offene Entwürfe in diesem Haushaltsjahr'); + } + const result = await client.query( + `UPDATE buchhaltung_haushaltsjahre SET abgeschlossen = TRUE WHERE id = $1 RETURNING *`, + [id] + ); + await client.query('COMMIT'); + return result.rows[0] || null; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('BuchhaltungService.closeHaushaltsjahr failed', { error, id }); + throw error; + } finally { + client.release(); + } +} + +// --------------------------------------------------------------------------- +// Konto-Typen (Account Types — static lookup) +// --------------------------------------------------------------------------- + +async function getAllKontoTypen() { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_konto_typen ORDER BY sort_order` + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getAllKontoTypen failed', { error }); + throw new Error('Kontotypen konnten nicht geladen werden'); + } +} + +// --------------------------------------------------------------------------- +// Bankkonten (Bank Accounts) +// --------------------------------------------------------------------------- + +async function getAllBankkonten() { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_bankkonten WHERE aktiv = TRUE ORDER BY ist_standard DESC, bezeichnung` + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getAllBankkonten failed', { error }); + throw new Error('Bankkonten konnten nicht geladen werden'); + } +} + +async function getBankkontoById(id: number) { + try { + const result = await pool.query(`SELECT * FROM buchhaltung_bankkonten WHERE id = $1`, [id]); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.getBankkontoById failed', { error, id }); + throw new Error('Bankkonto konnte nicht geladen werden'); + } +} + +async function createBankkonto( + data: { bezeichnung: string; iban?: string; bic?: string; institut?: string; ist_standard?: boolean }, + userId: string +) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + if (data.ist_standard) { + await client.query(`UPDATE buchhaltung_bankkonten SET ist_standard = FALSE`); + } + const result = await client.query( + `INSERT INTO buchhaltung_bankkonten (bezeichnung, iban, bic, institut, ist_standard, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [data.bezeichnung, data.iban || null, data.bic || null, data.institut || null, data.ist_standard || false, userId] + ); + await client.query('COMMIT'); + return result.rows[0]; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('BuchhaltungService.createBankkonto failed', { error }); + throw new Error('Bankkonto konnte nicht erstellt werden'); + } finally { + client.release(); + } +} + +async function updateBankkonto( + id: number, + data: { bezeichnung?: string; iban?: string; bic?: string; institut?: string; ist_standard?: boolean } +) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + if (data.ist_standard) { + await client.query(`UPDATE buchhaltung_bankkonten SET ist_standard = FALSE WHERE id != $1`, [id]); + } + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } + if (data.iban !== undefined) { fields.push(`iban = $${idx++}`); values.push(data.iban || null); } + if (data.bic !== undefined) { fields.push(`bic = $${idx++}`); values.push(data.bic || null); } + if (data.institut !== undefined) { fields.push(`institut = $${idx++}`); values.push(data.institut || null); } + if (data.ist_standard !== undefined) { fields.push(`ist_standard = $${idx++}`); values.push(data.ist_standard); } + if (fields.length > 0) { + values.push(id); + await client.query( + `UPDATE buchhaltung_bankkonten SET ${fields.join(', ')} WHERE id = $${idx}`, + values + ); + } + const result = await client.query(`SELECT * FROM buchhaltung_bankkonten WHERE id = $1`, [id]); + await client.query('COMMIT'); + return result.rows[0] || null; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('BuchhaltungService.updateBankkonto failed', { error, id }); + throw new Error('Bankkonto konnte nicht aktualisiert werden'); + } finally { + client.release(); + } +} + +async function deactivateBankkonto(id: number) { + try { + const result = await pool.query( + `UPDATE buchhaltung_bankkonten SET aktiv = FALSE WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.deactivateBankkonto failed', { error, id }); + throw new Error('Bankkonto konnte nicht deaktiviert werden'); + } +} + +// --------------------------------------------------------------------------- +// Konten (Budget Accounts) +// --------------------------------------------------------------------------- + +async function getAllKonten(haushaltsjahrId: number) { + try { + const result = await pool.query( + `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art + FROM buchhaltung_konten k + LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id + WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE + ORDER BY k.kontonummer`, + [haushaltsjahrId] + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getAllKonten failed', { error, haushaltsjahrId }); + throw new Error('Konten konnten nicht geladen werden'); + } +} + +async function getKontoById(id: number) { + try { + const result = await pool.query( + `SELECT k.*, kt.bezeichnung as konto_typ_bezeichnung, kt.art as konto_typ_art + FROM buchhaltung_konten k + LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id + WHERE k.id = $1`, + [id] + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.getKontoById failed', { error, id }); + throw new Error('Konto konnte nicht geladen werden'); + } +} + +async function createKonto( + data: { haushaltsjahr_id: number; konto_typ_id?: number; kontonummer: string; bezeichnung: string; budget_betrag?: number; notizen?: string }, + userId: string +) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_konten (haushaltsjahr_id, konto_typ_id, kontonummer, bezeichnung, budget_betrag, notizen, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [data.haushaltsjahr_id, data.konto_typ_id || null, data.kontonummer, data.bezeichnung, data.budget_betrag || 0, data.notizen || null, userId] + ); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.createKonto failed', { error }); + throw new Error('Konto konnte nicht erstellt werden'); + } +} + +async function updateKonto( + id: number, + data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; budget_betrag?: number; notizen?: string } +) { + try { + const fields: string[] = []; + const values: unknown[] = []; + let idx = 1; + if (data.konto_typ_id !== undefined) { fields.push(`konto_typ_id = $${idx++}`); values.push(data.konto_typ_id || null); } + if (data.kontonummer !== undefined) { fields.push(`kontonummer = $${idx++}`); values.push(data.kontonummer); } + if (data.bezeichnung !== undefined) { fields.push(`bezeichnung = $${idx++}`); values.push(data.bezeichnung); } + if (data.budget_betrag !== undefined){ fields.push(`budget_betrag = $${idx++}`); values.push(data.budget_betrag); } + if (data.notizen !== undefined) { fields.push(`notizen = $${idx++}`); values.push(data.notizen || null); } + if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); + values.push(id); + const result = await pool.query( + `UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.updateKonto failed', { error, id }); + throw new Error('Konto konnte nicht aktualisiert werden'); + } +} + +async function deleteKonto(id: number) { + try { + // Soft delete + await pool.query(`UPDATE buchhaltung_konten SET aktiv = FALSE WHERE id = $1`, [id]); + } catch (error) { + logger.error('BuchhaltungService.deleteKonto failed', { error, id }); + throw new Error('Konto konnte nicht gelöscht werden'); + } +} + +async function getBudgetUtilisation(id: number) { + try { + const result = await pool.query( + `SELECT k.*, + kt.bezeichnung as konto_typ_bezeichnung, + kt.art as konto_typ_art, + COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) as gebucht_betrag, + COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status = 'entwurf' THEN t.betrag ELSE 0 END), 0) as ausstehend_betrag + FROM buchhaltung_konten k + LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id + LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id + WHERE k.id = $1 + GROUP BY k.id, kt.bezeichnung, kt.art`, + [id] + ); + if (!result.rows[0]) return null; + const row = result.rows[0]; + const gebucht = parseFloat(row.gebucht_betrag); + const ausstehend = parseFloat(row.ausstehend_betrag); + const budget = parseFloat(row.budget_betrag); + return { + ...row, + gebucht_betrag: gebucht, + ausstehend_betrag: ausstehend, + verfuegbar_betrag: budget - gebucht - ausstehend, + auslastung_prozent: budget > 0 ? Math.round((gebucht / budget) * 100) : 0, + }; + } catch (error) { + logger.error('BuchhaltungService.getBudgetUtilisation failed', { error, id }); + throw new Error('Budgetauslastung konnte nicht geladen werden'); + } +} + +// --------------------------------------------------------------------------- +// Transaktionen (Transactions) +// --------------------------------------------------------------------------- + +async function listTransaktionen(filters: { + haushaltsjahr_id?: number; + konto_id?: number; + status?: string; + typ?: string; + datum_von?: string; + datum_bis?: string; + search?: string; +}) { + try { + const conditions: string[] = []; + const values: unknown[] = []; + let idx = 1; + + if (filters.haushaltsjahr_id) { conditions.push(`t.haushaltsjahr_id = $${idx++}`); values.push(filters.haushaltsjahr_id); } + if (filters.konto_id) { conditions.push(`t.konto_id = $${idx++}`); values.push(filters.konto_id); } + if (filters.status) { conditions.push(`t.status = $${idx++}`); values.push(filters.status); } + if (filters.typ) { conditions.push(`t.typ = $${idx++}`); values.push(filters.typ); } + if (filters.datum_von) { conditions.push(`t.datum >= $${idx++}`); values.push(filters.datum_von); } + if (filters.datum_bis) { conditions.push(`t.datum <= $${idx++}`); values.push(filters.datum_bis); } + if (filters.search) { + conditions.push(`(t.beschreibung ILIKE $${idx} OR t.empfaenger_auftraggeber ILIKE $${idx} OR t.beleg_nr ILIKE $${idx})`); + values.push(`%${filters.search}%`); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const result = await pool.query( + `SELECT t.*, + k.bezeichnung as konto_bezeichnung, + k.kontonummer as konto_kontonummer, + bk.bezeichnung as bankkonto_bezeichnung + FROM buchhaltung_transaktionen t + LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id + LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id + ${where} + ORDER BY t.datum DESC, t.id DESC`, + values + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.listTransaktionen failed', { error }); + throw new Error('Transaktionen konnten nicht geladen werden'); + } +} + +async function getTransaktionById(id: number) { + try { + const txResult = await pool.query( + `SELECT t.*, + k.bezeichnung as konto_bezeichnung, + k.kontonummer as konto_kontonummer, + bk.bezeichnung as bankkonto_bezeichnung + FROM buchhaltung_transaktionen t + LEFT JOIN buchhaltung_konten k ON t.konto_id = k.id + LEFT JOIN buchhaltung_bankkonten bk ON t.bankkonto_id = bk.id + WHERE t.id = $1`, + [id] + ); + if (!txResult.rows[0]) return null; + const tx = txResult.rows[0]; + const belegeResult = await pool.query( + `SELECT * FROM buchhaltung_belege WHERE transaktion_id = $1 ORDER BY erstellt_am`, + [id] + ); + tx.belege = belegeResult.rows; + return tx; + } catch (error) { + logger.error('BuchhaltungService.getTransaktionById failed', { error, id }); + throw new Error('Transaktion konnte nicht geladen werden'); + } +} + +async function createTransaktion( + data: { + haushaltsjahr_id: number; + konto_id?: number | null; + bankkonto_id?: number | null; + typ: 'einnahme' | 'ausgabe'; + betrag: number; + datum: string; + beschreibung?: string; + empfaenger_auftraggeber?: string; + verwendungszweck?: string; + beleg_nr?: string; + }, + userId: string +) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_transaktionen + (haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung, empfaenger_auftraggeber, verwendungszweck, beleg_nr, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + data.haushaltsjahr_id, + data.konto_id || null, + data.bankkonto_id || null, + data.typ, + data.betrag, + data.datum, + data.beschreibung || null, + data.empfaenger_auftraggeber || null, + data.verwendungszweck || null, + data.beleg_nr || null, + userId, + ] + ); + await logAudit(result.rows[0].id, 'erstellt', { betrag: data.betrag }, userId); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.createTransaktion failed', { error }); + throw new Error('Transaktion konnte nicht erstellt werden'); + } +} + +async function updateTransaktion( + id: number, + data: { + konto_id?: number | null; + bankkonto_id?: number | null; + betrag?: number; + datum?: string; + beschreibung?: string; + empfaenger_auftraggeber?: string; + verwendungszweck?: string; + beleg_nr?: string; + }, + userId: string +) { + 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.bankkonto_id !== undefined) { fields.push(`bankkonto_id = $${idx++}`); values.push(data.bankkonto_id); } + if (data.betrag !== undefined) { fields.push(`betrag = $${idx++}`); values.push(data.betrag); } + if (data.datum !== undefined) { fields.push(`datum = $${idx++}`); values.push(data.datum); } + if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung || null); } + if (data.empfaenger_auftraggeber !== undefined){ fields.push(`empfaenger_auftraggeber = $${idx++}`); values.push(data.empfaenger_auftraggeber || null); } + if (data.verwendungszweck !== undefined) { fields.push(`verwendungszweck = $${idx++}`); values.push(data.verwendungszweck || null); } + if (data.beleg_nr !== undefined) { fields.push(`beleg_nr = $${idx++}`); values.push(data.beleg_nr || null); } + if (fields.length === 0) throw new Error('Keine Felder zum Aktualisieren'); + values.push(id); + const result = await pool.query( + `UPDATE buchhaltung_transaktionen SET ${fields.join(', ')} WHERE id = $${idx} AND status = 'entwurf' RETURNING *`, + values + ); + if (result.rows[0]) await logAudit(id, 'aktualisiert', data, userId); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.updateTransaktion failed', { error, id }); + throw new Error('Transaktion konnte nicht aktualisiert werden'); + } +} + +async function bookTransaktion(id: number, userId: string) { + try { + const result = await pool.query( + `UPDATE buchhaltung_transaktionen + SET status = 'gebucht', buchungsdatum = NOW(), gebucht_von = $2 + WHERE id = $1 AND status = 'entwurf' + RETURNING *`, + [id, userId] + ); + if (result.rows[0]) await logAudit(id, 'gebucht', {}, userId); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.bookTransaktion failed', { error, id }); + throw new Error('Transaktion konnte nicht gebucht werden'); + } +} + +async function stornoTransaktion(id: number, userId: string) { + try { + const result = await pool.query( + `UPDATE buchhaltung_transaktionen + SET status = 'storniert' + WHERE id = $1 AND status IN ('gebucht', 'freigegeben') + RETURNING *`, + [id, userId] + ); + if (result.rows[0]) await logAudit(id, 'storniert', {}, userId); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.stornoTransaktion failed', { error, id }); + throw new Error('Transaktion konnte nicht storniert werden'); + } +} + +async function deleteTransaktion(id: number) { + try { + // Only entwurf can be deleted + const result = await pool.query( + `DELETE FROM buchhaltung_transaktionen WHERE id = $1 AND status = 'entwurf' RETURNING id`, + [id] + ); + return result.rows.length > 0; + } catch (error) { + logger.error('BuchhaltungService.deleteTransaktion failed', { error, id }); + throw new Error('Transaktion konnte nicht gelöscht werden'); + } +} + +// --------------------------------------------------------------------------- +// Belege (Receipts) +// --------------------------------------------------------------------------- + +async function getBelegeByTransaktion(transaktionId: number) { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_belege WHERE transaktion_id = $1 ORDER BY erstellt_am`, + [transaktionId] + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getBelegeByTransaktion failed', { error }); + throw new Error('Belege konnten nicht geladen werden'); + } +} + +async function uploadBeleg( + transaktionId: number, + file: { filename: string; originalname: string; mimetype: string; size: number }, + userId: string +) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_belege (transaktion_id, dateiname, original_name, dateityp, dateigroesse, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [transaktionId, file.filename, file.originalname, file.mimetype, file.size, userId] + ); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.uploadBeleg failed', { error }); + throw new Error('Beleg konnte nicht hochgeladen werden'); + } +} + +async function deleteBeleg(id: number) { + try { + const result = await pool.query( + `DELETE FROM buchhaltung_belege WHERE id = $1 RETURNING *`, + [id] + ); + if (result.rows[0]) { + // Delete file from disk + const filePath = process.env.NODE_ENV === 'production' + ? `/app/uploads/buchhaltung/${result.rows[0].dateiname}` + : `${process.cwd()}/uploads/buchhaltung/${result.rows[0].dateiname}`; + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + return result.rows.length > 0; + } catch (error) { + logger.error('BuchhaltungService.deleteBeleg failed', { error, id }); + throw new Error('Beleg konnte nicht gelöscht werden'); + } +} + +// --------------------------------------------------------------------------- +// Freigaben (Approvals) +// --------------------------------------------------------------------------- + +async function getFreigabenByTransaktion(transaktionId: number) { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_freigaben WHERE transaktion_id = $1 ORDER BY erstellt_am DESC`, + [transaktionId] + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getFreigabenByTransaktion failed', { error }); + throw new Error('Freigaben konnten nicht geladen werden'); + } +} + +async function createFreigabe(transaktionId: number, userId: string) { + try { + const result = await pool.query( + `INSERT INTO buchhaltung_freigaben (transaktion_id) VALUES ($1) RETURNING *`, + [transaktionId] + ); + await logAudit(transaktionId, 'freigabe_angefragt', {}, userId); + return result.rows[0]; + } catch (error) { + logger.error('BuchhaltungService.createFreigabe failed', { error }); + throw new Error('Freigabe konnte nicht erstellt werden'); + } +} + +async function approveFreigabe(id: number, kommentar: string | undefined, userId: string) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const result = await client.query( + `UPDATE buchhaltung_freigaben SET status = 'genehmigt', kommentar = $2, freigegeben_von = $3, freigegeben_am = NOW() WHERE id = $1 RETURNING *`, + [id, kommentar || null, userId] + ); + if (result.rows[0]) { + await client.query( + `UPDATE buchhaltung_transaktionen SET status = 'freigegeben' WHERE id = $1`, + [result.rows[0].transaktion_id] + ); + await logAudit(result.rows[0].transaktion_id, 'freigegeben', { kommentar }, userId); + } + await client.query('COMMIT'); + return result.rows[0] || null; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('BuchhaltungService.approveFreigabe failed', { error, id }); + throw new Error('Freigabe konnte nicht genehmigt werden'); + } finally { + client.release(); + } +} + +async function rejectFreigabe(id: number, kommentar: string | undefined, userId: string) { + try { + const result = await pool.query( + `UPDATE buchhaltung_freigaben SET status = 'abgelehnt', kommentar = $2, freigegeben_von = $3, freigegeben_am = NOW() WHERE id = $1 RETURNING *`, + [id, kommentar || null, userId] + ); + if (result.rows[0]) await logAudit(result.rows[0].transaktion_id, 'freigabe_abgelehnt', { kommentar }, userId); + return result.rows[0] || null; + } catch (error) { + logger.error('BuchhaltungService.rejectFreigabe failed', { error, id }); + throw new Error('Freigabe konnte nicht abgelehnt werden'); + } +} + +// --------------------------------------------------------------------------- +// Einstellungen (Settings) +// --------------------------------------------------------------------------- + +async function getEinstellungen() { + try { + const result = await pool.query(`SELECT * FROM buchhaltung_einstellungen`); + const settings: Record = {}; + for (const row of result.rows) settings[row.key] = row.value; + return settings; + } catch (error) { + logger.error('BuchhaltungService.getEinstellungen failed', { error }); + throw new Error('Einstellungen konnten nicht geladen werden'); + } +} + +async function setEinstellungen(data: Record) { + try { + for (const [key, value] of Object.entries(data)) { + await pool.query( + `INSERT INTO buchhaltung_einstellungen (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = $2, aktualisiert_am = NOW()`, + [key, JSON.stringify(value)] + ); + } + } catch (error) { + logger.error('BuchhaltungService.setEinstellungen failed', { error }); + throw new Error('Einstellungen konnten nicht gespeichert werden'); + } +} + +// --------------------------------------------------------------------------- +// Audit +// --------------------------------------------------------------------------- + +async function logAudit(transaktionId: number | null, aktion: string, details: unknown, userId: string) { + try { + await pool.query( + `INSERT INTO buchhaltung_audit (transaktion_id, aktion, details, erstellt_von) VALUES ($1, $2, $3, $4)`, + [transaktionId, aktion, JSON.stringify(details), userId] + ); + } catch (err) { + logger.warn('BuchhaltungService.logAudit failed (non-fatal)', { err }); + } +} + +async function getAuditByTransaktion(transaktionId: number) { + try { + const result = await pool.query( + `SELECT * FROM buchhaltung_audit WHERE transaktion_id = $1 ORDER BY erstellt_am DESC`, + [transaktionId] + ); + return result.rows; + } catch (error) { + logger.error('BuchhaltungService.getAuditByTransaktion failed', { error }); + throw new Error('Audit-Einträge konnten nicht geladen werden'); + } +} + +// --------------------------------------------------------------------------- +// Stats / Overview +// --------------------------------------------------------------------------- + +async function getOverview(haushaltsjahrId: number) { + try { + const totalsResult = await pool.query( + `SELECT + COALESCE(SUM(CASE WHEN typ = 'einnahme' AND status IN ('gebucht','freigegeben') THEN betrag ELSE 0 END), 0) as total_einnahmen, + COALESCE(SUM(CASE WHEN typ = 'ausgabe' AND status IN ('gebucht','freigegeben') THEN betrag ELSE 0 END), 0) as total_ausgaben + FROM buchhaltung_transaktionen + WHERE haushaltsjahr_id = $1`, + [haushaltsjahrId] + ); + + const kontenResult = await pool.query( + `SELECT k.*, + kt.bezeichnung as konto_typ_bezeichnung, + kt.art as konto_typ_art, + COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status IN ('gebucht','freigegeben') THEN t.betrag ELSE 0 END), 0) as gebucht_betrag, + COALESCE(SUM(CASE WHEN t.typ = 'ausgabe' AND t.status = 'entwurf' THEN t.betrag ELSE 0 END), 0) as ausstehend_betrag + FROM buchhaltung_konten k + LEFT JOIN buchhaltung_konto_typen kt ON k.konto_typ_id = kt.id + LEFT JOIN buchhaltung_transaktionen t ON t.konto_id = k.id AND t.haushaltsjahr_id = $1 + WHERE k.haushaltsjahr_id = $1 AND k.aktiv = TRUE + GROUP BY k.id, kt.bezeichnung, kt.art + ORDER BY k.kontonummer`, + [haushaltsjahrId] + ); + + const totalEinnahmen = parseFloat(totalsResult.rows[0].total_einnahmen); + const totalAusgaben = parseFloat(totalsResult.rows[0].total_ausgaben); + + return { + haushaltsjahr_id: haushaltsjahrId, + total_einnahmen: totalEinnahmen, + total_ausgaben: totalAusgaben, + saldo: totalEinnahmen - totalAusgaben, + konten_budget: kontenResult.rows.map(row => ({ + ...row, + gebucht_betrag: parseFloat(row.gebucht_betrag), + ausstehend_betrag: parseFloat(row.ausstehend_betrag), + verfuegbar_betrag: parseFloat(row.budget_betrag) - parseFloat(row.gebucht_betrag) - parseFloat(row.ausstehend_betrag), + auslastung_prozent: parseFloat(row.budget_betrag) > 0 + ? Math.round((parseFloat(row.gebucht_betrag) / parseFloat(row.budget_betrag)) * 100) + : 0, + })), + }; + } catch (error) { + logger.error('BuchhaltungService.getOverview failed', { error, haushaltsjahrId }); + throw new Error('Übersicht konnte nicht geladen werden'); + } +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +const buchhaltungService = { + getAllHaushaltsjahre, + getHaushaltsjahrById, + getCurrentHaushaltsjahr, + createHaushaltsjahr, + updateHaushaltsjahr, + closeHaushaltsjahr, + getAllKontoTypen, + getAllBankkonten, + getBankkontoById, + createBankkonto, + updateBankkonto, + deactivateBankkonto, + getAllKonten, + getKontoById, + createKonto, + updateKonto, + deleteKonto, + getBudgetUtilisation, + listTransaktionen, + getTransaktionById, + createTransaktion, + updateTransaktion, + bookTransaktion, + stornoTransaktion, + deleteTransaktion, + getBelegeByTransaktion, + uploadBeleg, + deleteBeleg, + getFreigabenByTransaktion, + createFreigabe, + approveFreigabe, + rejectFreigabe, + getEinstellungen, + setEinstellungen, + logAudit, + getAuditByTransaktion, + getOverview, +}; + +export default buchhaltungService; diff --git a/backend/src/services/permission.service.ts b/backend/src/services/permission.service.ts index fe810fc..7c0d0c6 100644 --- a/backend/src/services/permission.service.ts +++ b/backend/src/services/permission.service.ts @@ -41,6 +41,13 @@ const DEFAULT_PERMISSION_DEPS: Record = { 'wissen:widget_recent': ['wissen:view'], 'wissen:widget_search': ['wissen:view'], 'admin:write': ['admin:view'], + 'buchhaltung:create': ['buchhaltung:view'], + 'buchhaltung:edit': ['buchhaltung:view'], + 'buchhaltung:delete': ['buchhaltung:view', 'buchhaltung:create'], + 'buchhaltung:manage_accounts': ['buchhaltung:view'], + 'buchhaltung:manage_settings': ['buchhaltung:view'], + 'buchhaltung:export': ['buchhaltung:view'], + 'buchhaltung:widget': ['buchhaltung:view'], }; export interface FeatureGroupRow { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3718eb5..66a7dea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,7 @@ import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestel import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail'; import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; import Checklisten from './pages/Checklisten'; +import Buchhaltung from './pages/Buchhaltung'; import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen'; import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung'; import Issues from './pages/Issues'; @@ -370,6 +371,14 @@ function App() { } /> + + + + } + /> > = { 'Verwaltung': ['manage_templates'], 'Widget': ['widget'], }, + buchhaltung: { + 'Ansicht': ['view', 'widget'], + 'Transaktionen': ['create', 'edit', 'delete', 'export'], + 'Verwaltung': ['manage_accounts', 'manage_settings'], + }, admin: { 'Allgemein': ['view', 'write'], 'Services': ['view_services', 'edit_services'], diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 5b772a0..c612d38 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -30,6 +30,7 @@ import { BookOnline, Forum, AssignmentTurnedIn, + AccountBalance as AccountBalanceIcon, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; @@ -143,6 +144,17 @@ const baseNavigationItems: NavigationItem[] = [ path: '/checklisten', permission: 'checklisten:view', }, + { + text: 'Buchhaltung', + icon: , + path: '/buchhaltung', + subItems: [ + { text: 'Übersicht', path: '/buchhaltung?tab=0' }, + { text: 'Transaktionen', path: '/buchhaltung?tab=1' }, + { text: 'Konten', path: '/buchhaltung?tab=2' }, + ], + permission: 'buchhaltung:view', + }, { text: 'Issues', icon: , diff --git a/frontend/src/pages/Buchhaltung.tsx b/frontend/src/pages/Buchhaltung.tsx new file mode 100644 index 0000000..e06d323 --- /dev/null +++ b/frontend/src/pages/Buchhaltung.tsx @@ -0,0 +1,846 @@ +import { useState, useEffect } from 'react'; +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Fab, + FormControl, + IconButton, + InputLabel, + LinearProgress, + MenuItem, + Paper, + Select, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { + Add as AddIcon, + BookmarkAdd, + Cancel, + Delete, + Edit, + Lock, +} from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useLocation, useNavigate } from 'react-router-dom'; +import MainLayout from '../components/shared/MainLayout'; +import { buchhaltungApi } from '../services/buchhaltung'; +import { useNotification } from '../contexts/NotificationContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import type { + Haushaltsjahr, HaushaltsjahrFormData, + Bankkonto, BankkontoFormData, + Konto, KontoFormData, + Transaktion, TransaktionFormData, TransaktionFilters, + TransaktionStatus, +} from '../types/buchhaltung.types'; +import { + TRANSAKTION_STATUS_LABELS, + TRANSAKTION_STATUS_COLORS, + TRANSAKTION_TYP_LABELS, +} from '../types/buchhaltung.types'; + +// ─── helpers ─────────────────────────────────────────────────────────────────── + +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'); +} + +// ─── Sub-components ──────────────────────────────────────────────────────────── + +function HaushaltsjahrDialog({ + open, + onClose, + existing, + onSave, +}: { + open: boolean; + onClose: () => void; + existing?: Haushaltsjahr; + onSave: (data: HaushaltsjahrFormData) => void; +}) { + const year = new Date().getFullYear(); + const [form, setForm] = useState({ + jahr: year, + bezeichnung: `Haushaltsjahr ${year}`, + beginn: `${year}-01-01`, + ende: `${year}-12-31`, + }); + + useEffect(() => { + if (existing) { + setForm({ jahr: existing.jahr, bezeichnung: existing.bezeichnung, beginn: existing.beginn.slice(0, 10), ende: existing.ende.slice(0, 10) }); + } else { + setForm({ jahr: year, bezeichnung: `Haushaltsjahr ${year}`, beginn: `${year}-01-01`, ende: `${year}-12-31` }); + } + }, [existing, open, year]); + + return ( + + {existing ? 'Haushaltsjahr bearbeiten' : 'Neues Haushaltsjahr'} + + + setForm(f => ({ ...f, jahr: parseInt(e.target.value, 10) }))} /> + setForm(f => ({ ...f, bezeichnung: e.target.value }))} /> + setForm(f => ({ ...f, beginn: e.target.value }))} InputLabelProps={{ shrink: true }} /> + setForm(f => ({ ...f, ende: e.target.value }))} InputLabelProps={{ shrink: true }} /> + + + + + + + + ); +} + +function BankkontoDialog({ + open, + onClose, + existing, + onSave, +}: { + open: boolean; + onClose: () => void; + existing?: Bankkonto; + onSave: (data: BankkontoFormData) => void; +}) { + const empty: BankkontoFormData = { bezeichnung: '', iban: '', bic: '', institut: '', ist_standard: false }; + const [form, setForm] = useState(empty); + + useEffect(() => { + if (existing) { + setForm({ bezeichnung: existing.bezeichnung, iban: existing.iban || '', bic: existing.bic || '', institut: existing.institut || '', ist_standard: existing.ist_standard }); + } else { + setForm(empty); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existing, open]); + + return ( + + {existing ? 'Bankkonto bearbeiten' : 'Neues Bankkonto'} + + + setForm(f => ({ ...f, bezeichnung: e.target.value }))} required /> + setForm(f => ({ ...f, iban: e.target.value }))} /> + setForm(f => ({ ...f, bic: e.target.value }))} /> + setForm(f => ({ ...f, institut: e.target.value }))} /> + + + + + + + + ); +} + +function KontoDialog({ + open, + onClose, + haushaltsjahrId, + existing, + onSave, +}: { + open: boolean; + onClose: () => void; + haushaltsjahrId: number; + existing?: Konto; + onSave: (data: KontoFormData) => void; +}) { + const { data: kontoTypen = [] } = useQuery({ queryKey: ['konto-typen'], queryFn: buchhaltungApi.getKontoTypen }); + const empty: KontoFormData = { haushaltsjahr_id: haushaltsjahrId, kontonummer: '', bezeichnung: '', budget_betrag: 0, notizen: '' }; + const [form, setForm] = useState(empty); + + useEffect(() => { + if (existing) { + setForm({ haushaltsjahr_id: haushaltsjahrId, konto_typ_id: existing.konto_typ_id ?? undefined, kontonummer: existing.kontonummer, bezeichnung: existing.bezeichnung, budget_betrag: existing.budget_betrag, notizen: existing.notizen || '' }); + } else { + setForm({ ...empty, haushaltsjahr_id: haushaltsjahrId }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existing, open, haushaltsjahrId]); + + return ( + + {existing ? 'Konto bearbeiten' : 'Neues Konto'} + + + setForm(f => ({ ...f, kontonummer: e.target.value }))} required /> + setForm(f => ({ ...f, bezeichnung: e.target.value }))} required /> + + Kontotyp + + + setForm(f => ({ ...f, budget_betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01' }} /> + setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} /> + + + + + + + + ); +} + +function TransaktionDialog({ + open, + onClose, + haushaltsjahre, + selectedJahrId, + onSave, +}: { + open: boolean; + onClose: () => void; + haushaltsjahre: Haushaltsjahr[]; + selectedJahrId: number | null; + onSave: (data: TransaktionFormData) => void; +}) { + const today = new Date().toISOString().slice(0, 10); + const [form, setForm] = useState({ + haushaltsjahr_id: selectedJahrId || 0, + typ: 'ausgabe', + betrag: 0, + datum: today, + konto_id: null, + bankkonto_id: null, + beschreibung: '', + empfaenger_auftraggeber: '', + verwendungszweck: '', + beleg_nr: '', + }); + + const { data: konten = [] } = useQuery({ + queryKey: ['buchhaltung-konten', form.haushaltsjahr_id], + queryFn: () => buchhaltungApi.getKonten(form.haushaltsjahr_id), + enabled: form.haushaltsjahr_id > 0, + }); + const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten }); + + useEffect(() => { + if (open) setForm(f => ({ ...f, haushaltsjahr_id: selectedJahrId || 0, datum: today })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return ( + + Neue Transaktion + + + + Haushaltsjahr + + + + Typ + + + setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required /> + setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required /> + + Konto + + + + Bankkonto + + + setForm(f => ({ ...f, beschreibung: e.target.value }))} /> + setForm(f => ({ ...f, empfaenger_auftraggeber: e.target.value }))} /> + setForm(f => ({ ...f, verwendungszweck: e.target.value }))} /> + setForm(f => ({ ...f, beleg_nr: e.target.value }))} /> + + + + + + + + ); +} + +// ─── Tab 0: Übersicht ───────────────────────────────────────────────────────── + +function UebersichtTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { + haushaltsjahre: Haushaltsjahr[]; + selectedJahrId: number | null; + onJahrChange: (id: number) => void; +}) { + const { data: stats, isLoading } = useQuery({ + queryKey: ['buchhaltung-stats', selectedJahrId], + queryFn: () => buchhaltungApi.getStats(selectedJahrId!), + enabled: selectedJahrId != null, + }); + + return ( + + + + Haushaltsjahr + + + + + {isLoading && } + {stats && ( + <> + + + + Einnahmen + {fmtEur(stats.total_einnahmen)} + + + + + Ausgaben + {fmtEur(stats.total_ausgaben)} + + + + + Saldo + = 0 ? 'success.main' : 'error.main'}>{fmtEur(stats.saldo)} + + + + + Konten + + {stats.konten_budget.map(k => ( + + + {k.kontonummer} – {k.bezeichnung} + {k.konto_typ_bezeichnung || '–'} + + Gebucht: {fmtEur(k.gebucht_betrag)} + Budget: {fmtEur(k.budget_betrag)} + + = 90 ? 'error' : k.auslastung_prozent >= 75 ? 'warning' : 'primary'} + sx={{ height: 8, borderRadius: 4 }} + /> + = 90 ? 'error' : 'text.secondary'}> + {k.auslastung_prozent}% · Verfügbar: {fmtEur(k.verfuegbar_betrag)} + + + + ))} + + + )} + {!selectedJahrId && !isLoading && ( + Bitte ein Haushaltsjahr auswählen + )} + + ); +} + +// ─── Tab 1: Transaktionen ───────────────────────────────────────────────────── + +function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { + haushaltsjahre: Haushaltsjahr[]; + selectedJahrId: number | null; + onJahrChange: (id: number) => void; +}) { + const qc = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + const [filters, setFilters] = useState({ haushaltsjahr_id: selectedJahrId || undefined }); + const [createOpen, setCreateOpen] = useState(false); + + const { data: transaktionen = [], isLoading } = useQuery({ + queryKey: ['buchhaltung-transaktionen', filters], + queryFn: () => buchhaltungApi.getTransaktionen(filters), + }); + + const createMut = useMutation({ + mutationFn: buchhaltungApi.createTransaktion, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); setCreateOpen(false); showSuccess('Transaktion erstellt'); }, + onError: () => showError('Transaktion konnte nicht erstellt werden'), + }); + + const buchenMut = useMutation({ + mutationFn: (id: number) => buchhaltungApi.buchenTransaktion(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); showSuccess('Transaktion gebucht'); }, + onError: () => showError('Buchung fehlgeschlagen'), + }); + + const stornoMut = useMutation({ + mutationFn: (id: number) => buchhaltungApi.stornoTransaktion(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); qc.invalidateQueries({ queryKey: ['buchhaltung-stats'] }); showSuccess('Transaktion storniert'); }, + onError: () => showError('Storno fehlgeschlagen'), + }); + + const deleteMut = useMutation({ + mutationFn: (id: number) => buchhaltungApi.deleteTransaktion(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-transaktionen'] }); showSuccess('Transaktion gelöscht'); }, + onError: () => showError('Löschen fehlgeschlagen'), + }); + + useEffect(() => { + setFilters(f => ({ ...f, haushaltsjahr_id: selectedJahrId || undefined })); + }, [selectedJahrId]); + + return ( + + {/* Filters */} + + + Haushaltsjahr + + + + Status + + + + Typ + + + setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} /> + + + {isLoading ? : ( + + + + + Nr. + Datum + Typ + Beschreibung + Konto + Betrag + Status + Aktionen + + + + {transaktionen.length === 0 && ( + Keine Transaktionen + )} + {transaktionen.map((t: Transaktion) => ( + + {t.laufende_nummer ?? `E${t.id}`} + {fmtDate(t.datum)} + + + + {t.beschreibung || t.empfaenger_auftraggeber || '–'} + {t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung}` : '–'} + + {t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)} + + + + + + + {t.status === 'entwurf' && hasPermission('buchhaltung:edit') && ( + + buchenMut.mutate(t.id)}> + + + + )} + {(t.status === 'gebucht' || t.status === 'freigegeben') && hasPermission('buchhaltung:edit') && ( + + stornoMut.mutate(t.id)}> + + + + )} + {t.status === 'entwurf' && hasPermission('buchhaltung:delete') && ( + + deleteMut.mutate(t.id)}> + + + + )} + + + + ))} + +
+
+ )} + + {hasPermission('buchhaltung:create') && ( + setCreateOpen(true)}> + + + )} + + setCreateOpen(false)} + haushaltsjahre={haushaltsjahre} + selectedJahrId={selectedJahrId} + onSave={data => createMut.mutate(data)} + /> +
+ ); +} + +// ─── Tab 2: Konten ──────────────────────────────────────────────────────────── + +function KontenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: { + haushaltsjahre: Haushaltsjahr[]; + selectedJahrId: number | null; + onJahrChange: (id: number) => void; +}) { + const qc = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + const [subTab, setSubTab] = useState(0); + const [kontoDialog, setKontoDialog] = useState<{ open: boolean; existing?: Konto }>({ open: false }); + const [bankDialog, setBankDialog] = useState<{ open: boolean; existing?: Bankkonto }>({ open: false }); + const [jahrDialog, setJahrDialog] = useState<{ open: boolean; existing?: Haushaltsjahr }>({ open: false }); + + const { data: konten = [] } = useQuery({ + queryKey: ['buchhaltung-konten', selectedJahrId], + queryFn: () => buchhaltungApi.getKonten(selectedJahrId!), + enabled: selectedJahrId != null, + }); + const { data: bankkonten = [] } = useQuery({ queryKey: ['bankkonten'], queryFn: buchhaltungApi.getBankkonten }); + + const canManage = hasPermission('buchhaltung:manage_accounts'); + + const createKontoMut = useMutation({ + mutationFn: buchhaltungApi.createKonto, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); setKontoDialog({ open: false }); showSuccess('Konto erstellt'); }, + onError: () => showError('Konto konnte nicht erstellt werden'), + }); + const updateKontoMut = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateKonto(id, data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); setKontoDialog({ open: false }); showSuccess('Konto aktualisiert'); }, + onError: () => showError('Konto konnte nicht aktualisiert werden'), + }); + const deleteKontoMut = useMutation({ + mutationFn: buchhaltungApi.deleteKonto, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['buchhaltung-konten'] }); showSuccess('Konto deaktiviert'); }, + onError: () => showError('Löschen fehlgeschlagen'), + }); + + const createBankMut = useMutation({ + mutationFn: buchhaltungApi.createBankkonto, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['bankkonten'] }); setBankDialog({ open: false }); showSuccess('Bankkonto erstellt'); }, + onError: () => showError('Bankkonto konnte nicht erstellt werden'), + }); + const updateBankMut = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateBankkonto(id, data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['bankkonten'] }); setBankDialog({ open: false }); showSuccess('Bankkonto aktualisiert'); }, + onError: () => showError('Bankkonto konnte nicht aktualisiert werden'), + }); + const deleteBankMut = useMutation({ + mutationFn: buchhaltungApi.deleteBankkonto, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['bankkonten'] }); showSuccess('Bankkonto deaktiviert'); }, + onError: () => showError('Löschen fehlgeschlagen'), + }); + + const createJahrMut = useMutation({ + mutationFn: buchhaltungApi.createHaushaltsjahr, + onSuccess: (hj) => { qc.invalidateQueries({ queryKey: ['haushaltsjahre'] }); setJahrDialog({ open: false }); onJahrChange(hj.id); showSuccess('Haushaltsjahr erstellt'); }, + onError: () => showError('Haushaltsjahr konnte nicht erstellt werden'), + }); + const updateJahrMut = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => buchhaltungApi.updateHaushaltsjahr(id, data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['haushaltsjahre'] }); setJahrDialog({ open: false }); showSuccess('Haushaltsjahr aktualisiert'); }, + onError: () => showError('Aktualisierung fehlgeschlagen'), + }); + const closeJahrMut = useMutation({ + mutationFn: (id: number) => buchhaltungApi.closeHaushaltsjahr(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['haushaltsjahre'] }); showSuccess('Haushaltsjahr abgeschlossen'); }, + onError: (e: Error) => showError(e.message || 'Abschluss fehlgeschlagen'), + }); + + return ( + + setSubTab(v)} sx={{ mb: 2 }}> + + + + + + {/* Sub-Tab 0: Konten */} + {subTab === 0 && ( + + + + Haushaltsjahr + + + {canManage && } + + + + + + Kontonummer + Bezeichnung + Typ + Budget + {canManage && Aktionen} + + + + {konten.length === 0 && Keine Konten} + {konten.map((k: Konto) => ( + + {k.kontonummer} + {k.bezeichnung} + {k.konto_typ_bezeichnung || '–'} + {fmtEur(k.budget_betrag)} + {canManage && ( + + setKontoDialog({ open: true, existing: k })}> + deleteKontoMut.mutate(k.id)}> + + )} + + ))} + +
+
+ {selectedJahrId && setKontoDialog({ open: false })} + haushaltsjahrId={selectedJahrId} + existing={kontoDialog.existing} + onSave={data => kontoDialog.existing + ? updateKontoMut.mutate({ id: kontoDialog.existing.id, data }) + : createKontoMut.mutate(data) + } + />} +
+ )} + + {/* Sub-Tab 1: Bankkonten */} + {subTab === 1 && ( + + + {canManage && } + + + + + + Bezeichnung + IBAN + Institut + Standard + {canManage && Aktionen} + + + + {bankkonten.length === 0 && Keine Bankkonten} + {bankkonten.map((bk: Bankkonto) => ( + + {bk.bezeichnung} + {bk.iban || '–'} + {bk.institut || '–'} + {bk.ist_standard ? : '–'} + {canManage && ( + + setBankDialog({ open: true, existing: bk })}> + deleteBankMut.mutate(bk.id)}> + + )} + + ))} + +
+
+ setBankDialog({ open: false })} + existing={bankDialog.existing} + onSave={data => bankDialog.existing + ? updateBankMut.mutate({ id: bankDialog.existing.id, data }) + : createBankMut.mutate(data) + } + /> +
+ )} + + {/* Sub-Tab 2: Haushaltsjahre */} + {subTab === 2 && ( + + + {canManage && } + + + + + + Jahr + Bezeichnung + Beginn + Ende + Status + {canManage && Aktionen} + + + + {haushaltsjahre.length === 0 && Keine Haushaltsjahre} + {haushaltsjahre.map((hj: Haushaltsjahr) => ( + + {hj.jahr} + {hj.bezeichnung} + {fmtDate(hj.beginn)} + {fmtDate(hj.ende)} + {hj.abgeschlossen ? : } + {canManage && ( + + setJahrDialog({ open: true, existing: hj })}> + {!hj.abgeschlossen && ( + + closeJahrMut.mutate(hj.id)}> + + )} + + )} + + ))} + +
+
+ setJahrDialog({ open: false })} + existing={jahrDialog.existing} + onSave={data => jahrDialog.existing + ? updateJahrMut.mutate({ id: jahrDialog.existing.id, data }) + : createJahrMut.mutate(data) + } + /> +
+ )} +
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function Buchhaltung() { + const location = useLocation(); + const navigate = useNavigate(); + const searchParams = new URLSearchParams(location.search); + const tabFromUrl = parseInt(searchParams.get('tab') || '0', 10); + const [tab, setTab] = useState(isNaN(tabFromUrl) ? 0 : tabFromUrl); + const [selectedJahrId, setSelectedJahrId] = useState(null); + + const { data: haushaltsjahre = [] } = useQuery({ + queryKey: ['haushaltsjahre'], + queryFn: buchhaltungApi.getHaushaltsjahre, + onSuccess: (data: Haushaltsjahr[]) => { + if (data.length > 0 && !selectedJahrId) { + const active = data.find(hj => !hj.abgeschlossen) || data[0]; + setSelectedJahrId(active.id); + } + }, + }); + + const handleTabChange = (_: React.SyntheticEvent, newVal: number) => { + setTab(newVal); + navigate(`/buchhaltung?tab=${newVal}`, { replace: true }); + }; + + return ( + + + + Buchhaltung + + + + + + + + + {tab === 0 && ( + + )} + {tab === 1 && ( + + )} + {tab === 2 && ( + + )} + + + ); +} diff --git a/frontend/src/services/buchhaltung.ts b/frontend/src/services/buchhaltung.ts new file mode 100644 index 0000000..f313441 --- /dev/null +++ b/frontend/src/services/buchhaltung.ts @@ -0,0 +1,129 @@ +import { api } from './api'; +import type { + Haushaltsjahr, HaushaltsjahrFormData, + Bankkonto, BankkontoFormData, + Konto, KontoFormData, KontoBudgetInfo, + KontoTyp, + Transaktion, TransaktionFormData, TransaktionFilters, + Beleg, + BuchhaltungStats, +} from '../types/buchhaltung.types'; + +export const buchhaltungApi = { + + // ── Haushaltsjahre ────────────────────────────────────────────────────────── + getHaushaltsjahre: async (): Promise => { + const r = await api.get('/api/buchhaltung/haushaltsjahre'); + return r.data.data; + }, + createHaushaltsjahr: async (data: HaushaltsjahrFormData): Promise => { + const r = await api.post('/api/buchhaltung/haushaltsjahre', data); + return r.data.data; + }, + updateHaushaltsjahr: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/buchhaltung/haushaltsjahre/${id}`, data); + return r.data.data; + }, + closeHaushaltsjahr: async (id: number): Promise => { + const r = await api.post(`/api/buchhaltung/haushaltsjahre/${id}/close`); + return r.data.data; + }, + + // ── Konto-Typen ───────────────────────────────────────────────────────────── + getKontoTypen: async (): Promise => { + const r = await api.get('/api/buchhaltung/konto-typen'); + return r.data.data; + }, + + // ── Bankkonten ─────────────────────────────────────────────────────────────── + getBankkonten: async (): Promise => { + const r = await api.get('/api/buchhaltung/bankkonten'); + return r.data.data; + }, + createBankkonto: async (data: BankkontoFormData): Promise => { + const r = await api.post('/api/buchhaltung/bankkonten', data); + return r.data.data; + }, + updateBankkonto: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/buchhaltung/bankkonten/${id}`, data); + return r.data.data; + }, + deleteBankkonto: async (id: number): Promise => { + await api.delete(`/api/buchhaltung/bankkonten/${id}`); + }, + + // ── Konten ─────────────────────────────────────────────────────────────────── + getKonten: async (haushaltsjahrId: number): Promise => { + const r = await api.get(`/api/buchhaltung/konten?haushaltsjahr_id=${haushaltsjahrId}`); + return r.data.data; + }, + createKonto: async (data: KontoFormData): Promise => { + const r = await api.post('/api/buchhaltung/konten', data); + return r.data.data; + }, + updateKonto: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/buchhaltung/konten/${id}`, data); + return r.data.data; + }, + deleteKonto: async (id: number): Promise => { + await api.delete(`/api/buchhaltung/konten/${id}`); + }, + getKontoBudget: async (id: number): Promise => { + const r = await api.get(`/api/buchhaltung/konten/${id}/budget`); + return r.data.data; + }, + + // ── Stats ──────────────────────────────────────────────────────────────────── + getStats: async (haushaltsjahrId: number): Promise => { + const r = await api.get(`/api/buchhaltung/stats?haushaltsjahr_id=${haushaltsjahrId}`); + return r.data.data; + }, + + // ── Transaktionen ───────────────────────────────────────────────────────────── + getTransaktionen: async (filters?: TransaktionFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.haushaltsjahr_id) params.set('haushaltsjahr_id', String(filters.haushaltsjahr_id)); + if (filters?.konto_id) params.set('konto_id', String(filters.konto_id)); + if (filters?.status) params.set('status', filters.status); + if (filters?.typ) params.set('typ', filters.typ); + if (filters?.datum_von) params.set('datum_von', filters.datum_von); + if (filters?.datum_bis) params.set('datum_bis', filters.datum_bis); + if (filters?.search) params.set('search', filters.search); + const r = await api.get(`/api/buchhaltung?${params.toString()}`); + return r.data.data; + }, + getTransaktion: async (id: number): Promise => { + const r = await api.get(`/api/buchhaltung/${id}`); + return r.data.data; + }, + createTransaktion: async (data: TransaktionFormData): Promise => { + const r = await api.post('/api/buchhaltung', data); + return r.data.data; + }, + updateTransaktion: async (id: number, data: Partial): Promise => { + const r = await api.patch(`/api/buchhaltung/${id}`, data); + return r.data.data; + }, + deleteTransaktion: async (id: number): Promise => { + await api.delete(`/api/buchhaltung/${id}`); + }, + buchenTransaktion: async (id: number): Promise => { + const r = await api.post(`/api/buchhaltung/${id}/buchen`); + return r.data.data; + }, + stornoTransaktion: async (id: number): Promise => { + const r = await api.post(`/api/buchhaltung/${id}/storno`); + return r.data.data; + }, + uploadBeleg: async (transaktionId: number, file: File): Promise => { + const formData = new FormData(); + formData.append('datei', file); + const r = await api.post(`/api/buchhaltung/${transaktionId}/belege`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return r.data.data; + }, + deleteBeleg: async (id: number): Promise => { + await api.delete(`/api/buchhaltung/belege/${id}`); + }, +}; diff --git a/frontend/src/types/buchhaltung.types.ts b/frontend/src/types/buchhaltung.types.ts new file mode 100644 index 0000000..2f42b84 --- /dev/null +++ b/frontend/src/types/buchhaltung.types.ts @@ -0,0 +1,229 @@ +// Lookup types +export type KontoArt = 'einnahme' | 'ausgabe' | 'vermoegen' | 'verbindlichkeit'; +export type TransaktionTyp = 'einnahme' | 'ausgabe'; +export type TransaktionStatus = 'entwurf' | 'gebucht' | 'freigegeben' | 'storniert'; +export type FreigabeStatus = 'ausstehend' | 'genehmigt' | 'abgelehnt'; +export type WiederkehrendIntervall = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich'; +export type PlanungStatus = 'entwurf' | 'aktiv' | 'abgeschlossen'; + +// Label maps +export const TRANSAKTION_STATUS_LABELS: Record = { + entwurf: 'Entwurf', + gebucht: 'Gebucht', + freigegeben: 'Freigegeben', + storniert: 'Storniert', +}; + +export const TRANSAKTION_STATUS_COLORS: Record = { + entwurf: 'default', + gebucht: 'warning', + freigegeben: 'success', + storniert: 'error', +}; + +export const TRANSAKTION_TYP_LABELS: Record = { + einnahme: 'Einnahme', + ausgabe: 'Ausgabe', +}; + +export const KONTO_ART_LABELS: Record = { + einnahme: 'Einnahmen', + ausgabe: 'Ausgaben', + vermoegen: 'Vermögen', + verbindlichkeit: 'Verbindlichkeiten', +}; + +export const INTERVALL_LABELS: Record = { + monatlich: 'Monatlich', + quartalsweise: 'Quartalsweise', + halbjaehrlich: 'Halbjährlich', + jaehrlich: 'Jährlich', +}; + +// Entities +export interface KontoTyp { + id: number; + bezeichnung: string; + art: KontoArt; + sort_order: number; +} + +export interface Bankkonto { + id: number; + bezeichnung: string; + iban: string | null; + bic: string | null; + institut: string | null; + ist_standard: boolean; + aktiv: boolean; + erstellt_von: string | null; + erstellt_am: string; + aktualisiert_am: string; +} + +export interface Haushaltsjahr { + id: number; + jahr: number; + bezeichnung: string; + beginn: string; + ende: string; + abgeschlossen: boolean; + erstellt_von: string | null; + erstellt_am: string; + aktualisiert_am: string; +} + +export interface Konto { + id: number; + haushaltsjahr_id: number; + konto_typ_id: number | null; + kontonummer: string; + bezeichnung: string; + budget_betrag: number; + notizen: string | null; + aktiv: boolean; + erstellt_von: string | null; + erstellt_am: string; + aktualisiert_am: string; + // Joined fields + konto_typ_bezeichnung?: string; + konto_typ_art?: KontoArt; +} + +export interface KontoBudgetInfo extends Konto { + gebucht_betrag: number; + ausstehend_betrag: number; + verfuegbar_betrag: number; + auslastung_prozent: number; +} + +export interface Transaktion { + id: number; + haushaltsjahr_id: number; + konto_id: number | null; + bankkonto_id: number | null; + laufende_nummer: number | null; + typ: TransaktionTyp; + betrag: number; + datum: string; + buchungsdatum: string | null; + beschreibung: string | null; + empfaenger_auftraggeber: string | null; + verwendungszweck: string | null; + beleg_nr: string | null; + status: TransaktionStatus; + bestellung_id: number | null; + erstellt_von: string | null; + gebucht_von: string | null; + erstellt_am: string; + aktualisiert_am: string; + // Joined fields + konto_bezeichnung?: string; + konto_kontonummer?: string; + bankkonto_bezeichnung?: string; + belege?: Beleg[]; +} + +export interface Beleg { + id: number; + transaktion_id: number; + dateiname: string; + original_name: string; + dateityp: string; + dateigroesse: number; + erstellt_von: string | null; + erstellt_am: string; +} + +export interface Freigabe { + id: number; + transaktion_id: number; + status: FreigabeStatus; + kommentar: string | null; + freigegeben_von: string | null; + freigegeben_am: string | null; + erstellt_am: string; +} + +export interface WiederkehrendBuchung { + id: number; + bezeichnung: string; + konto_id: number | null; + bankkonto_id: number | null; + typ: TransaktionTyp; + betrag: number; + beschreibung: string | null; + empfaenger_auftraggeber: string | null; + intervall: WiederkehrendIntervall; + naechste_ausfuehrung: string; + aktiv: boolean; + erstellt_von: string | null; + erstellt_am: string; + aktualisiert_am: string; +} + +export interface BuchhaltungAudit { + id: number; + transaktion_id: number | null; + aktion: string; + details: Record | null; + erstellt_von: string | null; + erstellt_am: string; +} + +export interface BuchhaltungStats { + haushaltsjahr_id: number; + total_einnahmen: number; + total_ausgaben: number; + saldo: number; + konten_budget: KontoBudgetInfo[]; +} + +// Form data types +export interface HaushaltsjahrFormData { + jahr: number; + bezeichnung: string; + beginn: string; + ende: string; +} + +export interface BankkontoFormData { + bezeichnung: string; + iban?: string; + bic?: string; + institut?: string; + ist_standard?: boolean; +} + +export interface KontoFormData { + haushaltsjahr_id: number; + konto_typ_id?: number; + kontonummer: string; + bezeichnung: string; + budget_betrag?: number; + notizen?: string; +} + +export interface TransaktionFormData { + haushaltsjahr_id: number; + konto_id?: number | null; + bankkonto_id?: number | null; + typ: TransaktionTyp; + betrag: number; + datum: string; + beschreibung?: string; + empfaenger_auftraggeber?: string; + verwendungszweck?: string; + beleg_nr?: string; +} + +// Filter type for transaction list +export interface TransaktionFilters { + haushaltsjahr_id?: number; + konto_id?: number; + status?: TransaktionStatus; + typ?: TransaktionTyp; + datum_von?: string; + datum_bis?: string; + search?: string; +}