new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 14:01:39 +01:00
parent d2dc64d54a
commit 3326156b15
35 changed files with 1341 additions and 257 deletions

View File

@@ -120,6 +120,7 @@ class AuthController {
}); });
await userService.updateGroups(user.id, groups); await userService.updateGroups(user.id, groups);
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
await memberService.ensureProfileExists(user.id); await memberService.ensureProfileExists(user.id);
// Audit: first-ever login (user record creation) // Audit: first-ever login (user record creation)
@@ -168,6 +169,7 @@ class AuthController {
await userService.updateLastLogin(user.id); await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups); await userService.updateGroups(user.id, groups);
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
await memberService.ensureProfileExists(user.id); await memberService.ensureProfileExists(user.id);
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo); const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);

View File

@@ -113,9 +113,9 @@ class BestellungController {
} }
async createOrder(req: Request, res: Response): Promise<void> { async createOrder(req: Request, res: Response): Promise<void> {
const { titel } = req.body; const { bezeichnung } = req.body;
if (!titel || typeof titel !== 'string' || titel.trim().length === 0) { if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Titel ist erforderlich' }); res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return; return;
} }
try { try {
@@ -203,9 +203,9 @@ class BestellungController {
res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' }); res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' });
return; return;
} }
const { artikel, menge } = req.body; const { bezeichnung, menge } = req.body;
if (!artikel || typeof artikel !== 'string' || artikel.trim().length === 0) { if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Artikel ist erforderlich' }); res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return; return;
} }
if (menge === undefined || menge === null || menge <= 0) { if (menge === undefined || menge === null || menge <= 0) {
@@ -364,11 +364,7 @@ class BestellungController {
res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' }); res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' });
return; return;
} }
const { titel, faellig_am } = req.body; const { nachricht, faellig_am } = req.body;
if (!titel || typeof titel !== 'string' || titel.trim().length === 0) {
res.status(400).json({ success: false, message: 'Titel ist erforderlich' });
return;
}
if (!faellig_am) { if (!faellig_am) {
res.status(400).json({ success: false, message: 'Fälligkeitsdatum ist erforderlich' }); res.status(400).json({ success: false, message: 'Fälligkeitsdatum ist erforderlich' });
return; return;

View File

@@ -158,7 +158,7 @@ class BookingController {
handleZodError(res, parsed.error); handleZodError(res, parsed.error);
return; 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 }); res.status(201).json({ success: true, data: booking });
} catch (error: any) { } catch (error: any) {
if (handleConflictError(res, error)) return; if (handleConflictError(res, error)) return;

View File

@@ -391,6 +391,36 @@ class EquipmentController {
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' }); res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
} }
} }
async getStatusHistory(req: Request, res: Response): Promise<void> {
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<void> {
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(); export default new EquipmentController();

View File

@@ -304,7 +304,12 @@ class EventsController {
deleteEvent = async (req: Request, res: Response): Promise<void> => { deleteEvent = async (req: Request, res: Response): Promise<void> => {
try { try {
const { id } = req.params as Record<string, string>; const { id } = req.params as Record<string, string>;
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) { if (!deleted) {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' }); res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return; return;

View File

@@ -372,6 +372,36 @@ class VehicleController {
res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' }); res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' });
} }
} }
async getStatusHistory(req: Request, res: Response): Promise<void> {
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<void> {
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(); export default new VehicleController();

View File

@@ -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;

View File

@@ -56,4 +56,39 @@ const multerOptions: any = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const uploadBestellung: any = multer(multerOptions); 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 };

View File

@@ -66,6 +66,7 @@ const BuchungBaseSchema = z.object({
buchungsArt: z.enum(BUCHUNGS_ARTEN).default('intern'), buchungsArt: z.enum(BUCHUNGS_ARTEN).default('intern'),
kontaktPerson: z.string().max(255).optional().nullable(), kontaktPerson: z.string().max(255).optional().nullable(),
kontaktTelefon: z.string().max(50).optional().nullable(), kontaktTelefon: z.string().max(50).optional().nullable(),
ganztaegig: z.boolean().optional().default(false),
}); });
export const CreateBuchungSchema = BuchungBaseSchema.refine( export const CreateBuchungSchema = BuchungBaseSchema.refine(

View File

@@ -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 — paginated, filtered list
* GET /api/admin/audit-log/export — CSV download of filtered results * 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. * Both endpoints require authentication + admin:access permission.
* *
@@ -18,6 +19,7 @@ import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
import { auditExport } from '../middleware/audit.middleware'; import { auditExport } from '../middleware/audit.middleware';
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service'; import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
import cleanupService from '../services/cleanup.service';
import logger from '../utils/logger'; import logger from '../utils/logger';
const router = Router(); 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<CleanupTarget, (days: number, confirm: boolean) => 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<void> => {
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; export default router;

View File

@@ -2,18 +2,20 @@ import { Router } from 'express';
import equipmentController from '../controllers/equipment.controller'; import equipmentController from '../controllers/equipment.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
import { uploadWartung } from '../middleware/upload';
const router = Router(); const router = Router();
// ── Read-only (any authenticated user) ─────────────────────────────────────── // ── Read-only (any authenticated user) ───────────────────────────────────────
router.get('/', authenticate, equipmentController.listEquipment.bind(equipmentController)); router.get('/', authenticate, requirePermission('ausruestung:view'), equipmentController.listEquipment.bind(equipmentController));
router.get('/stats', authenticate, equipmentController.getStats.bind(equipmentController)); router.get('/stats', authenticate, requirePermission('ausruestung:view'), equipmentController.getStats.bind(equipmentController));
router.get('/alerts', authenticate, equipmentController.getAlerts.bind(equipmentController)); router.get('/alerts', authenticate, requirePermission('ausruestung:view'), equipmentController.getAlerts.bind(equipmentController));
router.get('/categories', authenticate, equipmentController.getCategories.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-warnings', authenticate, equipmentController.getVehicleWarnings.bind(equipmentController));
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController)); router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController)); router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
router.get('/:id/status-history', authenticate, equipmentController.getStatusHistory.bind(equipmentController));
// ── Write — gruppenfuehrer+ ──────────────────────────────────────────────── // ── 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', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.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('/: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 ────────────────────────────────────────────────────── // ── Delete — admin only ──────────────────────────────────────────────────────

View File

@@ -2,17 +2,19 @@ import { Router } from 'express';
import vehicleController from '../controllers/vehicle.controller'; import vehicleController from '../controllers/vehicle.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
import { uploadWartung } from '../middleware/upload';
const router = Router(); const router = Router();
// ── Read-only (any authenticated user) ─────────────────────────────────────── // ── Read-only (any authenticated user) ───────────────────────────────────────
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController)); router.get('/', authenticate, requirePermission('fahrzeuge:view'), vehicleController.listVehicles.bind(vehicleController));
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController)); router.get('/stats', authenticate, requirePermission('fahrzeuge:view'), vehicleController.getStats.bind(vehicleController));
router.get('/alerts', authenticate, vehicleController.getAlerts.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('/alerts/export', authenticate, requirePermission('fahrzeuge:view'), vehicleController.exportAlerts.bind(vehicleController));
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController)); router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
router.get('/:id/status-history', authenticate, vehicleController.getStatusHistory.bind(vehicleController));
// ── Write — kommandant+ ────────────────────────────────────────────────────── // ── 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.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('/: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; export default router;

View File

@@ -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 { try {
const result = await pool.query( const result = await pool.query(
`INSERT INTO lieferanten (name, kontakt_person, email, telefon, adresse, notizen, erstellt_von) `INSERT INTO lieferanten (name, kontakt_name, email, telefon, adresse, website, notizen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`, 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]; return result.rows[0];
} catch (error) { } 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 { try {
const result = await pool.query( const result = await pool.query(
`UPDATE lieferanten `UPDATE lieferanten
SET name = COALESCE($1, name), SET name = COALESCE($1, name),
kontakt_person = COALESCE($2, kontakt_person), kontakt_name = COALESCE($2, kontakt_name),
email = COALESCE($3, email), email = COALESCE($3, email),
telefon = COALESCE($4, telefon), telefon = COALESCE($4, telefon),
adresse = COALESCE($5, adresse), adresse = COALESCE($5, adresse),
notizen = COALESCE($6, notizen), website = COALESCE($6, website),
notizen = COALESCE($7, notizen),
aktualisiert_am = NOW() aktualisiert_am = NOW()
WHERE id = $7 WHERE id = $8
RETURNING *`, 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; 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 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_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 * 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 { 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 { try {
const result = await pool.query( 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) VALUES ($1, $2, $3, $4, $5)
RETURNING *`, 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]; 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; return order;
} catch (error) { } catch (error) {
logger.error('BestellungService.createOrder failed', { 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 { try {
// Check current order for status change detection // Check current order for status change detection
const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]); 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( const result = await pool.query(
`UPDATE bestellungen `UPDATE bestellungen
SET titel = COALESCE($1, titel), SET bezeichnung = COALESCE($1, bezeichnung),
lieferant_id = COALESCE($2, lieferant_id), lieferant_id = COALESCE($2, lieferant_id),
beschreibung = COALESCE($3, beschreibung), notizen = COALESCE($3, notizen),
prioritaet = COALESCE($4, prioritaet), budget = COALESCE($4, budget),
status = COALESCE($5, status), status = COALESCE($5, status),
bestellt_am = $6, bestellt_am = $6,
abgeschlossen_am = $7, abgeschlossen_am = $7,
aktualisiert_am = NOW() aktualisiert_am = NOW()
WHERE id = $8 WHERE id = $8
RETURNING *`, 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; if (result.rows.length === 0) return null;
const changes: string[] = []; 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.lieferant_id) changes.push(`Lieferant geändert`);
if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus}${data.status}`); 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); await logAction(id, 'Bestellung aktualisiert', changes.join(', ') || 'Bestellung bearbeitet', userId);
return result.rows[0]; return result.rows[0];
@@ -275,12 +276,12 @@ async function deleteOrder(id: number, _userId: string) {
} }
const VALID_STATUS_TRANSITIONS: Record<string, string[]> = { const VALID_STATUS_TRANSITIONS: Record<string, string[]> = {
entwurf: ['bestellt', 'storniert'], entwurf: ['erstellt', 'bestellt'],
bestellt: ['teillieferung', 'vollstaendig', 'storniert'], erstellt: ['bestellt'],
teillieferung: ['vollstaendig', 'storniert'], bestellt: ['teillieferung', 'vollstaendig'],
teillieferung: ['vollstaendig'],
vollstaendig: ['abgeschlossen'], vollstaendig: ['abgeschlossen'],
abgeschlossen: [], abgeschlossen: [],
storniert: ['entwurf'],
}; };
async function updateOrderStatus(id: number, status: string, userId: string) { 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) // 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 { try {
const result = await pool.query( const result = await pool.query(
`INSERT INTO bestellpositionen (bestellung_id, artikel, menge, einheit, einzelpreis, notizen) `INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`, 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]; return result.rows[0];
} catch (error) { } catch (error) {
logger.error('BestellungService.addLineItem failed', { error, bestellungId }); 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 { try {
const result = await pool.query( const result = await pool.query(
`UPDATE bestellpositionen `UPDATE bestellpositionen
SET artikel = COALESCE($1, artikel), SET bezeichnung = COALESCE($1, bezeichnung),
menge = COALESCE($2, menge), artikelnummer = COALESCE($2, artikelnummer),
einheit = COALESCE($3, einheit), menge = COALESCE($3, menge),
einzelpreis = COALESCE($4, einzelpreis), einheit = COALESCE($4, einheit),
notizen = COALESCE($5, notizen) einzelpreis = COALESCE($5, einzelpreis),
WHERE id = $6 notizen = COALESCE($6, notizen)
WHERE id = $7
RETURNING *`, 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; if (result.rows.length === 0) return null;
const item = result.rows[0]; 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; return item;
} catch (error) { } catch (error) {
logger.error('BestellungService.updateLineItem failed', { error, id }); 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; if (item.rows.length === 0) return false;
await pool.query(`DELETE FROM bestellpositionen WHERE id = $1`, [id]); 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; return true;
} catch (error) { } catch (error) {
logger.error('BestellungService.deleteLineItem failed', { error, id }); 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; if (result.rows.length === 0) return null;
const item = result.rows[0]; 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 // Check if all items for this order are fully received
const allItems = await pool.query( const allItems = await pool.query(
@@ -477,15 +479,15 @@ async function getFilesByOrder(bestellungId: number) {
// Reminders (Bestellung Erinnerungen) // 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 { try {
const result = await pool.query( const result = await pool.query(
`INSERT INTO bestellung_erinnerungen (bestellung_id, titel, faellig_am, notizen, erstellt_von) `INSERT INTO bestellung_erinnerungen (bestellung_id, faellig_am, nachricht, erstellt_von)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4)
RETURNING *`, 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]; return result.rows[0];
} catch (error) { } catch (error) {
logger.error('BestellungService.addReminder failed', { error, bestellungId }); 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; if (result.rows.length === 0) return null;
const reminder = result.rows[0]; 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; return reminder;
} catch (error) { } catch (error) {
logger.error('BestellungService.markReminderDone failed', { error, id }); logger.error('BestellungService.markReminderDone failed', { error, id });
@@ -526,7 +528,7 @@ async function deleteReminder(id: number) {
async function getDueReminders() { async function getDueReminders() {
try { try {
const result = await pool.query( 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 FROM bestellung_erinnerungen e
JOIN bestellungen b ON b.id = e.bestellung_id JOIN bestellungen b ON b.id = e.bestellung_id
WHERE e.faellig_am <= NOW() AND e.erledigt = FALSE 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) { async function logAction(bestellungId: number, aktion: string, details: string, userId: string) {
try { try {
await pool.query( await pool.query(
`INSERT INTO bestellung_historie (bestellung_id, benutzer_id, aktion, details) `INSERT INTO bestellung_historie (bestellung_id, erstellt_von, aktion, details)
VALUES ($1, $2, $3, $4)`, VALUES ($1, $2, $3, $4::jsonb)`,
[bestellungId, userId, aktion, details] [bestellungId, userId, aktion, JSON.stringify({ text: details })]
); );
} catch (error) { } catch (error) {
logger.error('BestellungService.logAction failed', { error, bestellungId, aktion }); logger.error('BestellungService.logAction failed', { error, bestellungId, aktion });
@@ -561,7 +563,7 @@ async function getHistory(bestellungId: number) {
const result = await pool.query( const result = await pool.query(
`SELECT h.*, u.display_name AS benutzer_name `SELECT h.*, u.display_name AS benutzer_name
FROM bestellung_historie h 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 WHERE h.bestellung_id = $1
ORDER BY h.erstellt_am DESC`, ORDER BY h.erstellt_am DESC`,
[bestellungId] [bestellungId]

View File

@@ -201,12 +201,14 @@ class BookingService {
return rows.length > 0; return rows.length > 0;
} }
/** Creates a new booking. Throws if the vehicle has a conflicting booking or is out of service. */ /** 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): Promise<FahrzeugBuchung> { async create(data: CreateBuchungData, userId: string, ignoreOutOfService = false): Promise<FahrzeugBuchung> {
if (!ignoreOutOfService) {
const outOfService = await this.checkOutOfServiceConflict(data.fahrzeugId, data.beginn, data.ende); const outOfService = await this.checkOutOfServiceConflict(data.fahrzeugId, data.beginn, data.ende);
if (outOfService) { if (outOfService) {
throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst'); throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst');
} }
}
const hasConflict = await this.checkConflict( const hasConflict = await this.checkConflict(
data.fahrzeugId, data.fahrzeugId,
@@ -219,9 +221,9 @@ class BookingService {
const query = ` const query = `
INSERT INTO fahrzeug_buchungen 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 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 RETURNING id
`; `;
@@ -235,6 +237,7 @@ class BookingService {
userId, userId,
data.kontaktPerson ?? null, data.kontaktPerson ?? null,
data.kontaktTelefon ?? null, data.kontaktTelefon ?? null,
data.ganztaegig ?? false,
]); ]);
const newId: string = rows[0].id; const newId: string = rows[0].id;

View File

@@ -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<CleanupResult> {
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<CleanupResult> {
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<CleanupResult> {
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<CleanupResult> {
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<CleanupResult> {
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<CleanupResult> {
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<CleanupResult> {
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();

View File

@@ -256,6 +256,13 @@ class EquipmentService {
updatedBy: string updatedBy: string
): Promise<void> { ): Promise<void> {
try { 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( const result = await pool.query(
`UPDATE ausruestung `UPDATE ausruestung
SET status = $1, status_bemerkung = $2, updated_at = NOW() SET status = $1, status_bemerkung = $2, updated_at = NOW()
@@ -268,6 +275,15 @@ class EquipmentService {
throw new Error('Equipment not found'); 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 }); logger.info('Equipment status updated', { id, status, by: updatedBy });
} catch (error) { } catch (error) {
logger.error('EquipmentService.updateStatus failed', { error, id }); logger.error('EquipmentService.updateStatus failed', { error, id });
@@ -422,6 +438,48 @@ class EquipmentService {
throw new Error('Failed to fetch upcoming inspections'); 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(); export default new EquipmentService();

View File

@@ -390,60 +390,63 @@ class EventsService {
* Capped at 100 instances and 2 years from the start date. */ * Capped at 100 instances and 2 years from the start date. */
private generateRecurrenceDates(startDate: Date, _endDate: Date, config: WiederholungConfig): Date[] { private generateRecurrenceDates(startDate: Date, _endDate: Date, config: WiederholungConfig): Date[] {
const dates: 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; const interval = config.intervall ?? 1;
// Cap at 100 instances max, and 2 years // Cap at 100 instances max, and 2 years
const maxDate = new Date(startDate); const maxDate = new Date(startDate);
maxDate.setFullYear(maxDate.getFullYear() + 2); maxDate.setUTCFullYear(maxDate.getUTCFullYear() + 2);
const effectiveLimit = limitDate < maxDate ? limitDate : maxDate; const effectiveLimit = limitDate < maxDate ? limitDate : maxDate;
let current = new Date(startDate); // Work in UTC to avoid timezone shifts
const originalDay = startDate.getDate(); let currentMs = startDate.getTime();
const originalDay = startDate.getUTCDate();
const startHours = startDate.getUTCHours();
const startMinutes = startDate.getUTCMinutes();
while (dates.length < 100) { while (dates.length < 100) {
let current = new Date(currentMs);
// Advance to next occurrence // Advance to next occurrence
switch (config.typ) { switch (config.typ) {
case 'wöchentlich': case 'wöchentlich':
current = new Date(current); current.setUTCDate(current.getUTCDate() + 7 * interval);
current.setDate(current.getDate() + 7 * interval);
break; break;
case 'zweiwöchentlich': case 'zweiwöchentlich':
current = new Date(current); current.setUTCDate(current.getUTCDate() + 14);
current.setDate(current.getDate() + 14);
break; break;
case 'monatlich_datum': { case 'monatlich_datum': {
current = new Date(current); const targetMonth = current.getUTCMonth() + interval;
const targetMonth = current.getMonth() + 1; current.setUTCDate(1);
current.setDate(1); current.setUTCMonth(targetMonth);
current.setMonth(targetMonth); const lastDay = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() + 1, 0)).getUTCDate();
const lastDay = new Date(current.getFullYear(), current.getMonth() + 1, 0).getDate(); current.setUTCDate(Math.min(originalDay, lastDay));
current.setDate(Math.min(originalDay, lastDay)); current.setUTCHours(startHours, startMinutes, 0, 0);
break; break;
} }
case 'monatlich_erster_wochentag': { case 'monatlich_erster_wochentag': {
const targetWeekday = config.wochentag ?? 0; // 0=Mon const targetWeekday = config.wochentag ?? 0; // 0=Mon
current = new Date(current); current.setUTCMonth(current.getUTCMonth() + 1);
current.setMonth(current.getMonth() + 1); current.setUTCDate(1);
current.setDate(1);
// Convert JS Sunday=0 to Monday=0: (getDay()+6)%7 // Convert JS Sunday=0 to Monday=0: (getDay()+6)%7
while ((current.getDay() + 6) % 7 !== targetWeekday) { while ((current.getUTCDay() + 6) % 7 !== targetWeekday) {
current.setDate(current.getDate() + 1); current.setUTCDate(current.getUTCDate() + 1);
} }
current.setUTCHours(startHours, startMinutes, 0, 0);
break; break;
} }
case 'monatlich_letzter_wochentag': { case 'monatlich_letzter_wochentag': {
const targetWeekday = config.wochentag ?? 0; const targetWeekday = config.wochentag ?? 0;
current = new Date(current);
// Go to last day of next month // Go to last day of next month
current.setMonth(current.getMonth() + 2); current.setUTCMonth(current.getUTCMonth() + 2);
current.setDate(0); current.setUTCDate(0);
while ((current.getDay() + 6) % 7 !== targetWeekday) { while ((current.getUTCDay() + 6) % 7 !== targetWeekday) {
current.setDate(current.getDate() - 1); current.setUTCDate(current.getUTCDate() - 1);
} }
current.setUTCHours(startHours, startMinutes, 0, 0);
break; break;
} }
} }
if (current > effectiveLimit) break; if (current > effectiveLimit) break;
currentMs = current.getTime();
dates.push(new Date(current)); dates.push(new Date(current));
} }
return dates; return dates;
@@ -515,13 +518,11 @@ class EventsService {
* Hard-deletes an event (and any recurrence children) from the database. * Hard-deletes an event (and any recurrence children) from the database.
* Returns true if the event was found and deleted, false if not found. * Returns true if the event was found and deleted, false if not found.
*/ */
async deleteEvent(id: string): Promise<boolean> { async deleteEvent(id: string, mode: 'all' | 'single' | 'future' = 'all'): Promise<boolean> {
logger.info('Hard-deleting event', { id }); logger.info('Hard-deleting event', { id, mode });
// Delete recurrence children first (wiederholung_parent_id references)
await pool.query( if (mode === 'single') {
`DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`, // Delete only this single instance
[id]
);
const result = await pool.query( const result = await pool.query(
`DELETE FROM veranstaltungen WHERE id = $1`, `DELETE FROM veranstaltungen WHERE id = $1`,
[id] [id]
@@ -529,6 +530,55 @@ class EventsService {
return (result.rowCount ?? 0) > 0; 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`,
[parentId]
);
return (result.rowCount ?? 0) > 0;
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// ICAL TOKEN // ICAL TOKEN
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -603,9 +653,9 @@ class EventsService {
FROM ( FROM (
SELECT unnest(authentik_groups) AS group_name SELECT unnest(authentik_groups) AS group_name
FROM users FROM users
WHERE is_active = true WHERE authentik_groups IS NOT NULL
) g ) g
WHERE group_name LIKE 'dashboard_%' WHERE group_name != 'dashboard_admin'
ORDER BY group_name` ORDER BY group_name`
); );

View File

@@ -36,17 +36,17 @@ async function createItem(
bezeichnung: string; bezeichnung: string;
beschreibung?: string; beschreibung?: string;
kategorie?: string; kategorie?: string;
geschaetzte_kosten?: number; geschaetzter_preis?: number;
url?: string; url?: string;
aktiv?: boolean; aktiv?: boolean;
}, },
userId: string, userId: string,
) { ) {
const result = await pool.query( 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) VALUES ($1, $2, $3, $4, $5, COALESCE($6, true), $7)
RETURNING *`, 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]; return result.rows[0];
} }
@@ -57,7 +57,7 @@ async function updateItem(
bezeichnung?: string; bezeichnung?: string;
beschreibung?: string; beschreibung?: string;
kategorie?: string; kategorie?: string;
geschaetzte_kosten?: number; geschaetzter_preis?: number;
url?: string; url?: string;
aktiv?: boolean; aktiv?: boolean;
}, },
@@ -78,9 +78,9 @@ async function updateItem(
params.push(data.kategorie); params.push(data.kategorie);
fields.push(`kategorie = $${params.length}`); fields.push(`kategorie = $${params.length}`);
} }
if (data.geschaetzte_kosten !== undefined) { if (data.geschaetzter_preis !== undefined) {
params.push(data.geschaetzte_kosten); params.push(data.geschaetzter_preis);
fields.push(`geschaetzte_kosten = $${params.length}`); fields.push(`geschaetzter_preis = $${params.length}`);
} }
if (data.url !== undefined) { if (data.url !== undefined) {
params.push(data.url); params.push(data.url);

View File

@@ -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'); await client.query('COMMIT');
logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy }); 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'); 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(); export default new VehicleService();

View File

@@ -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<Record<string, SectionState>>(() =>
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<SectionState>) => {
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 (
<Box sx={{ maxWidth: 800 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Datenverwaltung</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Alte Daten bereinigen, um die Datenbank schlank zu halten. Geloeschte Daten koennen nicht wiederhergestellt werden.
</Typography>
{SECTIONS.map((section, idx) => {
const s = states[section.key];
return (
<Paper key={section.key} sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" fontWeight={600}>{section.label}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{section.description}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<TextField
label="Aelter als (Tage)"
type="number"
size="small"
value={s.days}
onChange={e => {
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 }}
/>
<Button
variant="outlined"
size="small"
onClick={() => handlePreview(section.key)}
disabled={s.loading}
startIcon={s.loading ? <CircularProgress size={16} /> : undefined}
>
Vorschau
</Button>
{s.previewCount !== null && (
<>
<Alert severity={s.previewCount > 0 ? 'warning' : 'info'} sx={{ py: 0 }}>
{s.previewCount} {s.previewCount === 1 ? 'Eintrag' : 'Eintraege'} gefunden
</Alert>
{s.previewCount > 0 && (
<Button
variant="contained"
color="error"
size="small"
startIcon={<DeleteSweepIcon />}
onClick={() => setConfirmDialog({ key: section.key, label: section.label, count: s.previewCount! })}
>
Loeschen
</Button>
)}
</>
)}
</Box>
{idx < SECTIONS.length - 1 && <Divider sx={{ mt: 2, display: 'none' }} />}
</Paper>
);
})}
<Dialog open={!!confirmDialog} onClose={() => !deleting && setConfirmDialog(null)}>
<DialogTitle>Daten loeschen?</DialogTitle>
<DialogContent>
<DialogContentText>
{confirmDialog && (
<>
<strong>{confirmDialog.count}</strong> {confirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{confirmDialog.label}</strong> werden
unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
</>
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog(null)} disabled={deleting}>Abbrechen</Button>
<Button
onClick={handleDelete}
color="error"
variant="contained"
disabled={deleting}
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteSweepIcon />}
>
{deleting ? 'Wird geloescht...' : 'Endgueltig loeschen'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -64,6 +64,7 @@ const adminSubItems: SubItem[] = [
{ text: 'FDISK Sync', path: '/admin?tab=6' }, { text: 'FDISK Sync', path: '/admin?tab=6' },
{ text: 'Berechtigungen', path: '/admin?tab=7' }, { text: 'Berechtigungen', path: '/admin?tab=7' },
{ text: 'Bestellungen', path: '/admin?tab=8' }, { text: 'Bestellungen', path: '/admin?tab=8' },
{ text: 'Datenverwaltung', path: '/admin?tab=9' },
]; ];
const baseNavigationItems: NavigationItem[] = [ const baseNavigationItems: NavigationItem[] = [

View File

@@ -11,6 +11,7 @@ import ServiceModeTab from '../components/admin/ServiceModeTab';
import FdiskSyncTab from '../components/admin/FdiskSyncTab'; import FdiskSyncTab from '../components/admin/FdiskSyncTab';
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab'; import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
import BestellungenTab from '../components/admin/BestellungenTab'; import BestellungenTab from '../components/admin/BestellungenTab';
import DataManagementTab from '../components/admin/DataManagementTab';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
interface TabPanelProps { interface TabPanelProps {
@@ -24,7 +25,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
return <Box sx={{ pt: 3 }}>{children}</Box>; return <Box sx={{ pt: 3 }}>{children}</Box>;
} }
const ADMIN_TAB_COUNT = 9; const ADMIN_TAB_COUNT = 10;
function AdminDashboard() { function AdminDashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -59,6 +60,7 @@ function AdminDashboard() {
<Tab label="FDISK Sync" /> <Tab label="FDISK Sync" />
<Tab label="Berechtigungen" /> <Tab label="Berechtigungen" />
<Tab label="Bestellungen" /> <Tab label="Bestellungen" />
<Tab label="Datenverwaltung" />
</Tabs> </Tabs>
</Box> </Box>
@@ -89,6 +91,9 @@ function AdminDashboard() {
<TabPanel value={tab} index={8}> <TabPanel value={tab} index={8}>
<BestellungenTab /> <BestellungenTab />
</TabPanel> </TabPanel>
<TabPanel value={tab} index={9}>
<DataManagementTab />
</TabPanel>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -89,6 +89,8 @@ export default function Bestellungen() {
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
const [orderDialogOpen, setOrderDialogOpen] = useState(false); const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const [orderForm, setOrderForm] = useState<BestellungFormData>({ ...emptyOrderForm }); const [orderForm, setOrderForm] = useState<BestellungFormData>({ ...emptyOrderForm });
const [inlineVendorOpen, setInlineVendorOpen] = useState(false);
const [inlineVendorForm, setInlineVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
const [vendorDialogOpen, setVendorDialogOpen] = useState(false); const [vendorDialogOpen, setVendorDialogOpen] = useState(false);
const [vendorForm, setVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm }); const [vendorForm, setVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
@@ -122,10 +124,17 @@ export default function Bestellungen() {
const createVendor = useMutation({ const createVendor = useMutation({
mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data), mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data),
onSuccess: () => { onSuccess: (newVendor) => {
queryClient.invalidateQueries({ queryKey: ['lieferanten'] }); queryClient.invalidateQueries({ queryKey: ['lieferanten'] });
showSuccess('Lieferant erstellt'); showSuccess('Lieferant erstellt');
// 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(); closeVendorDialog();
}
}, },
onError: () => showError('Fehler beim Erstellen des Lieferanten'), onError: () => showError('Fehler beim Erstellen des Lieferanten'),
}); });
@@ -264,14 +273,6 @@ export default function Bestellungen() {
{/* ── Tab 1: Vendors ── */} {/* ── Tab 1: Vendors ── */}
<TabPanel value={tab} index={1}> <TabPanel value={tab} index={1}>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
{hasPermission('bestellungen:create') && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setVendorDialogOpen(true)}>
Lieferant hinzufügen
</Button>
)}
</Box>
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
@@ -315,6 +316,12 @@ export default function Bestellungen() {
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
{hasPermission('bestellungen:manage_vendors') && (
<ChatAwareFab onClick={() => setVendorDialogOpen(true)} aria-label="Lieferant hinzufügen">
<AddIcon />
</ChatAwareFab>
)}
</TabPanel> </TabPanel>
{/* ── Create Order Dialog ── */} {/* ── Create Order Dialog ── */}
@@ -327,13 +334,38 @@ export default function Bestellungen() {
value={orderForm.bezeichnung} value={orderForm.bezeichnung}
onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))} onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))}
/> />
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Autocomplete <Autocomplete
options={vendors} options={vendors}
getOptionLabel={(o) => o.name} getOptionLabel={(o) => o.name}
value={vendors.find((v) => v.id === orderForm.lieferant_id) || null} value={vendors.find((v) => v.id === orderForm.lieferant_id) || null}
onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))} onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))}
renderInput={(params) => <TextField {...params} label="Lieferant" />} renderInput={(params) => <TextField {...params} label="Lieferant" />}
sx={{ flexGrow: 1 }}
/> />
<Tooltip title="Neuen Lieferant anlegen">
<IconButton
onClick={() => setInlineVendorOpen(!inlineVendorOpen)}
color={inlineVendorOpen ? 'primary' : 'default'}
sx={{ mt: 1 }}
>
<AddIcon />
</IconButton>
</Tooltip>
</Box>
{inlineVendorOpen && (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="subtitle2">Neuer Lieferant</Typography>
<TextField size="small" label="Name *" value={inlineVendorForm.name} onChange={(e) => setInlineVendorForm(f => ({ ...f, name: e.target.value }))} />
<TextField size="small" label="Kontakt-Name" value={inlineVendorForm.kontakt_name || ''} onChange={(e) => setInlineVendorForm(f => ({ ...f, kontakt_name: e.target.value }))} />
<TextField size="small" label="E-Mail" value={inlineVendorForm.email || ''} onChange={(e) => setInlineVendorForm(f => ({ ...f, email: e.target.value }))} />
<TextField size="small" label="Telefon" value={inlineVendorForm.telefon || ''} onChange={(e) => setInlineVendorForm(f => ({ ...f, telefon: e.target.value }))} />
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button size="small" onClick={() => { setInlineVendorOpen(false); setInlineVendorForm({ ...emptyVendorForm }); }}>Abbrechen</Button>
<Button size="small" variant="contained" onClick={() => createVendor.mutate(inlineVendorForm)} disabled={!inlineVendorForm.name.trim() || createVendor.isPending}>Anlegen</Button>
</Box>
</Paper>
)}
<TextField <TextField
label="Besteller" label="Besteller"
value={orderForm.besteller_id || ''} value={orderForm.besteller_id || ''}

View File

@@ -21,11 +21,13 @@ import {
Select, Select,
MenuItem, MenuItem,
FormControl, FormControl,
FormControlLabel,
InputLabel, InputLabel,
CircularProgress, CircularProgress,
Alert, Alert,
Popover, Popover,
Stack, Stack,
Switch,
Tooltip, Tooltip,
} from '@mui/material'; } from '@mui/material';
import { import {
@@ -83,6 +85,7 @@ const EMPTY_FORM: CreateBuchungInput = {
buchungsArt: 'intern', buchungsArt: 'intern',
kontaktPerson: '', kontaktPerson: '',
kontaktTelefon: '', kontaktTelefon: '',
ganztaegig: false,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -218,6 +221,7 @@ function FahrzeugBuchungen() {
const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM }); const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM });
const [dialogLoading, setDialogLoading] = useState(false); const [dialogLoading, setDialogLoading] = useState(false);
const [dialogError, setDialogError] = useState<string | null>(null); const [dialogError, setDialogError] = useState<string | null>(null);
const [overrideOutOfService, setOverrideOutOfService] = useState(false);
const [availability, setAvailability] = useState<{ const [availability, setAvailability] = useState<{
available: boolean; available: boolean;
reason?: string; reason?: string;
@@ -254,6 +258,7 @@ function FahrzeugBuchungen() {
setForm({ ...EMPTY_FORM }); setForm({ ...EMPTY_FORM });
setDialogError(null); setDialogError(null);
setAvailability(null); setAvailability(null);
setOverrideOutOfService(false);
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -265,6 +270,7 @@ function FahrzeugBuchungen() {
setForm({ ...EMPTY_FORM, fahrzeugId: vehicleId, beginn: dateStr, ende: dateEndStr }); setForm({ ...EMPTY_FORM, fahrzeugId: vehicleId, beginn: dateStr, ende: dateEndStr });
setDialogError(null); setDialogError(null);
setAvailability(null); setAvailability(null);
setOverrideOutOfService(false);
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -276,27 +282,33 @@ function FahrzeugBuchungen() {
...form, ...form,
beginn: new Date(form.beginn).toISOString(), beginn: new Date(form.beginn).toISOString(),
ende: new Date(form.ende).toISOString(), ende: new Date(form.ende).toISOString(),
ganztaegig: form.ganztaegig || false,
}; };
if (editingBooking) { if (editingBooking) {
await bookingApi.update(editingBooking.id, payload); await bookingApi.update(editingBooking.id, payload);
notification.showSuccess('Buchung aktualisiert'); notification.showSuccess('Buchung aktualisiert');
} else { } else {
await bookingApi.create(payload); await bookingApi.create({ ...payload, ignoreOutOfService: overrideOutOfService } as any);
notification.showSuccess('Buchung erstellt'); notification.showSuccess('Buchung erstellt');
} }
setDialogOpen(false); setDialogOpen(false);
loadData(); loadData();
} catch (e: unknown) { } catch (e: unknown) {
try {
const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string }; const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string };
if (axiosError?.response?.status === 409) { if (axiosError?.response?.status === 409) {
const reason = axiosError?.response?.data?.reason; const reason = axiosError?.response?.data?.reason;
if (reason === 'out_of_service') { if (reason === 'out_of_service') {
setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst'); setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst');
} else { } else {
setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum bereits gebucht');
} }
} else { } else {
setDialogError(axiosError?.message || 'Fehler beim Speichern'); const msg = axiosError?.response?.data?.message || axiosError?.message || 'Fehler beim Speichern';
setDialogError(msg);
}
} catch {
setDialogError(e instanceof Error ? e.message : 'Fehler beim Speichern');
} }
} finally { } finally {
setDialogLoading(false); setDialogLoading(false);
@@ -495,11 +507,6 @@ function FahrzeugBuchungen() {
<Typography variant="body2" fontWeight={600}> <Typography variant="body2" fontWeight={600}>
{vehicle.bezeichnung} {vehicle.bezeichnung}
</Typography> </Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="caption" color="text.secondary">
{vehicle.amtliches_kennzeichen}
</Typography>
)}
</TableCell> </TableCell>
{weekDays.map((day) => { {weekDays.map((day) => {
const cellBookings = getBookingsForCell(vehicle.id, day); const cellBookings = getBookingsForCell(vehicle.id, day);
@@ -774,16 +781,39 @@ function FahrzeugBuchungen() {
} }
/> />
<FormControlLabel
control={
<Switch
checked={form.ganztaegig || false}
onChange={(e) => {
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"
/>
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
label="Beginn" label="Beginn"
type="datetime-local" type={form.ganztaegig ? 'date' : 'datetime-local'}
required required
value={form.beginn} value={form.ganztaegig ? (form.beginn?.split('T')[0] || '') : form.beginn}
onChange={(e) => onChange={(e) => {
setForm((f) => ({ ...f, beginn: e.target.value })) if (form.ganztaegig) {
setForm((f) => ({ ...f, beginn: `${e.target.value}T00:00` }));
} else {
setForm((f) => ({ ...f, beginn: e.target.value }));
} }
}}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
@@ -791,12 +821,16 @@ function FahrzeugBuchungen() {
fullWidth fullWidth
size="small" size="small"
label="Ende" label="Ende"
type="datetime-local" type={form.ganztaegig ? 'date' : 'datetime-local'}
required required
value={form.ende} value={form.ganztaegig ? (form.ende?.split('T')[0] || '') : form.ende}
onChange={(e) => onChange={(e) => {
setForm((f) => ({ ...f, ende: e.target.value })) if (form.ganztaegig) {
setForm((f) => ({ ...f, ende: `${e.target.value}T23:59` }));
} else {
setForm((f) => ({ ...f, ende: e.target.value }));
} }
}}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
@@ -818,6 +852,7 @@ function FahrzeugBuchungen() {
size="small" size="small"
/> />
) : availability.reason === 'out_of_service' ? ( ) : availability.reason === 'out_of_service' ? (
<Box>
<Chip <Chip
icon={<Block />} icon={<Block />}
label={ label={
@@ -828,6 +863,23 @@ function FahrzeugBuchungen() {
color="error" color="error"
size="small" size="small"
/> />
<FormControlLabel
control={
<Switch
checked={overrideOutOfService}
onChange={(e) => setOverrideOutOfService(e.target.checked)}
color="warning"
size="small"
/>
}
label={
<Typography variant="body2" color="warning.main">
Trotz Außer-Dienst-Status buchen
</Typography>
}
sx={{ mt: 0.5 }}
/>
</Box>
) : ( ) : (
<Chip <Chip
icon={<Warning />} icon={<Warning />}

View File

@@ -43,6 +43,7 @@ import {
DirectionsCar, DirectionsCar,
Edit, Edit,
Error as ErrorIcon, Error as ErrorIcon,
History,
LocalFireDepartment, LocalFireDepartment,
MoreHoriz, MoreHoriz,
PauseCircle, PauseCircle,
@@ -121,6 +122,58 @@ function fmtDatetime(iso: string | Date | null | undefined): string {
return fmtDate(iso ? new Date(iso).toISOString() : null); 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 (
<>
<Typography variant="h6" sx={{ mt: 3, mb: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
<History fontSize="small" /> Status-Historie
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Datum</TableCell>
<TableCell>Von</TableCell>
<TableCell>Nach</TableCell>
<TableCell>Bemerkung</TableCell>
<TableCell>Geändert von</TableCell>
</TableRow>
</TableHead>
<TableBody>
{history.map((h, idx) => (
<TableRow key={idx}>
<TableCell>{fmtDatetime(h.erstellt_am)}</TableCell>
<TableCell>
<Chip size="small" label={FahrzeugStatusLabel[h.alter_status as FahrzeugStatus] || h.alter_status} color={STATUS_CHIP_COLOR[h.alter_status as FahrzeugStatus] || 'default'} />
</TableCell>
<TableCell>
<Chip size="small" label={FahrzeugStatusLabel[h.neuer_status as FahrzeugStatus] || h.neuer_status} color={STATUS_CHIP_COLOR[h.neuer_status as FahrzeugStatus] || 'default'} />
</TableCell>
<TableCell>{h.bemerkung || '—'}</TableCell>
<TableCell>{h.geaendert_von_name || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
};
// ── Übersicht Tab ───────────────────────────────────────────────────────────── // ── Übersicht Tab ─────────────────────────────────────────────────────────────
interface UebersichtTabProps { interface UebersichtTabProps {
@@ -148,7 +201,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
const openDialog = () => { const openDialog = () => {
setNewStatus(vehicle.status); setNewStatus(vehicle.status);
setBemerkung(vehicle.status_bemerkung ?? ''); setBemerkung('');
setAusserDienstVon( setAusserDienstVon(
vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : '' vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : ''
); );
@@ -323,6 +376,9 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
})} })}
</Grid> </Grid>
{/* Status history */}
<StatusHistorySection vehicleId={vehicle.id} />
{/* Status change dialog */} {/* Status change dialog */}
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth> <Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>Fahrzeugstatus ändern</DialogTitle> <DialogTitle>Fahrzeugstatus ändern</DialogTitle>
@@ -475,6 +531,42 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
entry.externe_werkstatt && entry.externe_werkstatt, entry.externe_werkstatt && entry.externe_werkstatt,
].filter(Boolean).join(' · ')} ].filter(Boolean).join(' · ')}
</Typography> </Typography>
{entry.dokument_url ? (
<Chip
label="Dokument"
size="small"
color="info"
variant="outlined"
component="a"
href={`/api/uploads/${entry.dokument_url.split('/uploads/')[1] || entry.dokument_url}`}
target="_blank"
clickable
sx={{ mt: 0.5 }}
/>
) : canWrite ? (
<Button
size="small"
component="label"
sx={{ mt: 0.5, textTransform: 'none', fontSize: '0.75rem' }}
>
Dokument hochladen
<input
type="file"
hidden
accept=".pdf,.doc,.docx,.jpg,.png"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await vehiclesApi.uploadWartungFile(entry.id, file);
onAdded();
} catch {
// silent fail — user can retry
}
}}
/>
</Button>
) : null}
</Box> </Box>
</Box> </Box>
); );

View File

@@ -167,8 +167,6 @@ function FahrzeugForm() {
hersteller: form.hersteller.trim() || null, hersteller: form.hersteller.trim() || null,
typ_schluessel: form.typ_schluessel.trim() || null, typ_schluessel: form.typ_schluessel.trim() || null,
besatzung_soll: form.besatzung_soll.trim() || null, besatzung_soll: form.besatzung_soll.trim() || null,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || null,
standort: form.standort.trim() || 'Feuerwehrhaus', standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || null, bild_url: form.bild_url.trim() || null,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || null, paragraph57a_faellig_am: form.paragraph57a_faellig_am || null,
@@ -186,8 +184,6 @@ function FahrzeugForm() {
hersteller: form.hersteller.trim() || undefined, hersteller: form.hersteller.trim() || undefined,
typ_schluessel: form.typ_schluessel.trim() || undefined, typ_schluessel: form.typ_schluessel.trim() || undefined,
besatzung_soll: form.besatzung_soll.trim() || undefined, besatzung_soll: form.besatzung_soll.trim() || undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus', standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined, bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined, paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
@@ -285,32 +281,6 @@ function FahrzeugForm() {
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value as FahrzeugStatus }))}
>
{Object.values(FahrzeugStatus).map((s) => (
<MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Status-Bemerkung"
fullWidth
{...f('status_bemerkung')}
placeholder="z.B. Fahrzeug in Werkstatt bis 01.03."
/>
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüf- und Wartungsfristen</Typography> <Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüf- und Wartungsfristen</Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>

View File

@@ -236,8 +236,8 @@ function KatalogTab() {
{/* Artikel create/edit dialog */} {/* Artikel create/edit dialog */}
<Dialog open={artikelDialogOpen} onClose={() => setArtikelDialogOpen(false)} maxWidth="sm" fullWidth> <Dialog open={artikelDialogOpen} onClose={() => setArtikelDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'}</DialogTitle> <DialogTitle>{editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}> <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
<TextField label="Bezeichnung" required value={artikelForm.bezeichnung} onChange={e => setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} /> <TextField label="Bezeichnung" required value={artikelForm.bezeichnung} onChange={e => setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth />
<TextField label="Beschreibung" multiline rows={2} value={artikelForm.beschreibung ?? ''} onChange={e => setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} /> <TextField label="Beschreibung" multiline rows={2} value={artikelForm.beschreibung ?? ''} onChange={e => setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} />
<Autocomplete <Autocomplete
freeSolo freeSolo

View File

@@ -300,7 +300,7 @@ function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps
export default function VeranstaltungKategorien() { export default function VeranstaltungKategorien() {
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
const canManage = hasPermission('kalender:manage_categories'); const canManage = hasPermission('kalender:create');
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]); const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
const [groups, setGroups] = useState<GroupInfo[]>([]); const [groups, setGroups] = useState<GroupInfo[]>([]);

View File

@@ -21,8 +21,6 @@ import {
InputLabel, InputLabel,
FormControlLabel, FormControlLabel,
Switch, Switch,
Checkbox,
FormGroup,
Stack, Stack,
List, List,
ListItem, ListItem,
@@ -34,6 +32,9 @@ import {
useTheme, useTheme,
useMediaQuery, useMediaQuery,
Snackbar, Snackbar,
Autocomplete,
Radio,
RadioGroup,
} from '@mui/material'; } from '@mui/material';
import { import {
Add, Add,
@@ -61,6 +62,7 @@ import type {
GroupInfo, GroupInfo,
CreateVeranstaltungInput, CreateVeranstaltungInput,
ConflictEvent, ConflictEvent,
WiederholungConfig,
} from '../types/events.types'; } from '../types/events.types';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -667,16 +669,6 @@ function EventFormDialog({
setForm((prev) => ({ ...prev, [field]: value })); 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 () => { const handleSave = async () => {
if (!form.titel.trim()) { if (!form.titel.trim()) {
notification.showError('Titel ist erforderlich'); notification.showError('Titel ist erforderlich');
@@ -866,28 +858,33 @@ function EventFormDialog({
label="Für alle Mitglieder sichtbar" label="Für alle Mitglieder sichtbar"
/> />
{/* Zielgruppen checkboxes */} {/* Zielgruppen multi-select */}
{!form.alle_gruppen && groups.length > 0 && ( {!form.alle_gruppen && groups.length > 0 && (
<Box> <Autocomplete
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}> multiple
Zielgruppen options={groups}
</Typography> getOptionLabel={(option) => option.label}
<FormGroup> value={groups.filter((g) => form.zielgruppen.includes(g.id))}
{groups.map((g) => ( onChange={(_, newValue) => {
<FormControlLabel handleChange('zielgruppen', newValue.map((g) => g.id));
key={g.id} }}
control={ isOptionEqualToValue={(option, value) => option.id === value.id}
<Checkbox renderInput={(params) => (
checked={form.zielgruppen.includes(g.id)} <TextField {...params} label="Zielgruppen" placeholder="Gruppen auswählen" />
onChange={() => handleGroupToggle(g.id)} )}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option.id}
label={option.label}
size="small" size="small"
/> />
))
} }
label={g.label} size="small"
disableCloseOnSelect
/> />
))}
</FormGroup>
</Box>
)} )}
<Divider /> <Divider />
@@ -929,6 +926,103 @@ function EventFormDialog({
inputProps={{ min: 1 }} inputProps={{ min: 1 }}
fullWidth fullWidth
/> />
{/* Recurrence / Wiederholung — only for new events */}
{!editingEvent && (
<>
<Divider />
<FormControlLabel
control={
<Switch
checked={Boolean(form.wiederholung)}
onChange={(e) => {
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 && (
<Stack spacing={2} sx={{ pl: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Häufigkeit</InputLabel>
<Select
label="Häufigkeit"
value={form.wiederholung.typ}
onChange={(e) => {
const w = { ...form.wiederholung!, typ: e.target.value as WiederholungConfig['typ'] };
handleChange('wiederholung', w);
}}
>
<MenuItem value="wöchentlich">Wöchentlich</MenuItem>
<MenuItem value="zweiwöchentlich">Zweiwöchentlich</MenuItem>
<MenuItem value="monatlich_datum">Monatlich (gleicher Tag)</MenuItem>
<MenuItem value="monatlich_erster_wochentag">Monatlich (erster Wochentag)</MenuItem>
<MenuItem value="monatlich_letzter_wochentag">Monatlich (letzter Wochentag)</MenuItem>
</Select>
</FormControl>
{form.wiederholung.typ === 'wöchentlich' && (
<TextField
label="Alle X Wochen"
type="number"
size="small"
value={form.wiederholung.intervall ?? 1}
onChange={(e) => {
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') && (
<FormControl fullWidth size="small">
<InputLabel>Wochentag</InputLabel>
<Select
label="Wochentag"
value={form.wiederholung.wochentag ?? 0}
onChange={(e) => {
const w = { ...form.wiederholung!, wochentag: Number(e.target.value) };
handleChange('wiederholung', w);
}}
>
{WEEKDAY_LABELS.map((label, idx) => (
<MenuItem key={idx} value={idx}>{label === 'Mo' ? 'Montag' : label === 'Di' ? 'Dienstag' : label === 'Mi' ? 'Mittwoch' : label === 'Do' ? 'Donnerstag' : label === 'Fr' ? 'Freitag' : label === 'Sa' ? 'Samstag' : 'Sonntag'}</MenuItem>
))}
</Select>
</FormControl>
)}
<TextField
label="Wiederholen bis"
type="date"
size="small"
value={form.wiederholung.bis}
onChange={(e) => {
const w = { ...form.wiederholung!, bis: e.target.value };
handleChange('wiederholung', w);
}}
InputLabelProps={{ shrink: true }}
fullWidth
helperText="Enddatum der Wiederholungsserie"
/>
</Stack>
)}
</>
)}
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@@ -1105,6 +1199,7 @@ export default function Veranstaltungen() {
// Delete dialog // Delete dialog
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteMode, setDeleteMode] = useState<'all' | 'single' | 'future'>('all');
// iCal dialog // iCal dialog
const [icalOpen, setIcalOpen] = useState(false); const [icalOpen, setIcalOpen] = useState(false);
@@ -1215,8 +1310,9 @@ export default function Veranstaltungen() {
if (!deleteId) return; if (!deleteId) return;
setDeleteLoading(true); setDeleteLoading(true);
try { try {
await eventsApi.deleteEvent(deleteId); await eventsApi.deleteEvent(deleteId, deleteMode);
setDeleteId(null); setDeleteId(null);
setDeleteMode('all');
loadData(); loadData();
notification.showSuccess('Veranstaltung wurde gelöscht'); notification.showSuccess('Veranstaltung wurde gelöscht');
} catch (e: unknown) { } catch (e: unknown) {
@@ -1373,7 +1469,12 @@ export default function Veranstaltungen() {
canWrite={canWrite} canWrite={canWrite}
onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }} onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }}
onCancel={(id) => { setCancelId(id); setCancelGrund(''); }} 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);
}}
/> />
</Paper> </Paper>
)} )}
@@ -1444,15 +1545,38 @@ export default function Veranstaltungen() {
</Dialog> </Dialog>
{/* Delete Dialog */} {/* Delete Dialog */}
<Dialog open={Boolean(deleteId)} onClose={() => setDeleteId(null)} maxWidth="xs" fullWidth> <Dialog open={Boolean(deleteId)} onClose={() => { setDeleteId(null); setDeleteMode('all'); }} maxWidth="xs" fullWidth>
<DialogTitle>Veranstaltung endgültig löschen</DialogTitle> <DialogTitle>Veranstaltung endgültig löschen</DialogTitle>
<DialogContent> <DialogContent>
{(() => {
const deleteEvent = events.find((ev) => ev.id === deleteId);
const isRecurring = deleteEvent && (deleteEvent.wiederholung_parent_id || deleteEvent.wiederholung);
if (isRecurring) {
return (
<>
<DialogContentText sx={{ mb: 2 }}>
Diese Veranstaltung ist Teil einer Wiederholungsserie. Was soll gelöscht werden?
</DialogContentText>
<RadioGroup
value={deleteMode}
onChange={(e) => setDeleteMode(e.target.value as 'all' | 'single' | 'future')}
>
<FormControlLabel value="single" control={<Radio />} label="Nur diesen Termin" />
<FormControlLabel value="future" control={<Radio />} label="Diesen und alle folgenden Termine" />
<FormControlLabel value="all" control={<Radio />} label="Alle Termine der Serie" />
</RadioGroup>
</>
);
}
return (
<DialogContentText> <DialogContentText>
Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden. Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText> </DialogContentText>
);
})()}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setDeleteId(null)}>Abbrechen</Button> <Button onClick={() => { setDeleteId(null); setDeleteMode('all'); }}>Abbrechen</Button>
<Button variant="contained" color="error" onClick={handleDeleteEvent} disabled={deleteLoading}> <Button variant="contained" color="error" onClick={handleDeleteEvent} disabled={deleteLoading}>
{deleteLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'} {deleteLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'}
</Button> </Button>

View File

@@ -131,8 +131,8 @@ export const eventsApi = {
}, },
/** Hard-delete an event permanently */ /** Hard-delete an event permanently */
deleteEvent(id: string): Promise<void> { deleteEvent(id: string, mode: 'all' | 'single' | 'future' = 'all'): Promise<void> {
return api.post(`/api/events/${id}/delete`).then(() => undefined); return api.post(`/api/events/${id}/delete`, { mode }).then(() => undefined);
}, },
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -100,4 +100,17 @@ export const vehiclesApi = {
}); });
return response.data as Blob; 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<any> {
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;
},
}; };

View File

@@ -71,4 +71,5 @@ export interface CreateBuchungInput {
buchungsArt: BuchungsArt; buchungsArt: BuchungsArt;
kontaktPerson?: string | null; kontaktPerson?: string | null;
kontaktTelefon?: string | null; kontaktTelefon?: string | null;
ganztaegig?: boolean;
} }

View File

@@ -58,6 +58,7 @@ export interface FahrzeugWartungslog {
kraftstoff_liter: number | null; kraftstoff_liter: number | null;
kosten: number | null; kosten: number | null;
externe_werkstatt: string | null; externe_werkstatt: string | null;
dokument_url: string | null;
erfasst_von: string | null; erfasst_von: string | null;
created_at: string; created_at: string;
} }

View File

@@ -65,6 +65,23 @@ function mapDienstgrad(raw: string): string | null {
return 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( export async function syncToDatabase(
pool: Pool, pool: Pool,
members: FdiskMember[], members: FdiskMember[],
@@ -362,6 +379,13 @@ async function syncFahrgenehmigungen(
} }
for (const f of fahrgenehmigungen) { 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 }>( const result = await client.query<{ user_id: string }>(
`SELECT user_id FROM mitglieder_profile WHERE fdisk_standesbuch_nr = $1`, `SELECT user_id FROM mitglieder_profile WHERE fdisk_standesbuch_nr = $1`,
[f.standesbuchNr] [f.standesbuchNr]

View File

@@ -241,9 +241,25 @@ export async function scrapeAll(username: string, password: string, knownStNrs:
const idPersonen = urlObj.searchParams.get('id_personen'); const idPersonen = urlObj.searchParams.get('id_personen');
const idInstanzen = urlObj.searchParams.get('id_instanzen') ?? ID_INSTANZEN; const idInstanzen = urlObj.searchParams.get('id_instanzen') ?? ID_INSTANZEN;
// Ausbildungen — disabled: requires different page/approach (TODO) // Ausbildungen
// const quals = await scrapeAusbildungenFromDetailPage(mainFrame, member, idMitgliedschaft, idPersonen); if (idMitgliedschaft && idPersonen) {
// ausbildungen.push(...quals); 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 // Beförderungen
const befos = (idMitgliedschaft && idPersonen) const befos = (idMitgliedschaft && idPersonen)
@@ -1034,20 +1050,110 @@ async function scrapeMemberUntersuchungen(
+ `?search=1&searchid_mitgliedschaften=${idMitgliedschaft}&id_personen=${idPersonen}` + `?search=1&searchid_mitgliedschaften=${idMitgliedschaft}&id_personen=${idPersonen}`
+ `&id_mitgliedschaften=${idMitgliedschaft}&searchid_personen=${idPersonen}&searchid_maskmode=`; + `&id_mitgliedschaften=${idMitgliedschaft}&searchid_personen=${idPersonen}&searchid_maskmode=`;
const result = await navigateAndGetTableRows(frame, url); // Always dump for diagnosis when debug is on
if (!result) return []; 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[] = []; const results: FdiskUntersuchung[] = [];
for (const row of rows) { for (const row of dataRows) {
// Collect non-empty values from columns after the date column
const valueCols: string[] = []; const valueCols: string[] = [];
for (let ci = dateColIdx + 1; ci < row.cells.length; ci++) { for (let ci = dateColIdx + 1; ci < row.cells.length; ci++) {
const v = cellText(row.cells[ci]); const v = cellText(row.cells[ci]);
if (v !== null) valueCols.push(v); 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 anmerkungen = valueCols[0] ?? null;
const art = valueCols[1] ?? null; const art = valueCols[1] ?? null;
const ergebnis = valueCols[2] ?? null; const ergebnis = valueCols[2] ?? null;