diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 29ef020..59db8ec 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -120,6 +120,7 @@ class AuthController { }); await userService.updateGroups(user.id, groups); + logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length }); await memberService.ensureProfileExists(user.id); // Audit: first-ever login (user record creation) @@ -168,6 +169,7 @@ class AuthController { await userService.updateLastLogin(user.id); await userService.updateGroups(user.id, groups); + logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length }); await memberService.ensureProfileExists(user.id); const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo); diff --git a/backend/src/controllers/bestellung.controller.ts b/backend/src/controllers/bestellung.controller.ts index ec54c81..4f29646 100644 --- a/backend/src/controllers/bestellung.controller.ts +++ b/backend/src/controllers/bestellung.controller.ts @@ -113,9 +113,9 @@ class BestellungController { } async createOrder(req: Request, res: Response): Promise { - const { titel } = req.body; - if (!titel || typeof titel !== 'string' || titel.trim().length === 0) { - res.status(400).json({ success: false, message: 'Titel ist erforderlich' }); + const { bezeichnung } = req.body; + if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) { + res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' }); return; } try { @@ -203,9 +203,9 @@ class BestellungController { res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' }); return; } - const { artikel, menge } = req.body; - if (!artikel || typeof artikel !== 'string' || artikel.trim().length === 0) { - res.status(400).json({ success: false, message: 'Artikel ist erforderlich' }); + const { bezeichnung, menge } = req.body; + if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) { + res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' }); return; } if (menge === undefined || menge === null || menge <= 0) { @@ -364,11 +364,7 @@ class BestellungController { res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' }); return; } - const { titel, faellig_am } = req.body; - if (!titel || typeof titel !== 'string' || titel.trim().length === 0) { - res.status(400).json({ success: false, message: 'Titel ist erforderlich' }); - return; - } + const { nachricht, faellig_am } = req.body; if (!faellig_am) { res.status(400).json({ success: false, message: 'Fälligkeitsdatum ist erforderlich' }); return; diff --git a/backend/src/controllers/booking.controller.ts b/backend/src/controllers/booking.controller.ts index 0fbc6e8..3eb9ceb 100644 --- a/backend/src/controllers/booking.controller.ts +++ b/backend/src/controllers/booking.controller.ts @@ -158,7 +158,7 @@ class BookingController { handleZodError(res, parsed.error); return; } - const booking = await bookingService.create(parsed.data, req.user!.id); + const booking = await bookingService.create(parsed.data, req.user!.id, req.body.ignoreOutOfService === true); res.status(201).json({ success: true, data: booking }); } catch (error: any) { if (handleConflictError(res, error)) return; diff --git a/backend/src/controllers/equipment.controller.ts b/backend/src/controllers/equipment.controller.ts index 2364f03..a36835e 100644 --- a/backend/src/controllers/equipment.controller.ts +++ b/backend/src/controllers/equipment.controller.ts @@ -391,6 +391,36 @@ class EquipmentController { res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' }); } } + + async getStatusHistory(req: Request, res: Response): Promise { + try { + const history = await equipmentService.getStatusHistory(req.params.id); + res.status(200).json({ success: true, data: history }); + } catch (error) { + logger.error('getStatusHistory error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Status-Historie konnte nicht geladen werden' }); + } + } + + async uploadWartungFile(req: Request, res: Response): Promise { + const wartungId = parseInt(req.params.wartungId, 10); + if (isNaN(wartungId)) { + res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' }); + return; + } + const file = (req as any).file; + if (!file) { + res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' }); + return; + } + try { + const result = await equipmentService.updateWartungslogFile(wartungId, file.path); + res.status(200).json({ success: true, data: result }); + } catch (error) { + logger.error('uploadWartungFile error', { error, wartungId }); + res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' }); + } + } } export default new EquipmentController(); diff --git a/backend/src/controllers/events.controller.ts b/backend/src/controllers/events.controller.ts index 9aec201..b86ef08 100644 --- a/backend/src/controllers/events.controller.ts +++ b/backend/src/controllers/events.controller.ts @@ -304,7 +304,12 @@ class EventsController { deleteEvent = async (req: Request, res: Response): Promise => { try { const { id } = req.params as Record; - const deleted = await eventsService.deleteEvent(id); + const mode = (req.body?.mode as string) || 'all'; + if (!['all', 'single', 'future'].includes(mode)) { + res.status(400).json({ success: false, message: 'Ungültiger Löschmodus. Erlaubt: all, single, future' }); + return; + } + const deleted = await eventsService.deleteEvent(id, mode as 'all' | 'single' | 'future'); if (!deleted) { res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' }); return; diff --git a/backend/src/controllers/vehicle.controller.ts b/backend/src/controllers/vehicle.controller.ts index 8a00fb7..de5e95e 100644 --- a/backend/src/controllers/vehicle.controller.ts +++ b/backend/src/controllers/vehicle.controller.ts @@ -372,6 +372,36 @@ class VehicleController { res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' }); } } + + async getStatusHistory(req: Request, res: Response): Promise { + try { + const history = await vehicleService.getStatusHistory(req.params.id); + res.status(200).json({ success: true, data: history }); + } catch (error) { + logger.error('getStatusHistory error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Status-Historie konnte nicht geladen werden' }); + } + } + + async uploadWartungFile(req: Request, res: Response): Promise { + const wartungId = parseInt(req.params.wartungId, 10); + if (isNaN(wartungId)) { + res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' }); + return; + } + const file = (req as any).file; + if (!file) { + res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' }); + return; + } + try { + const result = await vehicleService.updateWartungslogFile(wartungId, file.path); + res.status(200).json({ success: true, data: result }); + } catch (error) { + logger.error('uploadWartungFile error', { error, wartungId }); + res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' }); + } + } } export default new VehicleController(); diff --git a/backend/src/database/migrations/041_status_history_and_features.sql b/backend/src/database/migrations/041_status_history_and_features.sql new file mode 100644 index 0000000..fa161f6 --- /dev/null +++ b/backend/src/database/migrations/041_status_history_and_features.sql @@ -0,0 +1,42 @@ +-- Migration 041: Status history tables, booking ganztaegig flag +-- Adds vehicle/equipment status change tracking and whole-day booking support. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. Vehicle Status Change History +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS fahrzeug_status_historie ( + id SERIAL PRIMARY KEY, + fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE, + alter_status TEXT NOT NULL, + neuer_status TEXT NOT NULL, + bemerkung TEXT, + geaendert_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_fahrzeug_status_historie_fahrzeug ON fahrzeug_status_historie(fahrzeug_id); +CREATE INDEX IF NOT EXISTS idx_fahrzeug_status_historie_zeit ON fahrzeug_status_historie(erstellt_am DESC); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Equipment Status Change History +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS ausruestung_status_historie ( + id SERIAL PRIMARY KEY, + ausruestung_id INT NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE, + alter_status TEXT NOT NULL, + neuer_status TEXT NOT NULL, + bemerkung TEXT, + geaendert_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ausruestung_status_historie_ausr ON ausruestung_status_historie(ausruestung_id); +CREATE INDEX IF NOT EXISTS idx_ausruestung_status_historie_zeit ON ausruestung_status_historie(erstellt_am DESC); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Whole-day booking flag +-- ═══════════════════════════════════════════════════════════════════════════ + +ALTER TABLE fahrzeug_buchungen ADD COLUMN IF NOT EXISTS ganztaegig BOOLEAN DEFAULT FALSE; diff --git a/backend/src/middleware/upload.ts b/backend/src/middleware/upload.ts index 27e3bb1..0c0c6ac 100644 --- a/backend/src/middleware/upload.ts +++ b/backend/src/middleware/upload.ts @@ -56,4 +56,39 @@ const multerOptions: any = { // eslint-disable-next-line @typescript-eslint/no-explicit-any export const uploadBestellung: any = multer(multerOptions); -export { UPLOAD_DIR, THUMBNAIL_DIR }; +// ── Wartungslog uploads (vehicle/equipment service reports) ────────────────── + +const WARTUNG_DIR = path.join(APP_ROOT, 'uploads', 'wartung'); +try { + if (!fs.existsSync(WARTUNG_DIR)) { + fs.mkdirSync(WARTUNG_DIR, { recursive: true }); + } +} catch (err) { + logger.warn(`Could not create wartung upload directory`, { err }); +} + +const wartungStorage = multer.diskStorage({ + destination(_req: any, _file: any, cb: any) { + cb(null, WARTUNG_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 +export const uploadWartung: any = multer({ + storage: wartungStorage, + 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 }, +}); + +export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR }; diff --git a/backend/src/models/booking.model.ts b/backend/src/models/booking.model.ts index fe9f1af..ee94f91 100644 --- a/backend/src/models/booking.model.ts +++ b/backend/src/models/booking.model.ts @@ -66,6 +66,7 @@ const BuchungBaseSchema = z.object({ buchungsArt: z.enum(BUCHUNGS_ARTEN).default('intern'), kontaktPerson: z.string().max(255).optional().nullable(), kontaktTelefon: z.string().max(50).optional().nullable(), + ganztaegig: z.boolean().optional().default(false), }); export const CreateBuchungSchema = BuchungBaseSchema.refine( diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index fc6b05d..3405db1 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -1,8 +1,9 @@ /** - * Admin API Routes — Audit Log + * Admin API Routes — Audit Log + Data Cleanup * * GET /api/admin/audit-log — paginated, filtered list * GET /api/admin/audit-log/export — CSV download of filtered results + * DELETE /api/admin/cleanup/:target — preview / delete old data * * Both endpoints require authentication + admin:access permission. * @@ -18,6 +19,7 @@ import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; import { auditExport } from '../middleware/audit.middleware'; import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service'; +import cleanupService from '../services/cleanup.service'; import logger from '../utils/logger'; const router = Router(); @@ -213,4 +215,53 @@ router.post( } ); +// --------------------------------------------------------------------------- +// Cleanup / Data Management endpoints +// --------------------------------------------------------------------------- + +const cleanupBodySchema = z.object({ + olderThanDays: z.number().int().min(1).max(3650), + confirm: z.boolean().optional().default(false), +}); + +type CleanupTarget = 'notifications' | 'audit-log' | 'events' | 'bookings' | 'orders' | 'vehicle-history' | 'equipment-history'; + +const CLEANUP_TARGETS: Record Promise<{ count: number; deleted: boolean }>> = { + 'notifications': (d, c) => cleanupService.cleanupNotifications(d, c), + 'audit-log': (d, c) => cleanupService.cleanupAuditLog(d, c), + 'events': (d, c) => cleanupService.cleanupEvents(d, c), + 'bookings': (d, c) => cleanupService.cleanupBookings(d, c), + 'orders': (d, c) => cleanupService.cleanupOrders(d, c), + 'vehicle-history': (d, c) => cleanupService.cleanupVehicleHistory(d, c), + 'equipment-history': (d, c) => cleanupService.cleanupEquipmentHistory(d, c), +}; + +router.delete( + '/cleanup/:target', + authenticate, + requirePermission('admin:write'), + async (req: Request, res: Response): Promise => { + try { + const target = req.params.target as CleanupTarget; + const handler = CLEANUP_TARGETS[target]; + if (!handler) { + res.status(400).json({ success: false, message: `Unknown cleanup target: ${target}` }); + return; + } + + const { olderThanDays, confirm } = cleanupBodySchema.parse(req.body); + const result = await handler(olderThanDays, confirm); + + res.json({ success: true, data: result }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ success: false, message: 'Invalid parameters', errors: error.issues }); + return; + } + logger.error('Cleanup failed', { error, target: req.params.target }); + res.status(500).json({ success: false, message: 'Cleanup failed' }); + } + } +); + export default router; diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts index 922319b..8e08fcb 100644 --- a/backend/src/routes/equipment.routes.ts +++ b/backend/src/routes/equipment.routes.ts @@ -2,18 +2,20 @@ import { Router } from 'express'; import equipmentController from '../controllers/equipment.controller'; import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; +import { uploadWartung } from '../middleware/upload'; const router = Router(); // ── Read-only (any authenticated user) ─────────────────────────────────────── -router.get('/', authenticate, equipmentController.listEquipment.bind(equipmentController)); -router.get('/stats', authenticate, equipmentController.getStats.bind(equipmentController)); -router.get('/alerts', authenticate, equipmentController.getAlerts.bind(equipmentController)); -router.get('/categories', authenticate, equipmentController.getCategories.bind(equipmentController)); +router.get('/', authenticate, requirePermission('ausruestung:view'), equipmentController.listEquipment.bind(equipmentController)); +router.get('/stats', authenticate, requirePermission('ausruestung:view'), equipmentController.getStats.bind(equipmentController)); +router.get('/alerts', authenticate, requirePermission('ausruestung:view'), equipmentController.getAlerts.bind(equipmentController)); +router.get('/categories', authenticate, requirePermission('ausruestung:view'), equipmentController.getCategories.bind(equipmentController)); router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleWarnings.bind(equipmentController)); router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController)); router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController)); +router.get('/:id/status-history', authenticate, equipmentController.getStatusHistory.bind(equipmentController)); // ── Write — gruppenfuehrer+ ──────────────────────────────────────────────── @@ -21,6 +23,7 @@ router.post('/', authenticate, requirePermission('ausruestung:create') router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController)); router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController)); router.post('/:id/wartung', authenticate, requirePermission('ausruestung:create'), equipmentController.addWartung.bind(equipmentController)); +router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:create'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController)); // ── Delete — admin only ────────────────────────────────────────────────────── diff --git a/backend/src/routes/vehicle.routes.ts b/backend/src/routes/vehicle.routes.ts index b7211b1..b75bf84 100644 --- a/backend/src/routes/vehicle.routes.ts +++ b/backend/src/routes/vehicle.routes.ts @@ -2,17 +2,19 @@ import { Router } from 'express'; import vehicleController from '../controllers/vehicle.controller'; import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; +import { uploadWartung } from '../middleware/upload'; const router = Router(); // ── Read-only (any authenticated user) ─────────────────────────────────────── -router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController)); -router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController)); -router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController)); +router.get('/', authenticate, requirePermission('fahrzeuge:view'), vehicleController.listVehicles.bind(vehicleController)); +router.get('/stats', authenticate, requirePermission('fahrzeuge:view'), vehicleController.getStats.bind(vehicleController)); +router.get('/alerts', authenticate, requirePermission('fahrzeuge:view'), vehicleController.getAlerts.bind(vehicleController)); router.get('/alerts/export', authenticate, requirePermission('fahrzeuge:view'), vehicleController.exportAlerts.bind(vehicleController)); router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController)); router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); +router.get('/:id/status-history', authenticate, vehicleController.getStatusHistory.bind(vehicleController)); // ── Write — kommandant+ ────────────────────────────────────────────────────── @@ -24,5 +26,6 @@ router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehic router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController)); router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.addWartung.bind(vehicleController)); +router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:change_status'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController)); export default router; diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index aa8faed..d50bf7d 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -35,13 +35,13 @@ async function getVendorById(id: number) { } } -async function createVendor(data: { name: string; kontakt_person?: string; email?: string; telefon?: string; adresse?: string; notizen?: string }, userId: string) { +async function createVendor(data: { name: string; kontakt_name?: string; email?: string; telefon?: string; adresse?: string; website?: string; notizen?: string }, userId: string) { try { const result = await pool.query( - `INSERT INTO lieferanten (name, kontakt_person, email, telefon, adresse, notizen, erstellt_von) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO lieferanten (name, kontakt_name, email, telefon, adresse, website, notizen, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, - [data.name, data.kontakt_person || null, data.email || null, data.telefon || null, data.adresse || null, data.notizen || null, userId] + [data.name, data.kontakt_name || null, data.email || null, data.telefon || null, data.adresse || null, data.website || null, data.notizen || null, userId] ); return result.rows[0]; } catch (error) { @@ -50,20 +50,21 @@ async function createVendor(data: { name: string; kontakt_person?: string; email } } -async function updateVendor(id: number, data: { name?: string; kontakt_person?: string; email?: string; telefon?: string; adresse?: string; notizen?: string }, userId: string) { +async function updateVendor(id: number, data: { name?: string; kontakt_name?: string; email?: string; telefon?: string; adresse?: string; website?: string; notizen?: string }, userId: string) { try { const result = await pool.query( `UPDATE lieferanten SET name = COALESCE($1, name), - kontakt_person = COALESCE($2, kontakt_person), + kontakt_name = COALESCE($2, kontakt_name), email = COALESCE($3, email), telefon = COALESCE($4, telefon), adresse = COALESCE($5, adresse), - notizen = COALESCE($6, notizen), + website = COALESCE($6, website), + notizen = COALESCE($7, notizen), aktualisiert_am = NOW() - WHERE id = $7 + WHERE id = $8 RETURNING *`, - [data.name, data.kontakt_person, data.email, data.telefon, data.adresse, data.notizen, id] + [data.name, data.kontakt_name, data.email, data.telefon, data.adresse, data.website, data.notizen, id] ); if (result.rows.length === 0) return null; @@ -157,7 +158,7 @@ async function getOrderById(id: number) { pool.query(`SELECT * FROM bestellpositionen WHERE bestellung_id = $1 ORDER BY id`, [id]), pool.query(`SELECT * FROM bestellung_dateien WHERE bestellung_id = $1 ORDER BY hochgeladen_am DESC`, [id]), pool.query(`SELECT * FROM bestellung_erinnerungen WHERE bestellung_id = $1 ORDER BY faellig_am`, [id]), - pool.query(`SELECT h.*, u.display_name AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.benutzer_id WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]), + pool.query(`SELECT h.*, u.display_name AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]), ]); return { @@ -173,16 +174,16 @@ async function getOrderById(id: number) { } } -async function createOrder(data: { titel: string; lieferant_id?: number; beschreibung?: string; prioritaet?: string }, userId: string) { +async function createOrder(data: { bezeichnung: string; lieferant_id?: number; notizen?: string; budget?: number }, userId: string) { try { const result = await pool.query( - `INSERT INTO bestellungen (titel, lieferant_id, beschreibung, prioritaet, erstellt_von) + `INSERT INTO bestellungen (bezeichnung, lieferant_id, notizen, budget, erstellt_von) VALUES ($1, $2, $3, $4, $5) RETURNING *`, - [data.titel, data.lieferant_id || null, data.beschreibung || null, data.prioritaet || 'normal', userId] + [data.bezeichnung, data.lieferant_id || null, data.notizen || null, data.budget || null, userId] ); const order = result.rows[0]; - await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.titel}" erstellt`, userId); + await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId); return order; } catch (error) { logger.error('BestellungService.createOrder failed', { error }); @@ -190,7 +191,7 @@ async function createOrder(data: { titel: string; lieferant_id?: number; beschre } } -async function updateOrder(id: number, data: { titel?: string; lieferant_id?: number; beschreibung?: string; prioritaet?: string; status?: string }, userId: string) { +async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_id?: number; notizen?: string; budget?: number; status?: string }, userId: string) { try { // Check current order for status change detection const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]); @@ -213,25 +214,25 @@ async function updateOrder(id: number, data: { titel?: string; lieferant_id?: nu const result = await pool.query( `UPDATE bestellungen - SET titel = COALESCE($1, titel), + SET bezeichnung = COALESCE($1, bezeichnung), lieferant_id = COALESCE($2, lieferant_id), - beschreibung = COALESCE($3, beschreibung), - prioritaet = COALESCE($4, prioritaet), + notizen = COALESCE($3, notizen), + budget = COALESCE($4, budget), status = COALESCE($5, status), bestellt_am = $6, abgeschlossen_am = $7, aktualisiert_am = NOW() WHERE id = $8 RETURNING *`, - [data.titel, data.lieferant_id, data.beschreibung, data.prioritaet, data.status, bestellt_am, abgeschlossen_am, id] + [data.bezeichnung, data.lieferant_id, data.notizen, data.budget, data.status, bestellt_am, abgeschlossen_am, id] ); if (result.rows.length === 0) return null; const changes: string[] = []; - if (data.titel) changes.push(`Titel geändert`); + if (data.bezeichnung) changes.push(`Bezeichnung geändert`); if (data.lieferant_id) changes.push(`Lieferant geändert`); if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus} → ${data.status}`); - if (data.prioritaet) changes.push(`Priorität geändert`); + if (data.budget) changes.push(`Budget geändert`); await logAction(id, 'Bestellung aktualisiert', changes.join(', ') || 'Bestellung bearbeitet', userId); return result.rows[0]; @@ -275,12 +276,12 @@ async function deleteOrder(id: number, _userId: string) { } const VALID_STATUS_TRANSITIONS: Record = { - entwurf: ['bestellt', 'storniert'], - bestellt: ['teillieferung', 'vollstaendig', 'storniert'], - teillieferung: ['vollstaendig', 'storniert'], + entwurf: ['erstellt', 'bestellt'], + erstellt: ['bestellt'], + bestellt: ['teillieferung', 'vollstaendig'], + teillieferung: ['vollstaendig'], vollstaendig: ['abgeschlossen'], abgeschlossen: [], - storniert: ['entwurf'], }; async function updateOrderStatus(id: number, status: string, userId: string) { @@ -323,15 +324,15 @@ async function updateOrderStatus(id: number, status: string, userId: string) { // Line Items (Bestellpositionen) // --------------------------------------------------------------------------- -async function addLineItem(bestellungId: number, data: { artikel: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) { +async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) { try { const result = await pool.query( - `INSERT INTO bestellpositionen (bestellung_id, artikel, menge, einheit, einzelpreis, notizen) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, - [bestellungId, data.artikel, data.menge, data.einheit || 'Stück', data.einzelpreis || 0, data.notizen || null] + [bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null] ); - await logAction(bestellungId, 'Position hinzugefügt', `"${data.artikel}" x${data.menge}`, userId); + await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId); return result.rows[0]; } catch (error) { logger.error('BestellungService.addLineItem failed', { error, bestellungId }); @@ -339,23 +340,24 @@ async function addLineItem(bestellungId: number, data: { artikel: string; menge: } } -async function updateLineItem(id: number, data: { artikel?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) { +async function updateLineItem(id: number, data: { bezeichnung?: string; artikelnummer?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) { try { const result = await pool.query( `UPDATE bestellpositionen - SET artikel = COALESCE($1, artikel), - menge = COALESCE($2, menge), - einheit = COALESCE($3, einheit), - einzelpreis = COALESCE($4, einzelpreis), - notizen = COALESCE($5, notizen) - WHERE id = $6 + SET bezeichnung = COALESCE($1, bezeichnung), + artikelnummer = COALESCE($2, artikelnummer), + menge = COALESCE($3, menge), + einheit = COALESCE($4, einheit), + einzelpreis = COALESCE($5, einzelpreis), + notizen = COALESCE($6, notizen) + WHERE id = $7 RETURNING *`, - [data.artikel, data.menge, data.einheit, data.einzelpreis, data.notizen, id] + [data.bezeichnung, data.artikelnummer, data.menge, data.einheit, data.einzelpreis, data.notizen, id] ); if (result.rows.length === 0) return null; const item = result.rows[0]; - await logAction(item.bestellung_id, 'Position aktualisiert', `"${item.artikel}" bearbeitet`, userId); + await logAction(item.bestellung_id, 'Position aktualisiert', `"${item.bezeichnung}" bearbeitet`, userId); return item; } catch (error) { logger.error('BestellungService.updateLineItem failed', { error, id }); @@ -369,7 +371,7 @@ async function deleteLineItem(id: number, userId: string) { if (item.rows.length === 0) return false; await pool.query(`DELETE FROM bestellpositionen WHERE id = $1`, [id]); - await logAction(item.rows[0].bestellung_id, 'Position entfernt', `"${item.rows[0].artikel}" entfernt`, userId); + await logAction(item.rows[0].bestellung_id, 'Position entfernt', `"${item.rows[0].bezeichnung}" entfernt`, userId); return true; } catch (error) { logger.error('BestellungService.deleteLineItem failed', { error, id }); @@ -386,7 +388,7 @@ async function updateReceivedQuantity(id: number, menge: number, userId: string) if (result.rows.length === 0) return null; const item = result.rows[0]; - await logAction(item.bestellung_id, 'Liefermenge aktualisiert', `"${item.artikel}": ${menge} von ${item.menge} erhalten`, userId); + await logAction(item.bestellung_id, 'Liefermenge aktualisiert', `"${item.bezeichnung}": ${menge} von ${item.menge} erhalten`, userId); // Check if all items for this order are fully received const allItems = await pool.query( @@ -477,15 +479,15 @@ async function getFilesByOrder(bestellungId: number) { // Reminders (Bestellung Erinnerungen) // --------------------------------------------------------------------------- -async function addReminder(bestellungId: number, data: { titel: string; faellig_am: string; notizen?: string }, userId: string) { +async function addReminder(bestellungId: number, data: { nachricht: string; faellig_am: string }, userId: string) { try { const result = await pool.query( - `INSERT INTO bestellung_erinnerungen (bestellung_id, titel, faellig_am, notizen, erstellt_von) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO bestellung_erinnerungen (bestellung_id, faellig_am, nachricht, erstellt_von) + VALUES ($1, $2, $3, $4) RETURNING *`, - [bestellungId, data.titel, data.faellig_am, data.notizen || null, userId] + [bestellungId, data.faellig_am, data.nachricht || null, userId] ); - await logAction(bestellungId, 'Erinnerung erstellt', `"${data.titel}" fällig am ${data.faellig_am}`, userId); + await logAction(bestellungId, 'Erinnerung erstellt', `Erinnerung fällig am ${data.faellig_am}`, userId); return result.rows[0]; } catch (error) { logger.error('BestellungService.addReminder failed', { error, bestellungId }); @@ -502,7 +504,7 @@ async function markReminderDone(id: number, userId: string) { if (result.rows.length === 0) return null; const reminder = result.rows[0]; - await logAction(reminder.bestellung_id, 'Erinnerung erledigt', `"${reminder.titel}"`, userId); + await logAction(reminder.bestellung_id, 'Erinnerung erledigt', `Erinnerung #${reminder.id}`, userId); return reminder; } catch (error) { logger.error('BestellungService.markReminderDone failed', { error, id }); @@ -526,7 +528,7 @@ async function deleteReminder(id: number) { async function getDueReminders() { try { const result = await pool.query( - `SELECT e.*, b.titel AS bestellung_titel, b.erstellt_von AS besteller_id + `SELECT e.*, b.bezeichnung AS bestellung_bezeichnung, b.erstellt_von AS besteller_id FROM bestellung_erinnerungen e JOIN bestellungen b ON b.id = e.bestellung_id WHERE e.faellig_am <= NOW() AND e.erledigt = FALSE @@ -546,9 +548,9 @@ async function getDueReminders() { async function logAction(bestellungId: number, aktion: string, details: string, userId: string) { try { await pool.query( - `INSERT INTO bestellung_historie (bestellung_id, benutzer_id, aktion, details) - VALUES ($1, $2, $3, $4)`, - [bestellungId, userId, aktion, details] + `INSERT INTO bestellung_historie (bestellung_id, erstellt_von, aktion, details) + VALUES ($1, $2, $3, $4::jsonb)`, + [bestellungId, userId, aktion, JSON.stringify({ text: details })] ); } catch (error) { logger.error('BestellungService.logAction failed', { error, bestellungId, aktion }); @@ -561,7 +563,7 @@ async function getHistory(bestellungId: number) { const result = await pool.query( `SELECT h.*, u.display_name AS benutzer_name FROM bestellung_historie h - LEFT JOIN users u ON u.id = h.benutzer_id + LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [bestellungId] diff --git a/backend/src/services/booking.service.ts b/backend/src/services/booking.service.ts index f9f837d..3b1aba4 100644 --- a/backend/src/services/booking.service.ts +++ b/backend/src/services/booking.service.ts @@ -201,11 +201,13 @@ class BookingService { return rows.length > 0; } - /** Creates a new booking. Throws if the vehicle has a conflicting booking or is out of service. */ - async create(data: CreateBuchungData, userId: string): Promise { - const outOfService = await this.checkOutOfServiceConflict(data.fahrzeugId, data.beginn, data.ende); - if (outOfService) { - throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst'); + /** Creates a new booking. Throws if the vehicle has a conflicting booking or is out of service (unless overridden). */ + async create(data: CreateBuchungData, userId: string, ignoreOutOfService = false): Promise { + if (!ignoreOutOfService) { + const outOfService = await this.checkOutOfServiceConflict(data.fahrzeugId, data.beginn, data.ende); + if (outOfService) { + throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst'); + } } const hasConflict = await this.checkConflict( @@ -219,9 +221,9 @@ class BookingService { const query = ` INSERT INTO fahrzeug_buchungen - (fahrzeug_id, titel, beschreibung, beginn, ende, buchungs_art, gebucht_von, kontakt_person, kontakt_telefon) + (fahrzeug_id, titel, beschreibung, beginn, ende, buchungs_art, gebucht_von, kontakt_person, kontakt_telefon, ganztaegig) VALUES - ($1, $2, $3, $4, $5, $6::fahrzeug_buchung_art, $7, $8, $9) + ($1, $2, $3, $4, $5, $6::fahrzeug_buchung_art, $7, $8, $9, $10) RETURNING id `; @@ -235,6 +237,7 @@ class BookingService { userId, data.kontaktPerson ?? null, data.kontaktTelefon ?? null, + data.ganztaegig ?? false, ]); const newId: string = rows[0].id; diff --git a/backend/src/services/cleanup.service.ts b/backend/src/services/cleanup.service.ts new file mode 100644 index 0000000..031ebd0 --- /dev/null +++ b/backend/src/services/cleanup.service.ts @@ -0,0 +1,131 @@ +import pool from '../config/database'; +import logger from '../utils/logger'; + +export interface CleanupResult { + count: number; + deleted: boolean; +} + +class CleanupService { + + async cleanupNotifications(olderThanDays: number, confirm: boolean): Promise { + const cutoff = `${olderThanDays} days`; + if (!confirm) { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count FROM benachrichtigungen WHERE erstellt_am < NOW() - $1::interval`, + [cutoff] + ); + return { count: rows[0].count, deleted: false }; + } + const { rowCount } = await pool.query( + `DELETE FROM benachrichtigungen WHERE erstellt_am < NOW() - $1::interval`, + [cutoff] + ); + logger.info(`Cleanup: deleted ${rowCount} notifications older than ${olderThanDays} days`); + return { count: rowCount ?? 0, deleted: true }; + } + + async cleanupAuditLog(olderThanDays: number, confirm: boolean): Promise { + const cutoff = `${olderThanDays} days`; + if (!confirm) { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count FROM audit_log WHERE created_at < NOW() - $1::interval`, + [cutoff] + ); + return { count: rows[0].count, deleted: false }; + } + const { rowCount } = await pool.query( + `DELETE FROM audit_log WHERE created_at < NOW() - $1::interval`, + [cutoff] + ); + logger.info(`Cleanup: deleted ${rowCount} audit_log entries older than ${olderThanDays} days`); + return { count: rowCount ?? 0, deleted: true }; + } + + async cleanupEvents(olderThanDays: number, confirm: boolean): Promise { + const cutoff = `${olderThanDays} days`; + if (!confirm) { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count FROM events WHERE end_date < NOW() - $1::interval`, + [cutoff] + ); + return { count: rows[0].count, deleted: false }; + } + const { rowCount } = await pool.query( + `DELETE FROM events WHERE end_date < NOW() - $1::interval`, + [cutoff] + ); + logger.info(`Cleanup: deleted ${rowCount} events older than ${olderThanDays} days`); + return { count: rowCount ?? 0, deleted: true }; + } + + async cleanupBookings(olderThanDays: number, confirm: boolean): Promise { + const cutoff = `${olderThanDays} days`; + if (!confirm) { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count FROM fahrzeug_buchungen WHERE end_date < NOW() - $1::interval AND status IN ('completed', 'cancelled')`, + [cutoff] + ); + return { count: rows[0].count, deleted: false }; + } + const { rowCount } = await pool.query( + `DELETE FROM fahrzeug_buchungen WHERE end_date < NOW() - $1::interval AND status IN ('completed', 'cancelled')`, + [cutoff] + ); + logger.info(`Cleanup: deleted ${rowCount} bookings older than ${olderThanDays} days`); + return { count: rowCount ?? 0, deleted: true }; + } + + async cleanupOrders(olderThanDays: number, confirm: boolean): Promise { + const cutoff = `${olderThanDays} days`; + if (!confirm) { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count FROM bestellungen WHERE updated_at < NOW() - $1::interval AND status = 'abgeschlossen'`, + [cutoff] + ); + return { count: rows[0].count, deleted: false }; + } + const { rowCount } = await pool.query( + `DELETE FROM bestellungen WHERE updated_at < NOW() - $1::interval AND status = 'abgeschlossen'`, + [cutoff] + ); + logger.info(`Cleanup: deleted ${rowCount} orders older than ${olderThanDays} days`); + return { count: rowCount ?? 0, deleted: true }; + } + + async cleanupVehicleHistory(olderThanDays: number, confirm: boolean): Promise { + const cutoff = `${olderThanDays} days`; + if (!confirm) { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count FROM fahrzeug_wartungslog WHERE datum < NOW() - $1::interval`, + [cutoff] + ); + return { count: rows[0].count, deleted: false }; + } + const { rowCount } = await pool.query( + `DELETE FROM fahrzeug_wartungslog WHERE datum < NOW() - $1::interval`, + [cutoff] + ); + logger.info(`Cleanup: deleted ${rowCount} vehicle history entries older than ${olderThanDays} days`); + return { count: rowCount ?? 0, deleted: true }; + } + + async cleanupEquipmentHistory(olderThanDays: number, confirm: boolean): Promise { + const cutoff = `${olderThanDays} days`; + if (!confirm) { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count FROM ausruestung_wartungslog WHERE datum < NOW() - $1::interval`, + [cutoff] + ); + return { count: rows[0].count, deleted: false }; + } + const { rowCount } = await pool.query( + `DELETE FROM ausruestung_wartungslog WHERE datum < NOW() - $1::interval`, + [cutoff] + ); + logger.info(`Cleanup: deleted ${rowCount} equipment history entries older than ${olderThanDays} days`); + return { count: rowCount ?? 0, deleted: true }; + } +} + +export default new CleanupService(); diff --git a/backend/src/services/equipment.service.ts b/backend/src/services/equipment.service.ts index c1c3416..1655798 100644 --- a/backend/src/services/equipment.service.ts +++ b/backend/src/services/equipment.service.ts @@ -256,6 +256,13 @@ class EquipmentService { updatedBy: string ): Promise { try { + // Get old status for history + const oldResult = await pool.query( + `SELECT status FROM ausruestung WHERE id = $1 AND deleted_at IS NULL`, + [id] + ); + const oldStatus = oldResult.rows[0]?.status; + const result = await pool.query( `UPDATE ausruestung SET status = $1, status_bemerkung = $2, updated_at = NOW() @@ -268,6 +275,15 @@ class EquipmentService { throw new Error('Equipment not found'); } + // Record status change history + if (oldStatus && oldStatus !== status) { + await pool.query( + `INSERT INTO ausruestung_status_historie (ausruestung_id, alter_status, neuer_status, bemerkung, geaendert_von) + VALUES ($1, $2, $3, $4, $5)`, + [id, oldStatus, status, bemerkung || null, updatedBy] + ); + } + logger.info('Equipment status updated', { id, status, by: updatedBy }); } catch (error) { logger.error('EquipmentService.updateStatus failed', { error, id }); @@ -422,6 +438,48 @@ class EquipmentService { throw new Error('Failed to fetch upcoming inspections'); } } + + // ========================================================================= + // STATUS HISTORY + // ========================================================================= + + async getStatusHistory(equipmentId: string) { + try { + const result = await pool.query( + `SELECT h.*, u.display_name AS geaendert_von_name + FROM ausruestung_status_historie h + LEFT JOIN users u ON u.id = h.geaendert_von + WHERE h.ausruestung_id = $1 + ORDER BY h.erstellt_am DESC + LIMIT 50`, + [equipmentId] + ); + return result.rows; + } catch (error) { + logger.error('EquipmentService.getStatusHistory failed', { error, equipmentId }); + throw new Error('Status-Historie konnte nicht geladen werden'); + } + } + + // ========================================================================= + // WARTUNGSLOG FILE UPLOAD + // ========================================================================= + + async updateWartungslogFile(wartungId: number, filePath: string) { + try { + const result = await pool.query( + `UPDATE ausruestung_wartungslog SET dokument_url = $1 WHERE id = $2 RETURNING *`, + [filePath, wartungId] + ); + if (result.rows.length === 0) { + throw new Error('Wartungseintrag nicht gefunden'); + } + return result.rows[0]; + } catch (error) { + logger.error('EquipmentService.updateWartungslogFile failed', { error, wartungId }); + throw error; + } + } } export default new EquipmentService(); diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index cc89dc9..fa5a73b 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -390,60 +390,63 @@ class EventsService { * Capped at 100 instances and 2 years from the start date. */ private generateRecurrenceDates(startDate: Date, _endDate: Date, config: WiederholungConfig): Date[] { const dates: Date[] = []; - const limitDate = new Date(config.bis); + const limitDate = config.bis ? new Date(config.bis + 'T23:59:59Z') : new Date(0); const interval = config.intervall ?? 1; // Cap at 100 instances max, and 2 years const maxDate = new Date(startDate); - maxDate.setFullYear(maxDate.getFullYear() + 2); + maxDate.setUTCFullYear(maxDate.getUTCFullYear() + 2); const effectiveLimit = limitDate < maxDate ? limitDate : maxDate; - let current = new Date(startDate); - const originalDay = startDate.getDate(); + // Work in UTC to avoid timezone shifts + let currentMs = startDate.getTime(); + const originalDay = startDate.getUTCDate(); + const startHours = startDate.getUTCHours(); + const startMinutes = startDate.getUTCMinutes(); while (dates.length < 100) { + let current = new Date(currentMs); // Advance to next occurrence switch (config.typ) { case 'wöchentlich': - current = new Date(current); - current.setDate(current.getDate() + 7 * interval); + current.setUTCDate(current.getUTCDate() + 7 * interval); break; case 'zweiwöchentlich': - current = new Date(current); - current.setDate(current.getDate() + 14); + current.setUTCDate(current.getUTCDate() + 14); break; case 'monatlich_datum': { - current = new Date(current); - const targetMonth = current.getMonth() + 1; - current.setDate(1); - current.setMonth(targetMonth); - const lastDay = new Date(current.getFullYear(), current.getMonth() + 1, 0).getDate(); - current.setDate(Math.min(originalDay, lastDay)); + const targetMonth = current.getUTCMonth() + interval; + current.setUTCDate(1); + current.setUTCMonth(targetMonth); + const lastDay = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() + 1, 0)).getUTCDate(); + current.setUTCDate(Math.min(originalDay, lastDay)); + current.setUTCHours(startHours, startMinutes, 0, 0); break; } case 'monatlich_erster_wochentag': { const targetWeekday = config.wochentag ?? 0; // 0=Mon - current = new Date(current); - current.setMonth(current.getMonth() + 1); - current.setDate(1); + current.setUTCMonth(current.getUTCMonth() + 1); + current.setUTCDate(1); // Convert JS Sunday=0 to Monday=0: (getDay()+6)%7 - while ((current.getDay() + 6) % 7 !== targetWeekday) { - current.setDate(current.getDate() + 1); + while ((current.getUTCDay() + 6) % 7 !== targetWeekday) { + current.setUTCDate(current.getUTCDate() + 1); } + current.setUTCHours(startHours, startMinutes, 0, 0); break; } case 'monatlich_letzter_wochentag': { const targetWeekday = config.wochentag ?? 0; - current = new Date(current); // Go to last day of next month - current.setMonth(current.getMonth() + 2); - current.setDate(0); - while ((current.getDay() + 6) % 7 !== targetWeekday) { - current.setDate(current.getDate() - 1); + current.setUTCMonth(current.getUTCMonth() + 2); + current.setUTCDate(0); + while ((current.getUTCDay() + 6) % 7 !== targetWeekday) { + current.setUTCDate(current.getUTCDate() - 1); } + current.setUTCHours(startHours, startMinutes, 0, 0); break; } } if (current > effectiveLimit) break; + currentMs = current.getTime(); dates.push(new Date(current)); } return dates; @@ -515,16 +518,63 @@ class EventsService { * Hard-deletes an event (and any recurrence children) from the database. * Returns true if the event was found and deleted, false if not found. */ - async deleteEvent(id: string): Promise { - logger.info('Hard-deleting event', { id }); - // Delete recurrence children first (wiederholung_parent_id references) - await pool.query( - `DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`, + async deleteEvent(id: string, mode: 'all' | 'single' | 'future' = 'all'): Promise { + logger.info('Hard-deleting event', { id, mode }); + + if (mode === 'single') { + // Delete only this single instance + const result = await pool.query( + `DELETE FROM veranstaltungen WHERE id = $1`, + [id] + ); + return (result.rowCount ?? 0) > 0; + } + + if (mode === 'future') { + // Delete this instance and all later instances in the same series + const event = await pool.query( + `SELECT id, datum_von, wiederholung_parent_id FROM veranstaltungen WHERE id = $1`, + [id] + ); + if (event.rows.length === 0) return false; + const row = event.rows[0]; + const parentId = row.wiederholung_parent_id ?? row.id; + const datumVon = new Date(row.datum_von); + + // Delete this instance and all siblings/children with datum_von >= this one + await pool.query( + `DELETE FROM veranstaltungen + WHERE (wiederholung_parent_id = $1 OR id = $1) + AND datum_von >= $2 + AND id != $1`, + [parentId, datumVon] + ); + // Also delete the selected instance itself + await pool.query( + `DELETE FROM veranstaltungen WHERE id = $1`, + [id] + ); + return true; + } + + // mode === 'all': Delete parent + all children (original behavior) + // First check if this is a child instance — find the parent + const event = await pool.query( + `SELECT id, wiederholung_parent_id FROM veranstaltungen WHERE id = $1`, [id] ); + if (event.rows.length === 0) return false; + const parentId = event.rows[0].wiederholung_parent_id ?? id; + + // Delete all children of the parent + await pool.query( + `DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`, + [parentId] + ); + // Delete the parent itself const result = await pool.query( `DELETE FROM veranstaltungen WHERE id = $1`, - [id] + [parentId] ); return (result.rowCount ?? 0) > 0; } @@ -603,9 +653,9 @@ class EventsService { FROM ( SELECT unnest(authentik_groups) AS group_name FROM users - WHERE is_active = true + WHERE authentik_groups IS NOT NULL ) g - WHERE group_name LIKE 'dashboard_%' + WHERE group_name != 'dashboard_admin' ORDER BY group_name` ); diff --git a/backend/src/services/shop.service.ts b/backend/src/services/shop.service.ts index 8daa727..b4ed0b2 100644 --- a/backend/src/services/shop.service.ts +++ b/backend/src/services/shop.service.ts @@ -36,17 +36,17 @@ async function createItem( bezeichnung: string; beschreibung?: string; kategorie?: string; - geschaetzte_kosten?: number; + geschaetzter_preis?: number; url?: string; aktiv?: boolean; }, userId: string, ) { const result = await pool.query( - `INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzte_kosten, url, aktiv, erstellt_von) + `INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, url, aktiv, erstellt_von) VALUES ($1, $2, $3, $4, $5, COALESCE($6, true), $7) RETURNING *`, - [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzte_kosten || null, data.url || null, data.aktiv ?? true, userId], + [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.url || null, data.aktiv ?? true, userId], ); return result.rows[0]; } @@ -57,7 +57,7 @@ async function updateItem( bezeichnung?: string; beschreibung?: string; kategorie?: string; - geschaetzte_kosten?: number; + geschaetzter_preis?: number; url?: string; aktiv?: boolean; }, @@ -78,9 +78,9 @@ async function updateItem( params.push(data.kategorie); fields.push(`kategorie = $${params.length}`); } - if (data.geschaetzte_kosten !== undefined) { - params.push(data.geschaetzte_kosten); - fields.push(`geschaetzte_kosten = $${params.length}`); + if (data.geschaetzter_preis !== undefined) { + params.push(data.geschaetzter_preis); + fields.push(`geschaetzter_preis = $${params.length}`); } if (data.url !== undefined) { params.push(data.url); diff --git a/backend/src/services/vehicle.service.ts b/backend/src/services/vehicle.service.ts index 2695297..f829593 100644 --- a/backend/src/services/vehicle.service.ts +++ b/backend/src/services/vehicle.service.ts @@ -299,6 +299,15 @@ class VehicleService { ] ); + // Record status change history + if (oldStatus !== status) { + await client.query( + `INSERT INTO fahrzeug_status_historie (fahrzeug_id, alter_status, neuer_status, bemerkung, geaendert_von) + VALUES ($1, $2, $3, $4, $5)`, + [id, oldStatus, status, bemerkung || null, updatedBy] + ); + } + await client.query('COMMIT'); logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy }); @@ -574,6 +583,48 @@ class VehicleService { throw new Error('Failed to fetch inspection alerts'); } } + + // ========================================================================= + // STATUS HISTORY + // ========================================================================= + + async getStatusHistory(fahrzeugId: string) { + try { + const result = await pool.query( + `SELECT h.*, u.display_name AS geaendert_von_name + FROM fahrzeug_status_historie h + LEFT JOIN users u ON u.id = h.geaendert_von + WHERE h.fahrzeug_id = $1 + ORDER BY h.erstellt_am DESC + LIMIT 50`, + [fahrzeugId] + ); + return result.rows; + } catch (error) { + logger.error('VehicleService.getStatusHistory failed', { error, fahrzeugId }); + throw new Error('Status-Historie konnte nicht geladen werden'); + } + } + + // ========================================================================= + // WARTUNGSLOG FILE UPLOAD + // ========================================================================= + + async updateWartungslogFile(wartungId: number, filePath: string) { + try { + const result = await pool.query( + `UPDATE fahrzeug_wartungslog SET dokument_url = $1 WHERE id = $2 RETURNING *`, + [filePath, wartungId] + ); + if (result.rows.length === 0) { + throw new Error('Wartungseintrag nicht gefunden'); + } + return result.rows[0]; + } catch (error) { + logger.error('VehicleService.updateWartungslogFile failed', { error, wartungId }); + throw error; + } + } } export default new VehicleService(); diff --git a/frontend/src/components/admin/DataManagementTab.tsx b/frontend/src/components/admin/DataManagementTab.tsx new file mode 100644 index 0000000..b3940ad --- /dev/null +++ b/frontend/src/components/admin/DataManagementTab.tsx @@ -0,0 +1,170 @@ +import { useState, useCallback } from 'react'; +import { + Box, Paper, Typography, TextField, Button, Alert, + Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, + CircularProgress, Divider, +} from '@mui/material'; +import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; +import { api } from '../../services/api'; +import { useNotification } from '../../contexts/NotificationContext'; + +interface CleanupSection { + key: string; + label: string; + description: string; + defaultDays: number; +} + +const SECTIONS: CleanupSection[] = [ + { key: 'notifications', label: 'Benachrichtigungen', description: 'Alte Benachrichtigungen aller Benutzer entfernen.', defaultDays: 90 }, + { key: 'audit-log', label: 'Audit-Log', description: 'Alte Audit-Log Eintraege entfernen.', defaultDays: 365 }, + { key: 'events', label: 'Veranstaltungen', description: 'Vergangene Veranstaltungen entfernen (nach Enddatum).', defaultDays: 365 }, + { key: 'bookings', label: 'Fahrzeugbuchungen', description: 'Abgeschlossene oder stornierte Buchungen entfernen.', defaultDays: 180 }, + { key: 'orders', label: 'Bestellungen', description: 'Abgeschlossene Bestellungen entfernen.', defaultDays: 365 }, + { key: 'vehicle-history', label: 'Fahrzeug-Wartungslog', description: 'Alte Fahrzeug-Wartungseintraege entfernen.', defaultDays: 730 }, + { key: 'equipment-history', label: 'Ausruestungs-Wartungslog', description: 'Alte Ausruestungs-Wartungseintraege entfernen.', defaultDays: 730 }, +]; + +interface SectionState { + days: number; + previewCount: number | null; + loading: boolean; +} + +export default function DataManagementTab() { + const { showSuccess, showError } = useNotification(); + + const [states, setStates] = useState>(() => + Object.fromEntries(SECTIONS.map(s => [s.key, { days: s.defaultDays, previewCount: null, loading: false }])) + ); + + const [confirmDialog, setConfirmDialog] = useState<{ key: string; label: string; count: number } | null>(null); + const [deleting, setDeleting] = useState(false); + + const updateState = useCallback((key: string, partial: Partial) => { + setStates(prev => ({ ...prev, [key]: { ...prev[key], ...partial } })); + }, []); + + const handlePreview = useCallback(async (key: string) => { + const s = states[key]; + if (!s || s.days < 1) return; + updateState(key, { loading: true, previewCount: null }); + try { + const res = await api.delete(`/api/admin/cleanup/${key}`, { data: { olderThanDays: s.days, confirm: false } }); + updateState(key, { previewCount: res.data.data.count, loading: false }); + } catch { + showError('Vorschau konnte nicht geladen werden'); + updateState(key, { loading: false }); + } + }, [states, updateState, showError]); + + const handleDelete = useCallback(async () => { + if (!confirmDialog) return; + const { key } = confirmDialog; + const s = states[key]; + setDeleting(true); + try { + const res = await api.delete(`/api/admin/cleanup/${key}`, { data: { olderThanDays: s.days, confirm: true } }); + const deleted = res.data.data.count; + showSuccess(`${deleted} Eintraege geloescht`); + updateState(key, { previewCount: null }); + } catch { + showError('Loeschen fehlgeschlagen'); + } finally { + setDeleting(false); + setConfirmDialog(null); + } + }, [confirmDialog, states, updateState, showSuccess, showError]); + + return ( + + Datenverwaltung + + Alte Daten bereinigen, um die Datenbank schlank zu halten. Geloeschte Daten koennen nicht wiederhergestellt werden. + + + {SECTIONS.map((section, idx) => { + const s = states[section.key]; + return ( + + {section.label} + {section.description} + + + { + const v = parseInt(e.target.value, 10); + if (v > 0) updateState(section.key, { days: v, previewCount: null }); + }} + sx={{ width: 160 }} + inputProps={{ min: 1, max: 3650 }} + /> + + + + {s.previewCount !== null && ( + <> + 0 ? 'warning' : 'info'} sx={{ py: 0 }}> + {s.previewCount} {s.previewCount === 1 ? 'Eintrag' : 'Eintraege'} gefunden + + + {s.previewCount > 0 && ( + + )} + + )} + + + {idx < SECTIONS.length - 1 && } + + ); + })} + + !deleting && setConfirmDialog(null)}> + Daten loeschen? + + + {confirmDialog && ( + <> + {confirmDialog.count} {confirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus {confirmDialog.label} werden + unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden. + + )} + + + + + + + + + ); +} diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 0357701..71422a7 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -64,6 +64,7 @@ const adminSubItems: SubItem[] = [ { text: 'FDISK Sync', path: '/admin?tab=6' }, { text: 'Berechtigungen', path: '/admin?tab=7' }, { text: 'Bestellungen', path: '/admin?tab=8' }, + { text: 'Datenverwaltung', path: '/admin?tab=9' }, ]; const baseNavigationItems: NavigationItem[] = [ diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index cafe43a..2a4973a 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -11,6 +11,7 @@ import ServiceModeTab from '../components/admin/ServiceModeTab'; import FdiskSyncTab from '../components/admin/FdiskSyncTab'; import PermissionMatrixTab from '../components/admin/PermissionMatrixTab'; import BestellungenTab from '../components/admin/BestellungenTab'; +import DataManagementTab from '../components/admin/DataManagementTab'; import { usePermissionContext } from '../contexts/PermissionContext'; interface TabPanelProps { @@ -24,7 +25,7 @@ function TabPanel({ children, value, index }: TabPanelProps) { return {children}; } -const ADMIN_TAB_COUNT = 9; +const ADMIN_TAB_COUNT = 10; function AdminDashboard() { const navigate = useNavigate(); @@ -59,6 +60,7 @@ function AdminDashboard() { + @@ -89,6 +91,9 @@ function AdminDashboard() { + + + ); } diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx index 8b4bd17..0387309 100644 --- a/frontend/src/pages/Bestellungen.tsx +++ b/frontend/src/pages/Bestellungen.tsx @@ -89,6 +89,8 @@ export default function Bestellungen() { const [statusFilter, setStatusFilter] = useState(''); const [orderDialogOpen, setOrderDialogOpen] = useState(false); const [orderForm, setOrderForm] = useState({ ...emptyOrderForm }); + const [inlineVendorOpen, setInlineVendorOpen] = useState(false); + const [inlineVendorForm, setInlineVendorForm] = useState({ ...emptyVendorForm }); const [vendorDialogOpen, setVendorDialogOpen] = useState(false); const [vendorForm, setVendorForm] = useState({ ...emptyVendorForm }); @@ -122,10 +124,17 @@ export default function Bestellungen() { const createVendor = useMutation({ mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data), - onSuccess: () => { + onSuccess: (newVendor) => { queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); showSuccess('Lieferant erstellt'); - closeVendorDialog(); + // If inline vendor creation during order creation, auto-select the new vendor + if (inlineVendorOpen) { + setOrderForm((f) => ({ ...f, lieferant_id: newVendor.id })); + setInlineVendorOpen(false); + setInlineVendorForm({ ...emptyVendorForm }); + } else { + closeVendorDialog(); + } }, onError: () => showError('Fehler beim Erstellen des Lieferanten'), }); @@ -264,14 +273,6 @@ export default function Bestellungen() { {/* ── Tab 1: Vendors ── */} - - {hasPermission('bestellungen:create') && ( - - )} - - @@ -315,6 +316,12 @@ export default function Bestellungen() {
+ + {hasPermission('bestellungen:manage_vendors') && ( + setVendorDialogOpen(true)} aria-label="Lieferant hinzufügen"> + + + )}
{/* ── Create Order Dialog ── */} @@ -327,13 +334,38 @@ export default function Bestellungen() { value={orderForm.bezeichnung} onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))} /> - o.name} - value={vendors.find((v) => v.id === orderForm.lieferant_id) || null} - onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))} - renderInput={(params) => } - /> + + o.name} + value={vendors.find((v) => v.id === orderForm.lieferant_id) || null} + onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))} + renderInput={(params) => } + sx={{ flexGrow: 1 }} + /> + + setInlineVendorOpen(!inlineVendorOpen)} + color={inlineVendorOpen ? 'primary' : 'default'} + sx={{ mt: 1 }} + > + + + + + {inlineVendorOpen && ( + + Neuer Lieferant + setInlineVendorForm(f => ({ ...f, name: e.target.value }))} /> + setInlineVendorForm(f => ({ ...f, kontakt_name: e.target.value }))} /> + setInlineVendorForm(f => ({ ...f, email: e.target.value }))} /> + setInlineVendorForm(f => ({ ...f, telefon: e.target.value }))} /> + + + + + + )} ({ ...EMPTY_FORM }); const [dialogLoading, setDialogLoading] = useState(false); const [dialogError, setDialogError] = useState(null); + const [overrideOutOfService, setOverrideOutOfService] = useState(false); const [availability, setAvailability] = useState<{ available: boolean; reason?: string; @@ -254,6 +258,7 @@ function FahrzeugBuchungen() { setForm({ ...EMPTY_FORM }); setDialogError(null); setAvailability(null); + setOverrideOutOfService(false); setDialogOpen(true); }; @@ -265,6 +270,7 @@ function FahrzeugBuchungen() { setForm({ ...EMPTY_FORM, fahrzeugId: vehicleId, beginn: dateStr, ende: dateEndStr }); setDialogError(null); setAvailability(null); + setOverrideOutOfService(false); setDialogOpen(true); }; @@ -276,27 +282,33 @@ function FahrzeugBuchungen() { ...form, beginn: new Date(form.beginn).toISOString(), ende: new Date(form.ende).toISOString(), + ganztaegig: form.ganztaegig || false, }; if (editingBooking) { await bookingApi.update(editingBooking.id, payload); notification.showSuccess('Buchung aktualisiert'); } else { - await bookingApi.create(payload); + await bookingApi.create({ ...payload, ignoreOutOfService: overrideOutOfService } as any); notification.showSuccess('Buchung erstellt'); } setDialogOpen(false); loadData(); } catch (e: unknown) { - const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string }; - if (axiosError?.response?.status === 409) { - const reason = axiosError?.response?.data?.reason; - if (reason === 'out_of_service') { - setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst'); + try { + const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string }; + if (axiosError?.response?.status === 409) { + const reason = axiosError?.response?.data?.reason; + if (reason === 'out_of_service') { + setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst'); + } else { + setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum bereits gebucht'); + } } else { - setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); + const msg = axiosError?.response?.data?.message || axiosError?.message || 'Fehler beim Speichern'; + setDialogError(msg); } - } else { - setDialogError(axiosError?.message || 'Fehler beim Speichern'); + } catch { + setDialogError(e instanceof Error ? e.message : 'Fehler beim Speichern'); } } finally { setDialogLoading(false); @@ -495,11 +507,6 @@ function FahrzeugBuchungen() { {vehicle.bezeichnung} - {vehicle.amtliches_kennzeichen && ( - - {vehicle.amtliches_kennzeichen} - - )} {weekDays.map((day) => { const cellBookings = getBookingsForCell(vehicle.id, day); @@ -774,16 +781,39 @@ function FahrzeugBuchungen() { } /> + { + const checked = e.target.checked; + setForm((f) => { + if (checked && f.beginn) { + const dateStr = f.beginn.split('T')[0]; + return { ...f, ganztaegig: true, beginn: `${dateStr}T00:00`, ende: f.ende ? `${(f.ende.split('T')[0])}T23:59` : `${dateStr}T23:59` }; + } + return { ...f, ganztaegig: checked }; + }); + }} + /> + } + label="Ganztägig" + /> + - setForm((f) => ({ ...f, beginn: e.target.value })) - } + value={form.ganztaegig ? (form.beginn?.split('T')[0] || '') : form.beginn} + onChange={(e) => { + if (form.ganztaegig) { + setForm((f) => ({ ...f, beginn: `${e.target.value}T00:00` })); + } else { + setForm((f) => ({ ...f, beginn: e.target.value })); + } + }} InputLabelProps={{ shrink: true }} /> @@ -791,12 +821,16 @@ function FahrzeugBuchungen() { fullWidth size="small" label="Ende" - type="datetime-local" + type={form.ganztaegig ? 'date' : 'datetime-local'} required - value={form.ende} - onChange={(e) => - setForm((f) => ({ ...f, ende: e.target.value })) - } + value={form.ganztaegig ? (form.ende?.split('T')[0] || '') : form.ende} + onChange={(e) => { + if (form.ganztaegig) { + setForm((f) => ({ ...f, ende: `${e.target.value}T23:59` })); + } else { + setForm((f) => ({ ...f, ende: e.target.value })); + } + }} InputLabelProps={{ shrink: true }} /> @@ -818,16 +852,34 @@ function FahrzeugBuchungen() { size="small" /> ) : availability.reason === 'out_of_service' ? ( - } - label={ - availability.ausserDienstBis - ? `Außer Dienst bis ${new Date(availability.ausserDienstBis).toLocaleDateString('de-DE')} (geschätzt)` - : 'Fahrzeug ist außer Dienst' - } - color="error" - size="small" - /> + + } + label={ + availability.ausserDienstBis + ? `Außer Dienst bis ${new Date(availability.ausserDienstBis).toLocaleDateString('de-DE')} (geschätzt)` + : 'Fahrzeug ist außer Dienst' + } + color="error" + size="small" + /> + setOverrideOutOfService(e.target.checked)} + color="warning" + size="small" + /> + } + label={ + + Trotz Außer-Dienst-Status buchen + + } + sx={{ mt: 0.5 }} + /> + ) : ( } diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index fd807d0..6752fad 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -43,6 +43,7 @@ import { DirectionsCar, Edit, Error as ErrorIcon, + History, LocalFireDepartment, MoreHoriz, PauseCircle, @@ -121,6 +122,58 @@ function fmtDatetime(iso: string | Date | null | undefined): string { return fmtDate(iso ? new Date(iso).toISOString() : null); } +// ── Status History Section ──────────────────────────────────────────────────── + +const StatusHistorySection: React.FC<{ vehicleId: string }> = ({ vehicleId }) => { + const [history, setHistory] = useState<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + vehiclesApi.getStatusHistory(vehicleId) + .then(setHistory) + .catch(() => setHistory([])) + .finally(() => setLoading(false)); + }, [vehicleId]); + + if (loading || history.length === 0) return null; + + return ( + <> + + Status-Historie + + + + + + Datum + Von + Nach + Bemerkung + Geändert von + + + + {history.map((h, idx) => ( + + {fmtDatetime(h.erstellt_am)} + + + + + + + {h.bemerkung || '—'} + {h.geaendert_von_name || '—'} + + ))} + +
+
+ + ); +}; + // ── Übersicht Tab ───────────────────────────────────────────────────────────── interface UebersichtTabProps { @@ -148,7 +201,7 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, const openDialog = () => { setNewStatus(vehicle.status); - setBemerkung(vehicle.status_bemerkung ?? ''); + setBemerkung(''); setAusserDienstVon( vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : '' ); @@ -323,6 +376,9 @@ const UebersichtTab: React.FC = ({ vehicle, onStatusUpdated, })} + {/* Status history */} + + {/* Status change dialog */} Fahrzeugstatus ändern @@ -475,6 +531,42 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde entry.externe_werkstatt && entry.externe_werkstatt, ].filter(Boolean).join(' · ')} + {entry.dokument_url ? ( + + ) : canWrite ? ( + + ) : null} ); diff --git a/frontend/src/pages/FahrzeugForm.tsx b/frontend/src/pages/FahrzeugForm.tsx index 10f59be..ee4a86e 100644 --- a/frontend/src/pages/FahrzeugForm.tsx +++ b/frontend/src/pages/FahrzeugForm.tsx @@ -167,8 +167,6 @@ function FahrzeugForm() { hersteller: form.hersteller.trim() || null, typ_schluessel: form.typ_schluessel.trim() || null, besatzung_soll: form.besatzung_soll.trim() || null, - status: form.status, - status_bemerkung: form.status_bemerkung.trim() || null, standort: form.standort.trim() || 'Feuerwehrhaus', bild_url: form.bild_url.trim() || null, paragraph57a_faellig_am: form.paragraph57a_faellig_am || null, @@ -186,8 +184,6 @@ function FahrzeugForm() { hersteller: form.hersteller.trim() || undefined, typ_schluessel: form.typ_schluessel.trim() || undefined, besatzung_soll: form.besatzung_soll.trim() || undefined, - status: form.status, - status_bemerkung: form.status_bemerkung.trim() || undefined, standort: form.standort.trim() || 'Feuerwehrhaus', bild_url: form.bild_url.trim() || undefined, paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, @@ -285,32 +281,6 @@ function FahrzeugForm() { - Status - - - - Status - - - - - - - - Prüf- und Wartungsfristen diff --git a/frontend/src/pages/Shop.tsx b/frontend/src/pages/Shop.tsx index 08e1bcd..ef6f949 100644 --- a/frontend/src/pages/Shop.tsx +++ b/frontend/src/pages/Shop.tsx @@ -236,8 +236,8 @@ function KatalogTab() { {/* Artikel create/edit dialog */} setArtikelDialogOpen(false)} maxWidth="sm" fullWidth> {editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'} - - setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} /> + + setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth /> setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} /> ([]); const [groups, setGroups] = useState([]); diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx index 80cc038..fb168f0 100644 --- a/frontend/src/pages/Veranstaltungen.tsx +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -21,8 +21,6 @@ import { InputLabel, FormControlLabel, Switch, - Checkbox, - FormGroup, Stack, List, ListItem, @@ -34,6 +32,9 @@ import { useTheme, useMediaQuery, Snackbar, + Autocomplete, + Radio, + RadioGroup, } from '@mui/material'; import { Add, @@ -61,6 +62,7 @@ import type { GroupInfo, CreateVeranstaltungInput, ConflictEvent, + WiederholungConfig, } from '../types/events.types'; // --------------------------------------------------------------------------- @@ -667,16 +669,6 @@ function EventFormDialog({ setForm((prev) => ({ ...prev, [field]: value })); }; - const handleGroupToggle = (groupId: string) => { - setForm((prev) => { - const current = prev.zielgruppen; - const updated = current.includes(groupId) - ? current.filter((g) => g !== groupId) - : [...current, groupId]; - return { ...prev, zielgruppen: updated }; - }); - }; - const handleSave = async () => { if (!form.titel.trim()) { notification.showError('Titel ist erforderlich'); @@ -866,28 +858,33 @@ function EventFormDialog({ label="Für alle Mitglieder sichtbar" /> - {/* Zielgruppen checkboxes */} + {/* Zielgruppen multi-select */} {!form.alle_gruppen && groups.length > 0 && ( - - - Zielgruppen - - - {groups.map((g) => ( - handleGroupToggle(g.id)} - size="small" - /> - } - label={g.label} + option.label} + value={groups.filter((g) => form.zielgruppen.includes(g.id))} + onChange={(_, newValue) => { + handleChange('zielgruppen', newValue.map((g) => g.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + - ))} - - + )) + } + size="small" + disableCloseOnSelect + /> )} @@ -929,6 +926,103 @@ function EventFormDialog({ inputProps={{ min: 1 }} fullWidth /> + + {/* Recurrence / Wiederholung — only for new events */} + {!editingEvent && ( + <> + + { + if (e.target.checked) { + const bisDefault = new Date(form.datum_von); + bisDefault.setMonth(bisDefault.getMonth() + 3); + handleChange('wiederholung', { + typ: 'wöchentlich', + intervall: 1, + bis: bisDefault.toISOString().slice(0, 10), + } as WiederholungConfig); + } else { + handleChange('wiederholung', null); + } + }} + /> + } + label="Wiederholung" + /> + {form.wiederholung && ( + + + Häufigkeit + + + + {form.wiederholung.typ === 'wöchentlich' && ( + { + const w = { ...form.wiederholung!, intervall: Math.max(1, Number(e.target.value) || 1) }; + handleChange('wiederholung', w); + }} + inputProps={{ min: 1, max: 52 }} + fullWidth + /> + )} + + {(form.wiederholung.typ === 'monatlich_erster_wochentag' || + form.wiederholung.typ === 'monatlich_letzter_wochentag') && ( + + Wochentag + + + )} + + { + const w = { ...form.wiederholung!, bis: e.target.value }; + handleChange('wiederholung', w); + }} + InputLabelProps={{ shrink: true }} + fullWidth + helperText="Enddatum der Wiederholungsserie" + /> + + )} + + )} @@ -1105,6 +1199,7 @@ export default function Veranstaltungen() { // Delete dialog const [deleteId, setDeleteId] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteMode, setDeleteMode] = useState<'all' | 'single' | 'future'>('all'); // iCal dialog const [icalOpen, setIcalOpen] = useState(false); @@ -1215,8 +1310,9 @@ export default function Veranstaltungen() { if (!deleteId) return; setDeleteLoading(true); try { - await eventsApi.deleteEvent(deleteId); + await eventsApi.deleteEvent(deleteId, deleteMode); setDeleteId(null); + setDeleteMode('all'); loadData(); notification.showSuccess('Veranstaltung wurde gelöscht'); } catch (e: unknown) { @@ -1373,7 +1469,12 @@ export default function Veranstaltungen() { canWrite={canWrite} onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }} onCancel={(id) => { setCancelId(id); setCancelGrund(''); }} - onDelete={(id) => setDeleteId(id)} + onDelete={(id) => { + const ev = events.find((e) => e.id === id); + const isRecurring = ev && (ev.wiederholung_parent_id || ev.wiederholung); + setDeleteMode(isRecurring ? 'single' : 'all'); + setDeleteId(id); + }} /> )} @@ -1444,15 +1545,38 @@ export default function Veranstaltungen() { {/* Delete Dialog */} - setDeleteId(null)} maxWidth="xs" fullWidth> + { setDeleteId(null); setDeleteMode('all'); }} maxWidth="xs" fullWidth> Veranstaltung endgültig löschen - - Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden. - + {(() => { + const deleteEvent = events.find((ev) => ev.id === deleteId); + const isRecurring = deleteEvent && (deleteEvent.wiederholung_parent_id || deleteEvent.wiederholung); + if (isRecurring) { + return ( + <> + + Diese Veranstaltung ist Teil einer Wiederholungsserie. Was soll gelöscht werden? + + setDeleteMode(e.target.value as 'all' | 'single' | 'future')} + > + } label="Nur diesen Termin" /> + } label="Diesen und alle folgenden Termine" /> + } label="Alle Termine der Serie" /> + + + ); + } + return ( + + Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden. + + ); + })()} - + diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts index 4345710..3bc9dc5 100644 --- a/frontend/src/services/events.ts +++ b/frontend/src/services/events.ts @@ -131,8 +131,8 @@ export const eventsApi = { }, /** Hard-delete an event permanently */ - deleteEvent(id: string): Promise { - return api.post(`/api/events/${id}/delete`).then(() => undefined); + deleteEvent(id: string, mode: 'all' | 'single' | 'future' = 'all'): Promise { + return api.post(`/api/events/${id}/delete`, { mode }).then(() => undefined); }, // ------------------------------------------------------------------------- diff --git a/frontend/src/services/vehicles.ts b/frontend/src/services/vehicles.ts index 7d430e4..4ba9d80 100644 --- a/frontend/src/services/vehicles.ts +++ b/frontend/src/services/vehicles.ts @@ -100,4 +100,17 @@ export const vehiclesApi = { }); return response.data as Blob; }, + + async getStatusHistory(id: string): Promise<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]> { + return unwrap(api.get(`/api/vehicles/${id}/status-history`)); + }, + + async uploadWartungFile(wartungId: number, file: File): Promise { + const formData = new FormData(); + formData.append('datei', file); + const r = await api.post(`/api/vehicles/wartung/${wartungId}/upload`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return r.data.data; + }, }; diff --git a/frontend/src/types/booking.types.ts b/frontend/src/types/booking.types.ts index 9e67cfd..c727e4e 100644 --- a/frontend/src/types/booking.types.ts +++ b/frontend/src/types/booking.types.ts @@ -71,4 +71,5 @@ export interface CreateBuchungInput { buchungsArt: BuchungsArt; kontaktPerson?: string | null; kontaktTelefon?: string | null; + ganztaegig?: boolean; } diff --git a/frontend/src/types/vehicle.types.ts b/frontend/src/types/vehicle.types.ts index c3528ab..3767dee 100644 --- a/frontend/src/types/vehicle.types.ts +++ b/frontend/src/types/vehicle.types.ts @@ -58,6 +58,7 @@ export interface FahrzeugWartungslog { kraftstoff_liter: number | null; kosten: number | null; externe_werkstatt: string | null; + dokument_url: string | null; erfasst_von: string | null; created_at: string; } diff --git a/sync/src/db.ts b/sync/src/db.ts index d4b78c1..47b095c 100644 --- a/sync/src/db.ts +++ b/sync/src/db.ts @@ -65,6 +65,23 @@ function mapDienstgrad(raw: string): string | null { return null; } +/** + * Valid Austrian/EU driving license class patterns. + * Filters out non-class data that the scraper may pick up from FDISK form fields. + */ +const VALID_LICENSE_CLASSES = new Set([ + 'A', 'A1', 'A2', 'AM', + 'B', 'B1', 'BE', + 'C', 'C1', 'CE', 'C1E', + 'D', 'D1', 'DE', 'D1E', + 'F', 'G', 'L', 'T', +]); + +function isValidLicenseClass(klasse: string): boolean { + const normalized = klasse.trim().toUpperCase(); + return VALID_LICENSE_CLASSES.has(normalized); +} + export async function syncToDatabase( pool: Pool, members: FdiskMember[], @@ -362,6 +379,13 @@ async function syncFahrgenehmigungen( } for (const f of fahrgenehmigungen) { + // J2: Filter out non-class data that the scraper may pick up + if (!f.klasse || !isValidLicenseClass(f.klasse)) { + log(`Skipping Fahrgenehmigung: invalid klasse "${f.klasse}" for StNr ${f.standesbuchNr}`); + skipped++; + continue; + } + const result = await client.query<{ user_id: string }>( `SELECT user_id FROM mitglieder_profile WHERE fdisk_standesbuch_nr = $1`, [f.standesbuchNr] diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index b71ce9c..7f72de6 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -241,9 +241,25 @@ export async function scrapeAll(username: string, password: string, knownStNrs: const idPersonen = urlObj.searchParams.get('id_personen'); const idInstanzen = urlObj.searchParams.get('id_instanzen') ?? ID_INSTANZEN; - // Ausbildungen — disabled: requires different page/approach (TODO) - // const quals = await scrapeAusbildungenFromDetailPage(mainFrame, member, idMitgliedschaft, idPersonen); - // ausbildungen.push(...quals); + // Ausbildungen + if (idMitgliedschaft && idPersonen) { + try { + const quals = await scrapeAusbildungenFromDetailPage(mainFrame, member, idMitgliedschaft, idPersonen); + ausbildungen.push(...quals); + log(` ${member.vorname} ${member.zuname}: ${quals.length} Ausbildungen`); + } catch (err: any) { + log(` WARN: Ausbildungen scrape failed for ${member.vorname} ${member.zuname} (StNr ${member.standesbuchNr}): ${err.message}`); + // Always dump HTML on failure for diagnosis + try { + const debugDir = path.resolve(process.cwd(), 'debug'); + fs.mkdirSync(debugDir, { recursive: true }); + const html = await mainFrame.content(); + const filePath = path.join(debugDir, `ausbildungen_error_StNr${member.standesbuchNr}.html`); + fs.writeFileSync(filePath, html, 'utf-8'); + log(` [debug] saved error HTML → ${filePath}`); + } catch { /* ignore dump errors */ } + } + } // Beförderungen const befos = (idMitgliedschaft && idPersonen) @@ -1034,20 +1050,110 @@ async function scrapeMemberUntersuchungen( + `?search=1&searchid_mitgliedschaften=${idMitgliedschaft}&id_personen=${idPersonen}` + `&id_mitgliedschaften=${idMitgliedschaft}&searchid_personen=${idPersonen}&searchid_maskmode=`; - const result = await navigateAndGetTableRows(frame, url); - if (!result) return []; + // Always dump for diagnosis when debug is on + await frame_goto(frame, url); + + const landed = frame.url(); + const title = await frame.title().catch(() => ''); + if (landed.includes('BLError') || landed.includes('support.aspx') || title.toLowerCase().includes('fehler')) { + log(` → Untersuchungen ERROR page: ${landed}`); + await dumpHtml(frame, `untersuchungen_error_StNr${standesbuchNr}`); + return []; + } + + // Show all rows + await selectAlleAnzeige(frame); + + // Dump HTML for diagnosis (always when debug enabled) + await dumpHtml(frame, `untersuchungen_StNr${standesbuchNr}`); + + // Try to navigate to history/detail view if available + // FDISK may show only the most recent per exam type on the list page. + // Look for a "Verlauf" or "Detail" or "Alle anzeigen" link/button + const hasHistoryLink = await frame.evaluate(() => { + const links = Array.from(document.querySelectorAll('a, input[type="button"], button')); + for (const el of links) { + const text = (el.textContent || '').toLowerCase(); + const title = (el.getAttribute('title') || '').toLowerCase(); + if (text.includes('verlauf') || text.includes('historie') || text.includes('alle anzeigen') + || title.includes('verlauf') || title.includes('historie')) { + return (el as HTMLElement).id || (el as HTMLAnchorElement).href || text; + } + } + return null; + }).catch(() => null); + + if (hasHistoryLink) { + log(` → Found history link: ${hasHistoryLink}`); + } + + // Parse the table using navigateAndGetTableRows logic (reuse existing page state) + // Re-collect rows from the already-loaded page + const allRows = await frame.evaluate(() => { + const results: Array<{ cells: string[]; tableClass: string }> = []; + for (const table of Array.from(document.querySelectorAll('table'))) { + const cls = table.className || ''; + for (const tr of Array.from(table.querySelectorAll('tbody tr, tr'))) { + if (tr.closest('table') !== table) continue; + const tds = Array.from(tr.querySelectorAll('td')); + if (tds.length < 2) continue; + results.push({ + tableClass: cls, + cells: tds.map(td => { + const input = td.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null; + if (input) return input.value?.trim() ?? ''; + const sel = td.querySelector('select') as HTMLSelectElement | null; + if (sel) { + const opt = sel.options[sel.selectedIndex]; + return (opt?.text || opt?.value || '').trim(); + } + const anchor = td.querySelector('a'); + const atitle = anchor?.getAttribute('title')?.trim(); + if (atitle) return atitle; + return td.textContent?.trim() ?? ''; + }), + }); + } + } + return results; + }).catch(() => [] as Array<{ cells: string[]; tableClass: string }>); + + const fdcRows = allRows.filter(r => r.tableClass.includes('FdcLayList')); + const resultRows = fdcRows.length > 0 ? fdcRows : allRows; + const mapped = resultRows.map(r => ({ + cells: r.cells.map(c => c.replace(/\u00A0/g, ' ').trim()), + })); + + // Find date column + const datePattern = /^\d{2}\.\d{2}\.\d{4}$/; + let dateColIdx = -1; + for (const r of mapped) { + for (let ci = 0; ci < r.cells.length; ci++) { + if (datePattern.test(r.cells[ci] ?? '')) { + dateColIdx = ci; + break; + } + } + if (dateColIdx >= 0) break; + } + + const dataRows = dateColIdx >= 0 + ? mapped.filter(r => datePattern.test(r.cells[dateColIdx] ?? '')) + : []; + + log(` → Untersuchungen: ${allRows.length} total rows, ${dataRows.length} data rows (date in col ${dateColIdx})`); + + if (dataRows.length === 0) { + await dumpHtml(frame, `untersuchungen_empty_StNr${standesbuchNr}`); + } - const { rows, dateColIdx } = result; const results: FdiskUntersuchung[] = []; - for (const row of rows) { - // Collect non-empty values from columns after the date column + for (const row of dataRows) { const valueCols: string[] = []; for (let ci = dateColIdx + 1; ci < row.cells.length; ci++) { const v = cellText(row.cells[ci]); if (v !== null) valueCols.push(v); } - // Original layout: 0=Datum, 1=Anmerkungen, 2=Untersuchungsart, 3=Tauglichkeitsstufe - // With spacer columns the date may not be at 0; use relative offsets from collected values const anmerkungen = valueCols[0] ?? null; const art = valueCols[1] ?? null; const ergebnis = valueCols[2] ?? null;