feat: add Buchhaltung module with fiscal years, budget accounts, transactions, and approval workflow
This commit is contained in:
@@ -107,6 +107,7 @@ import buchungskategorieRoutes from './routes/buchungskategorie.routes';
|
|||||||
import checklistRoutes from './routes/checklist.routes';
|
import checklistRoutes from './routes/checklist.routes';
|
||||||
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
||||||
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
|
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
|
||||||
|
import buchhaltungRoutes from './routes/buchhaltung.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -136,6 +137,7 @@ app.use('/api/buchungskategorien', buchungskategorieRoutes);
|
|||||||
app.use('/api/checklisten', checklistRoutes);
|
app.use('/api/checklisten', checklistRoutes);
|
||||||
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
||||||
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
||||||
|
app.use('/api/buchhaltung', buchhaltungRoutes);
|
||||||
|
|
||||||
// Static file serving for uploads (authenticated)
|
// Static file serving for uploads (authenticated)
|
||||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||||
|
|||||||
339
backend/src/controllers/buchhaltung.controller.ts
Normal file
339
backend/src/controllers/buchhaltung.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
await buchhaltungService.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<void> {
|
||||||
|
const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10);
|
||||||
|
if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
await buchhaltungService.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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10);
|
||||||
|
if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
const id = parseInt(param(req, 'id'), 10);
|
||||||
|
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
|
||||||
|
try {
|
||||||
|
const data = await buchhaltungService.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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
231
backend/src/database/migrations/075_buchhaltung_schema.sql
Normal file
231
backend/src/database/migrations/075_buchhaltung_schema.sql
Normal file
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -132,4 +132,42 @@ const issueOptions: any = {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const uploadIssue: any = multer(issueOptions);
|
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 };
|
||||||
|
|||||||
53
backend/src/routes/buchhaltung.routes.ts
Normal file
53
backend/src/routes/buchhaltung.routes.ts
Normal file
@@ -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;
|
||||||
857
backend/src/services/buchhaltung.service.ts
Normal file
857
backend/src/services/buchhaltung.service.ts
Normal file
@@ -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<string, unknown> = {};
|
||||||
|
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<string, unknown>) {
|
||||||
|
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;
|
||||||
@@ -41,6 +41,13 @@ const DEFAULT_PERMISSION_DEPS: Record<string, string[]> = {
|
|||||||
'wissen:widget_recent': ['wissen:view'],
|
'wissen:widget_recent': ['wissen:view'],
|
||||||
'wissen:widget_search': ['wissen:view'],
|
'wissen:widget_search': ['wissen:view'],
|
||||||
'admin:write': ['admin: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 {
|
export interface FeatureGroupRow {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestel
|
|||||||
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
|
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
|
||||||
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
||||||
import Checklisten from './pages/Checklisten';
|
import Checklisten from './pages/Checklisten';
|
||||||
|
import Buchhaltung from './pages/Buchhaltung';
|
||||||
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
|
import FahrzeugEinstellungen from './pages/FahrzeugEinstellungen';
|
||||||
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
|
import ChecklistAusfuehrung from './pages/ChecklistAusfuehrung';
|
||||||
import Issues from './pages/Issues';
|
import Issues from './pages/Issues';
|
||||||
@@ -370,6 +371,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/buchhaltung"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Buchhaltung />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/checklisten/ausfuehrung/:id"
|
path="/checklisten/ausfuehrung/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
|
|||||||
'Verwaltung': ['manage_templates'],
|
'Verwaltung': ['manage_templates'],
|
||||||
'Widget': ['widget'],
|
'Widget': ['widget'],
|
||||||
},
|
},
|
||||||
|
buchhaltung: {
|
||||||
|
'Ansicht': ['view', 'widget'],
|
||||||
|
'Transaktionen': ['create', 'edit', 'delete', 'export'],
|
||||||
|
'Verwaltung': ['manage_accounts', 'manage_settings'],
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
'Allgemein': ['view', 'write'],
|
'Allgemein': ['view', 'write'],
|
||||||
'Services': ['view_services', 'edit_services'],
|
'Services': ['view_services', 'edit_services'],
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
BookOnline,
|
BookOnline,
|
||||||
Forum,
|
Forum,
|
||||||
AssignmentTurnedIn,
|
AssignmentTurnedIn,
|
||||||
|
AccountBalance as AccountBalanceIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -143,6 +144,17 @@ const baseNavigationItems: NavigationItem[] = [
|
|||||||
path: '/checklisten',
|
path: '/checklisten',
|
||||||
permission: 'checklisten:view',
|
permission: 'checklisten:view',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Buchhaltung',
|
||||||
|
icon: <AccountBalanceIcon />,
|
||||||
|
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',
|
text: 'Issues',
|
||||||
icon: <BugReport />,
|
icon: <BugReport />,
|
||||||
|
|||||||
846
frontend/src/pages/Buchhaltung.tsx
Normal file
846
frontend/src/pages/Buchhaltung.tsx
Normal file
@@ -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<HaushaltsjahrFormData>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{existing ? 'Haushaltsjahr bearbeiten' : 'Neues Haushaltsjahr'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField label="Jahr" type="number" value={form.jahr} onChange={e => setForm(f => ({ ...f, jahr: parseInt(e.target.value, 10) }))} />
|
||||||
|
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} />
|
||||||
|
<TextField label="Beginn" type="date" value={form.beginn} onChange={e => setForm(f => ({ ...f, beginn: e.target.value }))} InputLabelProps={{ shrink: true }} />
|
||||||
|
<TextField label="Ende" type="date" value={form.ende} onChange={e => setForm(f => ({ ...f, ende: e.target.value }))} InputLabelProps={{ shrink: true }} />
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={() => onSave(form)}>Speichern</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<BankkontoFormData>(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 (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{existing ? 'Bankkonto bearbeiten' : 'Neues Bankkonto'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
|
||||||
|
<TextField label="IBAN" value={form.iban} onChange={e => setForm(f => ({ ...f, iban: e.target.value }))} />
|
||||||
|
<TextField label="BIC" value={form.bic} onChange={e => setForm(f => ({ ...f, bic: e.target.value }))} />
|
||||||
|
<TextField label="Institut" value={form.institut} onChange={e => setForm(f => ({ ...f, institut: e.target.value }))} />
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={() => onSave(form)}>Speichern</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<KontoFormData>(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 (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{existing ? 'Konto bearbeiten' : 'Neues Konto'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField label="Kontonummer" value={form.kontonummer} onChange={e => setForm(f => ({ ...f, kontonummer: e.target.value }))} required />
|
||||||
|
<TextField label="Bezeichnung" value={form.bezeichnung} onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} required />
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Kontotyp</InputLabel>
|
||||||
|
<Select value={form.konto_typ_id ?? ''} label="Kontotyp" onChange={e => setForm(f => ({ ...f, konto_typ_id: e.target.value ? Number(e.target.value) : undefined }))}>
|
||||||
|
<MenuItem value=""><em>Kein Typ</em></MenuItem>
|
||||||
|
{kontoTypen.map(kt => <MenuItem key={kt.id} value={kt.id}>{kt.bezeichnung}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField label="Budget (€)" type="number" value={form.budget_betrag} onChange={e => setForm(f => ({ ...f, budget_betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01' }} />
|
||||||
|
<TextField label="Notizen" value={form.notizen} onChange={e => setForm(f => ({ ...f, notizen: e.target.value }))} multiline rows={2} />
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={() => onSave(form)}>Speichern</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TransaktionFormData>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Neue Transaktion</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel>Haushaltsjahr</InputLabel>
|
||||||
|
<Select value={form.haushaltsjahr_id || ''} label="Haushaltsjahr" onChange={e => setForm(f => ({ ...f, haushaltsjahr_id: Number(e.target.value), konto_id: null }))}>
|
||||||
|
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth required>
|
||||||
|
<InputLabel>Typ</InputLabel>
|
||||||
|
<Select value={form.typ} label="Typ" onChange={e => setForm(f => ({ ...f, typ: e.target.value as 'einnahme' | 'ausgabe' }))}>
|
||||||
|
<MenuItem value="ausgabe">Ausgabe</MenuItem>
|
||||||
|
<MenuItem value="einnahme">Einnahme</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField label="Betrag (€)" type="number" value={form.betrag} onChange={e => setForm(f => ({ ...f, betrag: parseFloat(e.target.value) || 0 }))} inputProps={{ step: '0.01', min: '0' }} required />
|
||||||
|
<TextField label="Datum" type="date" value={form.datum} onChange={e => setForm(f => ({ ...f, datum: e.target.value }))} InputLabelProps={{ shrink: true }} required />
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Konto</InputLabel>
|
||||||
|
<Select value={form.konto_id ?? ''} label="Konto" onChange={e => setForm(f => ({ ...f, konto_id: e.target.value ? Number(e.target.value) : null }))}>
|
||||||
|
<MenuItem value=""><em>Kein Konto</em></MenuItem>
|
||||||
|
{konten.map(k => <MenuItem key={k.id} value={k.id}>{k.kontonummer} – {k.bezeichnung}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Bankkonto</InputLabel>
|
||||||
|
<Select value={form.bankkonto_id ?? ''} label="Bankkonto" onChange={e => setForm(f => ({ ...f, bankkonto_id: e.target.value ? Number(e.target.value) : null }))}>
|
||||||
|
<MenuItem value=""><em>Kein Bankkonto</em></MenuItem>
|
||||||
|
{bankkonten.map(bk => <MenuItem key={bk.id} value={bk.id}>{bk.bezeichnung}{bk.iban ? ` (${bk.iban})` : ''}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField label="Beschreibung" value={form.beschreibung} onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))} />
|
||||||
|
<TextField label="Empfänger/Auftraggeber" value={form.empfaenger_auftraggeber} onChange={e => setForm(f => ({ ...f, empfaenger_auftraggeber: e.target.value }))} />
|
||||||
|
<TextField label="Verwendungszweck" value={form.verwendungszweck} onChange={e => setForm(f => ({ ...f, verwendungszweck: e.target.value }))} />
|
||||||
|
<TextField label="Belegnummer" value={form.beleg_nr} onChange={e => setForm(f => ({ ...f, beleg_nr: e.target.value }))} />
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={() => onSave(form)} disabled={!form.haushaltsjahr_id || !form.betrag || !form.datum}>Erstellen</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<FormControl sx={{ minWidth: 240 }}>
|
||||||
|
<InputLabel>Haushaltsjahr</InputLabel>
|
||||||
|
<Select value={selectedJahrId ?? ''} label="Haushaltsjahr" onChange={e => onJahrChange(Number(e.target.value))}>
|
||||||
|
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}{hj.abgeschlossen ? ' (abgeschlossen)' : ''}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isLoading && <CircularProgress />}
|
||||||
|
{stats && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mb: 3 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="text.secondary" variant="body2">Einnahmen</Typography>
|
||||||
|
<Typography variant="h5" color="success.main">{fmtEur(stats.total_einnahmen)}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="text.secondary" variant="body2">Ausgaben</Typography>
|
||||||
|
<Typography variant="h5" color="error.main">{fmtEur(stats.total_ausgaben)}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="text.secondary" variant="body2">Saldo</Typography>
|
||||||
|
<Typography variant="h5" color={stats.saldo >= 0 ? 'success.main' : 'error.main'}>{fmtEur(stats.saldo)}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h6" gutterBottom>Konten</Typography>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2 }}>
|
||||||
|
{stats.konten_budget.map(k => (
|
||||||
|
<Card key={k.id}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>{k.kontonummer} – {k.bezeichnung}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>{k.konto_typ_bezeichnung || '–'}</Typography>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="body2">Gebucht: {fmtEur(k.gebucht_betrag)}</Typography>
|
||||||
|
<Typography variant="body2">Budget: {fmtEur(k.budget_betrag)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={Math.min(k.auslastung_prozent, 100)}
|
||||||
|
color={k.auslastung_prozent >= 90 ? 'error' : k.auslastung_prozent >= 75 ? 'warning' : 'primary'}
|
||||||
|
sx={{ height: 8, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color={k.auslastung_prozent >= 90 ? 'error' : 'text.secondary'}>
|
||||||
|
{k.auslastung_prozent}% · Verfügbar: {fmtEur(k.verfuegbar_betrag)}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!selectedJahrId && !isLoading && (
|
||||||
|
<Typography color="text.secondary">Bitte ein Haushaltsjahr auswählen</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<TransaktionFilters>({ 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 (
|
||||||
|
<Box>
|
||||||
|
{/* Filters */}
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<FormControl sx={{ minWidth: 200 }}>
|
||||||
|
<InputLabel>Haushaltsjahr</InputLabel>
|
||||||
|
<Select size="small" value={filters.haushaltsjahr_id ?? ''} label="Haushaltsjahr"
|
||||||
|
onChange={e => { const v = Number(e.target.value); setFilters(f => ({ ...f, haushaltsjahr_id: v || undefined })); onJahrChange(v); }}>
|
||||||
|
<MenuItem value=""><em>Alle</em></MenuItem>
|
||||||
|
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl sx={{ minWidth: 140 }}>
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select size="small" value={filters.status ?? ''} label="Status"
|
||||||
|
onChange={e => setFilters(f => ({ ...f, status: (e.target.value as TransaktionStatus) || undefined }))}>
|
||||||
|
<MenuItem value=""><em>Alle</em></MenuItem>
|
||||||
|
{(Object.entries(TRANSAKTION_STATUS_LABELS) as [TransaktionStatus, string][]).map(([v, l]) => <MenuItem key={v} value={v}>{l}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl sx={{ minWidth: 130 }}>
|
||||||
|
<InputLabel>Typ</InputLabel>
|
||||||
|
<Select size="small" value={filters.typ ?? ''} label="Typ"
|
||||||
|
onChange={e => setFilters(f => ({ ...f, typ: (e.target.value as 'einnahme' | 'ausgabe') || undefined }))}>
|
||||||
|
<MenuItem value=""><em>Alle</em></MenuItem>
|
||||||
|
<MenuItem value="einnahme">Einnahme</MenuItem>
|
||||||
|
<MenuItem value="ausgabe">Ausgabe</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField size="small" label="Suche" value={filters.search ?? ''}
|
||||||
|
onChange={e => setFilters(f => ({ ...f, search: e.target.value || undefined }))} sx={{ minWidth: 180 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isLoading ? <CircularProgress /> : (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Nr.</TableCell>
|
||||||
|
<TableCell>Datum</TableCell>
|
||||||
|
<TableCell>Typ</TableCell>
|
||||||
|
<TableCell>Beschreibung</TableCell>
|
||||||
|
<TableCell>Konto</TableCell>
|
||||||
|
<TableCell align="right">Betrag</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Aktionen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{transaktionen.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={8} align="center"><Typography color="text.secondary">Keine Transaktionen</Typography></TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{transaktionen.map((t: Transaktion) => (
|
||||||
|
<TableRow key={t.id} hover>
|
||||||
|
<TableCell>{t.laufende_nummer ?? `E${t.id}`}</TableCell>
|
||||||
|
<TableCell>{fmtDate(t.datum)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={TRANSAKTION_TYP_LABELS[t.typ]} size="small" color={t.typ === 'einnahme' ? 'success' : 'error'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{t.beschreibung || t.empfaenger_auftraggeber || '–'}</TableCell>
|
||||||
|
<TableCell>{t.konto_kontonummer ? `${t.konto_kontonummer} ${t.konto_bezeichnung}` : '–'}</TableCell>
|
||||||
|
<TableCell align="right" sx={{ color: t.typ === 'einnahme' ? 'success.main' : 'error.main', fontWeight: 600 }}>
|
||||||
|
{t.typ === 'ausgabe' ? '-' : '+'}{fmtEur(t.betrag)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={TRANSAKTION_STATUS_LABELS[t.status]} size="small" color={TRANSAKTION_STATUS_COLORS[t.status]} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
{t.status === 'entwurf' && hasPermission('buchhaltung:edit') && (
|
||||||
|
<Tooltip title="Buchen">
|
||||||
|
<IconButton size="small" color="primary" onClick={() => buchenMut.mutate(t.id)}>
|
||||||
|
<BookmarkAdd fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{(t.status === 'gebucht' || t.status === 'freigegeben') && hasPermission('buchhaltung:edit') && (
|
||||||
|
<Tooltip title="Stornieren">
|
||||||
|
<IconButton size="small" color="warning" onClick={() => stornoMut.mutate(t.id)}>
|
||||||
|
<Cancel fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{t.status === 'entwurf' && hasPermission('buchhaltung:delete') && (
|
||||||
|
<Tooltip title="Löschen">
|
||||||
|
<IconButton size="small" color="error" onClick={() => deleteMut.mutate(t.id)}>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPermission('buchhaltung:create') && (
|
||||||
|
<Fab color="primary" sx={{ position: 'fixed', bottom: 32, right: 80 }} onClick={() => setCreateOpen(true)}>
|
||||||
|
<AddIcon />
|
||||||
|
</Fab>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TransaktionDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
haushaltsjahre={haushaltsjahre}
|
||||||
|
selectedJahrId={selectedJahrId}
|
||||||
|
onSave={data => createMut.mutate(data)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<KontoFormData> }) => 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<BankkontoFormData> }) => 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<HaushaltsjahrFormData> }) => 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 (
|
||||||
|
<Box>
|
||||||
|
<Tabs value={subTab} onChange={(_, v) => setSubTab(v)} sx={{ mb: 2 }}>
|
||||||
|
<Tab label="Konten" />
|
||||||
|
<Tab label="Bankkonten" />
|
||||||
|
<Tab label="Haushaltsjahre" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Sub-Tab 0: Konten */}
|
||||||
|
{subTab === 0 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<FormControl sx={{ minWidth: 240 }}>
|
||||||
|
<InputLabel>Haushaltsjahr</InputLabel>
|
||||||
|
<Select size="small" value={selectedJahrId ?? ''} label="Haushaltsjahr" onChange={e => onJahrChange(Number(e.target.value))}>
|
||||||
|
{haushaltsjahre.map(hj => <MenuItem key={hj.id} value={hj.id}>{hj.bezeichnung}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setKontoDialog({ open: true })}>Konto anlegen</Button>}
|
||||||
|
</Box>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Kontonummer</TableCell>
|
||||||
|
<TableCell>Bezeichnung</TableCell>
|
||||||
|
<TableCell>Typ</TableCell>
|
||||||
|
<TableCell align="right">Budget</TableCell>
|
||||||
|
{canManage && <TableCell>Aktionen</TableCell>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{konten.length === 0 && <TableRow><TableCell colSpan={5} align="center"><Typography color="text.secondary">Keine Konten</Typography></TableCell></TableRow>}
|
||||||
|
{konten.map((k: Konto) => (
|
||||||
|
<TableRow key={k.id} hover>
|
||||||
|
<TableCell>{k.kontonummer}</TableCell>
|
||||||
|
<TableCell>{k.bezeichnung}</TableCell>
|
||||||
|
<TableCell>{k.konto_typ_bezeichnung || '–'}</TableCell>
|
||||||
|
<TableCell align="right">{fmtEur(k.budget_betrag)}</TableCell>
|
||||||
|
{canManage && (
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => setKontoDialog({ open: true, existing: k })}><Edit fontSize="small" /></IconButton>
|
||||||
|
<IconButton size="small" color="error" onClick={() => deleteKontoMut.mutate(k.id)}><Delete fontSize="small" /></IconButton>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
{selectedJahrId && <KontoDialog
|
||||||
|
open={kontoDialog.open}
|
||||||
|
onClose={() => setKontoDialog({ open: false })}
|
||||||
|
haushaltsjahrId={selectedJahrId}
|
||||||
|
existing={kontoDialog.existing}
|
||||||
|
onSave={data => kontoDialog.existing
|
||||||
|
? updateKontoMut.mutate({ id: kontoDialog.existing.id, data })
|
||||||
|
: createKontoMut.mutate(data)
|
||||||
|
}
|
||||||
|
/>}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sub-Tab 1: Bankkonten */}
|
||||||
|
{subTab === 1 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setBankDialog({ open: true })}>Bankkonto anlegen</Button>}
|
||||||
|
</Box>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Bezeichnung</TableCell>
|
||||||
|
<TableCell>IBAN</TableCell>
|
||||||
|
<TableCell>Institut</TableCell>
|
||||||
|
<TableCell>Standard</TableCell>
|
||||||
|
{canManage && <TableCell>Aktionen</TableCell>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{bankkonten.length === 0 && <TableRow><TableCell colSpan={5} align="center"><Typography color="text.secondary">Keine Bankkonten</Typography></TableCell></TableRow>}
|
||||||
|
{bankkonten.map((bk: Bankkonto) => (
|
||||||
|
<TableRow key={bk.id} hover>
|
||||||
|
<TableCell>{bk.bezeichnung}</TableCell>
|
||||||
|
<TableCell>{bk.iban || '–'}</TableCell>
|
||||||
|
<TableCell>{bk.institut || '–'}</TableCell>
|
||||||
|
<TableCell>{bk.ist_standard ? <Chip label="Standard" size="small" color="primary" /> : '–'}</TableCell>
|
||||||
|
{canManage && (
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => setBankDialog({ open: true, existing: bk })}><Edit fontSize="small" /></IconButton>
|
||||||
|
<IconButton size="small" color="error" onClick={() => deleteBankMut.mutate(bk.id)}><Delete fontSize="small" /></IconButton>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<BankkontoDialog
|
||||||
|
open={bankDialog.open}
|
||||||
|
onClose={() => setBankDialog({ open: false })}
|
||||||
|
existing={bankDialog.existing}
|
||||||
|
onSave={data => bankDialog.existing
|
||||||
|
? updateBankMut.mutate({ id: bankDialog.existing.id, data })
|
||||||
|
: createBankMut.mutate(data)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sub-Tab 2: Haushaltsjahre */}
|
||||||
|
{subTab === 2 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
{canManage && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setJahrDialog({ open: true })}>Haushaltsjahr anlegen</Button>}
|
||||||
|
</Box>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Jahr</TableCell>
|
||||||
|
<TableCell>Bezeichnung</TableCell>
|
||||||
|
<TableCell>Beginn</TableCell>
|
||||||
|
<TableCell>Ende</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
{canManage && <TableCell>Aktionen</TableCell>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{haushaltsjahre.length === 0 && <TableRow><TableCell colSpan={6} align="center"><Typography color="text.secondary">Keine Haushaltsjahre</Typography></TableCell></TableRow>}
|
||||||
|
{haushaltsjahre.map((hj: Haushaltsjahr) => (
|
||||||
|
<TableRow key={hj.id} hover>
|
||||||
|
<TableCell>{hj.jahr}</TableCell>
|
||||||
|
<TableCell>{hj.bezeichnung}</TableCell>
|
||||||
|
<TableCell>{fmtDate(hj.beginn)}</TableCell>
|
||||||
|
<TableCell>{fmtDate(hj.ende)}</TableCell>
|
||||||
|
<TableCell>{hj.abgeschlossen ? <Chip label="Abgeschlossen" size="small" color="default" /> : <Chip label="Aktiv" size="small" color="success" />}</TableCell>
|
||||||
|
{canManage && (
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => setJahrDialog({ open: true, existing: hj })}><Edit fontSize="small" /></IconButton>
|
||||||
|
{!hj.abgeschlossen && (
|
||||||
|
<Tooltip title="Abschließen">
|
||||||
|
<IconButton size="small" color="warning" onClick={() => closeJahrMut.mutate(hj.id)}><Lock fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<HaushaltsjahrDialog
|
||||||
|
open={jahrDialog.open}
|
||||||
|
onClose={() => setJahrDialog({ open: false })}
|
||||||
|
existing={jahrDialog.existing}
|
||||||
|
onSave={data => jahrDialog.existing
|
||||||
|
? updateJahrMut.mutate({ id: jahrDialog.existing.id, data })
|
||||||
|
: createJahrMut.mutate(data)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<number | null>(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 (
|
||||||
|
<MainLayout>
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2 }}>
|
||||||
|
<Typography variant="h4" fontWeight={700}>Buchhaltung</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 3 }}>
|
||||||
|
<Tab label="Übersicht" />
|
||||||
|
<Tab label="Transaktionen" />
|
||||||
|
<Tab label="Konten" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{tab === 0 && (
|
||||||
|
<UebersichtTab
|
||||||
|
haushaltsjahre={haushaltsjahre}
|
||||||
|
selectedJahrId={selectedJahrId}
|
||||||
|
onJahrChange={setSelectedJahrId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 1 && (
|
||||||
|
<TransaktionenTab
|
||||||
|
haushaltsjahre={haushaltsjahre}
|
||||||
|
selectedJahrId={selectedJahrId}
|
||||||
|
onJahrChange={setSelectedJahrId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 2 && (
|
||||||
|
<KontenTab
|
||||||
|
haushaltsjahre={haushaltsjahre}
|
||||||
|
selectedJahrId={selectedJahrId}
|
||||||
|
onJahrChange={setSelectedJahrId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/src/services/buchhaltung.ts
Normal file
129
frontend/src/services/buchhaltung.ts
Normal file
@@ -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<Haushaltsjahr[]> => {
|
||||||
|
const r = await api.get('/api/buchhaltung/haushaltsjahre');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createHaushaltsjahr: async (data: HaushaltsjahrFormData): Promise<Haushaltsjahr> => {
|
||||||
|
const r = await api.post('/api/buchhaltung/haushaltsjahre', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateHaushaltsjahr: async (id: number, data: Partial<HaushaltsjahrFormData>): Promise<Haushaltsjahr> => {
|
||||||
|
const r = await api.patch(`/api/buchhaltung/haushaltsjahre/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
closeHaushaltsjahr: async (id: number): Promise<Haushaltsjahr> => {
|
||||||
|
const r = await api.post(`/api/buchhaltung/haushaltsjahre/${id}/close`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Konto-Typen ─────────────────────────────────────────────────────────────
|
||||||
|
getKontoTypen: async (): Promise<KontoTyp[]> => {
|
||||||
|
const r = await api.get('/api/buchhaltung/konto-typen');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Bankkonten ───────────────────────────────────────────────────────────────
|
||||||
|
getBankkonten: async (): Promise<Bankkonto[]> => {
|
||||||
|
const r = await api.get('/api/buchhaltung/bankkonten');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createBankkonto: async (data: BankkontoFormData): Promise<Bankkonto> => {
|
||||||
|
const r = await api.post('/api/buchhaltung/bankkonten', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateBankkonto: async (id: number, data: Partial<BankkontoFormData>): Promise<Bankkonto> => {
|
||||||
|
const r = await api.patch(`/api/buchhaltung/bankkonten/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteBankkonto: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/buchhaltung/bankkonten/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Konten ───────────────────────────────────────────────────────────────────
|
||||||
|
getKonten: async (haushaltsjahrId: number): Promise<Konto[]> => {
|
||||||
|
const r = await api.get(`/api/buchhaltung/konten?haushaltsjahr_id=${haushaltsjahrId}`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createKonto: async (data: KontoFormData): Promise<Konto> => {
|
||||||
|
const r = await api.post('/api/buchhaltung/konten', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateKonto: async (id: number, data: Partial<KontoFormData>): Promise<Konto> => {
|
||||||
|
const r = await api.patch(`/api/buchhaltung/konten/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteKonto: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/buchhaltung/konten/${id}`);
|
||||||
|
},
|
||||||
|
getKontoBudget: async (id: number): Promise<KontoBudgetInfo> => {
|
||||||
|
const r = await api.get(`/api/buchhaltung/konten/${id}/budget`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||||
|
getStats: async (haushaltsjahrId: number): Promise<BuchhaltungStats> => {
|
||||||
|
const r = await api.get(`/api/buchhaltung/stats?haushaltsjahr_id=${haushaltsjahrId}`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Transaktionen ─────────────────────────────────────────────────────────────
|
||||||
|
getTransaktionen: async (filters?: TransaktionFilters): Promise<Transaktion[]> => {
|
||||||
|
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<Transaktion> => {
|
||||||
|
const r = await api.get(`/api/buchhaltung/${id}`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
createTransaktion: async (data: TransaktionFormData): Promise<Transaktion> => {
|
||||||
|
const r = await api.post('/api/buchhaltung', data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
updateTransaktion: async (id: number, data: Partial<TransaktionFormData>): Promise<Transaktion> => {
|
||||||
|
const r = await api.patch(`/api/buchhaltung/${id}`, data);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
deleteTransaktion: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/api/buchhaltung/${id}`);
|
||||||
|
},
|
||||||
|
buchenTransaktion: async (id: number): Promise<Transaktion> => {
|
||||||
|
const r = await api.post(`/api/buchhaltung/${id}/buchen`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
stornoTransaktion: async (id: number): Promise<Transaktion> => {
|
||||||
|
const r = await api.post(`/api/buchhaltung/${id}/storno`);
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
uploadBeleg: async (transaktionId: number, file: File): Promise<Beleg> => {
|
||||||
|
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<void> => {
|
||||||
|
await api.delete(`/api/buchhaltung/belege/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
229
frontend/src/types/buchhaltung.types.ts
Normal file
229
frontend/src/types/buchhaltung.types.ts
Normal file
@@ -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<TransaktionStatus, string> = {
|
||||||
|
entwurf: 'Entwurf',
|
||||||
|
gebucht: 'Gebucht',
|
||||||
|
freigegeben: 'Freigegeben',
|
||||||
|
storniert: 'Storniert',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRANSAKTION_STATUS_COLORS: Record<TransaktionStatus, 'default' | 'warning' | 'success' | 'error'> = {
|
||||||
|
entwurf: 'default',
|
||||||
|
gebucht: 'warning',
|
||||||
|
freigegeben: 'success',
|
||||||
|
storniert: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRANSAKTION_TYP_LABELS: Record<TransaktionTyp, string> = {
|
||||||
|
einnahme: 'Einnahme',
|
||||||
|
ausgabe: 'Ausgabe',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KONTO_ART_LABELS: Record<KontoArt, string> = {
|
||||||
|
einnahme: 'Einnahmen',
|
||||||
|
ausgabe: 'Ausgaben',
|
||||||
|
vermoegen: 'Vermögen',
|
||||||
|
verbindlichkeit: 'Verbindlichkeiten',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INTERVALL_LABELS: Record<WiederkehrendIntervall, string> = {
|
||||||
|
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<string, unknown> | 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user