new features
This commit is contained in:
@@ -120,6 +120,7 @@ class AuthController {
|
||||
});
|
||||
|
||||
await userService.updateGroups(user.id, groups);
|
||||
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
|
||||
await memberService.ensureProfileExists(user.id);
|
||||
|
||||
// Audit: first-ever login (user record creation)
|
||||
@@ -168,6 +169,7 @@ class AuthController {
|
||||
|
||||
await userService.updateLastLogin(user.id);
|
||||
await userService.updateGroups(user.id, groups);
|
||||
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
|
||||
await memberService.ensureProfileExists(user.id);
|
||||
|
||||
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
|
||||
|
||||
@@ -113,9 +113,9 @@ class BestellungController {
|
||||
}
|
||||
|
||||
async createOrder(req: Request, res: Response): Promise<void> {
|
||||
const { titel } = req.body;
|
||||
if (!titel || typeof titel !== 'string' || titel.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Titel ist erforderlich' });
|
||||
const { bezeichnung } = req.body;
|
||||
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -203,9 +203,9 @@ class BestellungController {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' });
|
||||
return;
|
||||
}
|
||||
const { artikel, menge } = req.body;
|
||||
if (!artikel || typeof artikel !== 'string' || artikel.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Artikel ist erforderlich' });
|
||||
const { bezeichnung, menge } = req.body;
|
||||
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
|
||||
return;
|
||||
}
|
||||
if (menge === undefined || menge === null || menge <= 0) {
|
||||
@@ -364,11 +364,7 @@ class BestellungController {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' });
|
||||
return;
|
||||
}
|
||||
const { titel, faellig_am } = req.body;
|
||||
if (!titel || typeof titel !== 'string' || titel.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Titel ist erforderlich' });
|
||||
return;
|
||||
}
|
||||
const { nachricht, faellig_am } = req.body;
|
||||
if (!faellig_am) {
|
||||
res.status(400).json({ success: false, message: 'Fälligkeitsdatum ist erforderlich' });
|
||||
return;
|
||||
|
||||
@@ -158,7 +158,7 @@ class BookingController {
|
||||
handleZodError(res, parsed.error);
|
||||
return;
|
||||
}
|
||||
const booking = await bookingService.create(parsed.data, req.user!.id);
|
||||
const booking = await bookingService.create(parsed.data, req.user!.id, req.body.ignoreOutOfService === true);
|
||||
res.status(201).json({ success: true, data: booking });
|
||||
} catch (error: any) {
|
||||
if (handleConflictError(res, error)) return;
|
||||
|
||||
@@ -391,6 +391,36 @@ class EquipmentController {
|
||||
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusHistory(req: Request, res: Response): Promise<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();
|
||||
|
||||
@@ -304,7 +304,12 @@ class EventsController {
|
||||
deleteEvent = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
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) {
|
||||
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||
return;
|
||||
|
||||
@@ -372,6 +372,36 @@ class VehicleController {
|
||||
res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusHistory(req: Request, res: Response): Promise<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();
|
||||
|
||||
@@ -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;
|
||||
@@ -56,4 +56,39 @@ const multerOptions: any = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const uploadBestellung: any = multer(multerOptions);
|
||||
|
||||
export { UPLOAD_DIR, THUMBNAIL_DIR };
|
||||
// ── Wartungslog uploads (vehicle/equipment service reports) ──────────────────
|
||||
|
||||
const WARTUNG_DIR = path.join(APP_ROOT, 'uploads', 'wartung');
|
||||
try {
|
||||
if (!fs.existsSync(WARTUNG_DIR)) {
|
||||
fs.mkdirSync(WARTUNG_DIR, { recursive: true });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Could not create wartung upload directory`, { err });
|
||||
}
|
||||
|
||||
const wartungStorage = multer.diskStorage({
|
||||
destination(_req: any, _file: any, cb: any) {
|
||||
cb(null, WARTUNG_DIR);
|
||||
},
|
||||
filename(_req: any, file: any, cb: any) {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uniqueSuffix}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const uploadWartung: any = multer({
|
||||
storage: wartungStorage,
|
||||
fileFilter(_req: any, file: any, cb: any) {
|
||||
if (ALLOWED_TYPES.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Dateityp ${file.mimetype} ist nicht erlaubt.`));
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
export { UPLOAD_DIR, THUMBNAIL_DIR, WARTUNG_DIR };
|
||||
|
||||
@@ -66,6 +66,7 @@ const BuchungBaseSchema = z.object({
|
||||
buchungsArt: z.enum(BUCHUNGS_ARTEN).default('intern'),
|
||||
kontaktPerson: z.string().max(255).optional().nullable(),
|
||||
kontaktTelefon: z.string().max(50).optional().nullable(),
|
||||
ganztaegig: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export const CreateBuchungSchema = BuchungBaseSchema.refine(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Admin API Routes — Audit Log
|
||||
* Admin API Routes — Audit Log + Data Cleanup
|
||||
*
|
||||
* GET /api/admin/audit-log — paginated, filtered list
|
||||
* GET /api/admin/audit-log/export — CSV download of filtered results
|
||||
* DELETE /api/admin/cleanup/:target — preview / delete old data
|
||||
*
|
||||
* Both endpoints require authentication + admin:access permission.
|
||||
*
|
||||
@@ -18,6 +19,7 @@ import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
import { auditExport } from '../middleware/audit.middleware';
|
||||
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
|
||||
import cleanupService from '../services/cleanup.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
@@ -213,4 +215,53 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup / Data Management endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cleanupBodySchema = z.object({
|
||||
olderThanDays: z.number().int().min(1).max(3650),
|
||||
confirm: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
type CleanupTarget = 'notifications' | 'audit-log' | 'events' | 'bookings' | 'orders' | 'vehicle-history' | 'equipment-history';
|
||||
|
||||
const CLEANUP_TARGETS: Record<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;
|
||||
|
||||
@@ -2,18 +2,20 @@ import { Router } from 'express';
|
||||
import equipmentController from '../controllers/equipment.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
import { uploadWartung } from '../middleware/upload';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Read-only (any authenticated user) ───────────────────────────────────────
|
||||
|
||||
router.get('/', authenticate, equipmentController.listEquipment.bind(equipmentController));
|
||||
router.get('/stats', authenticate, equipmentController.getStats.bind(equipmentController));
|
||||
router.get('/alerts', authenticate, equipmentController.getAlerts.bind(equipmentController));
|
||||
router.get('/categories', authenticate, equipmentController.getCategories.bind(equipmentController));
|
||||
router.get('/', authenticate, requirePermission('ausruestung:view'), equipmentController.listEquipment.bind(equipmentController));
|
||||
router.get('/stats', authenticate, requirePermission('ausruestung:view'), equipmentController.getStats.bind(equipmentController));
|
||||
router.get('/alerts', authenticate, requirePermission('ausruestung:view'), equipmentController.getAlerts.bind(equipmentController));
|
||||
router.get('/categories', authenticate, requirePermission('ausruestung:view'), equipmentController.getCategories.bind(equipmentController));
|
||||
router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleWarnings.bind(equipmentController));
|
||||
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
|
||||
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
|
||||
router.get('/:id/status-history', authenticate, equipmentController.getStatusHistory.bind(equipmentController));
|
||||
|
||||
// ── Write — gruppenfuehrer+ ────────────────────────────────────────────────
|
||||
|
||||
@@ -21,6 +23,7 @@ router.post('/', authenticate, requirePermission('ausruestung:create')
|
||||
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
|
||||
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController));
|
||||
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:create'), equipmentController.addWartung.bind(equipmentController));
|
||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:create'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController));
|
||||
|
||||
// ── Delete — admin only ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,17 +2,19 @@ import { Router } from 'express';
|
||||
import vehicleController from '../controllers/vehicle.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
import { uploadWartung } from '../middleware/upload';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Read-only (any authenticated user) ───────────────────────────────────────
|
||||
|
||||
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
||||
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
||||
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
|
||||
router.get('/', authenticate, requirePermission('fahrzeuge:view'), vehicleController.listVehicles.bind(vehicleController));
|
||||
router.get('/stats', authenticate, requirePermission('fahrzeuge:view'), vehicleController.getStats.bind(vehicleController));
|
||||
router.get('/alerts', authenticate, requirePermission('fahrzeuge:view'), vehicleController.getAlerts.bind(vehicleController));
|
||||
router.get('/alerts/export', authenticate, requirePermission('fahrzeuge:view'), vehicleController.exportAlerts.bind(vehicleController));
|
||||
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
||||
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
||||
router.get('/:id/status-history', authenticate, vehicleController.getStatusHistory.bind(vehicleController));
|
||||
|
||||
// ── Write — kommandant+ ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,5 +26,6 @@ router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehic
|
||||
|
||||
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.addWartung.bind(vehicleController));
|
||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:change_status'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -35,13 +35,13 @@ async function getVendorById(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createVendor(data: { name: string; kontakt_person?: string; email?: string; telefon?: string; adresse?: string; notizen?: string }, userId: string) {
|
||||
async function createVendor(data: { name: string; kontakt_name?: string; email?: string; telefon?: string; adresse?: string; website?: string; notizen?: string }, userId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO lieferanten (name, kontakt_person, email, telefon, adresse, notizen, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`INSERT INTO lieferanten (name, kontakt_name, email, telefon, adresse, website, notizen, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[data.name, data.kontakt_person || null, data.email || null, data.telefon || null, data.adresse || null, data.notizen || null, userId]
|
||||
[data.name, data.kontakt_name || null, data.email || null, data.telefon || null, data.adresse || null, data.website || null, data.notizen || null, userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
@@ -50,20 +50,21 @@ async function createVendor(data: { name: string; kontakt_person?: string; email
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVendor(id: number, data: { name?: string; kontakt_person?: string; email?: string; telefon?: string; adresse?: string; notizen?: string }, userId: string) {
|
||||
async function updateVendor(id: number, data: { name?: string; kontakt_name?: string; email?: string; telefon?: string; adresse?: string; website?: string; notizen?: string }, userId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE lieferanten
|
||||
SET name = COALESCE($1, name),
|
||||
kontakt_person = COALESCE($2, kontakt_person),
|
||||
kontakt_name = COALESCE($2, kontakt_name),
|
||||
email = COALESCE($3, email),
|
||||
telefon = COALESCE($4, telefon),
|
||||
adresse = COALESCE($5, adresse),
|
||||
notizen = COALESCE($6, notizen),
|
||||
website = COALESCE($6, website),
|
||||
notizen = COALESCE($7, notizen),
|
||||
aktualisiert_am = NOW()
|
||||
WHERE id = $7
|
||||
WHERE id = $8
|
||||
RETURNING *`,
|
||||
[data.name, data.kontakt_person, data.email, data.telefon, data.adresse, data.notizen, id]
|
||||
[data.name, data.kontakt_name, data.email, data.telefon, data.adresse, data.website, data.notizen, id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
@@ -157,7 +158,7 @@ async function getOrderById(id: number) {
|
||||
pool.query(`SELECT * FROM bestellpositionen WHERE bestellung_id = $1 ORDER BY id`, [id]),
|
||||
pool.query(`SELECT * FROM bestellung_dateien WHERE bestellung_id = $1 ORDER BY hochgeladen_am DESC`, [id]),
|
||||
pool.query(`SELECT * FROM bestellung_erinnerungen WHERE bestellung_id = $1 ORDER BY faellig_am`, [id]),
|
||||
pool.query(`SELECT h.*, u.display_name AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.benutzer_id WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]),
|
||||
pool.query(`SELECT h.*, u.display_name AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -173,16 +174,16 @@ async function getOrderById(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrder(data: { titel: string; lieferant_id?: number; beschreibung?: string; prioritaet?: string }, userId: string) {
|
||||
async function createOrder(data: { bezeichnung: string; lieferant_id?: number; notizen?: string; budget?: number }, userId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO bestellungen (titel, lieferant_id, beschreibung, prioritaet, erstellt_von)
|
||||
`INSERT INTO bestellungen (bezeichnung, lieferant_id, notizen, budget, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[data.titel, data.lieferant_id || null, data.beschreibung || null, data.prioritaet || 'normal', userId]
|
||||
[data.bezeichnung, data.lieferant_id || null, data.notizen || null, data.budget || null, userId]
|
||||
);
|
||||
const order = result.rows[0];
|
||||
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.titel}" erstellt`, userId);
|
||||
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
|
||||
return order;
|
||||
} catch (error) {
|
||||
logger.error('BestellungService.createOrder failed', { error });
|
||||
@@ -190,7 +191,7 @@ async function createOrder(data: { titel: string; lieferant_id?: number; beschre
|
||||
}
|
||||
}
|
||||
|
||||
async function updateOrder(id: number, data: { titel?: string; lieferant_id?: number; beschreibung?: string; prioritaet?: string; status?: string }, userId: string) {
|
||||
async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_id?: number; notizen?: string; budget?: number; status?: string }, userId: string) {
|
||||
try {
|
||||
// Check current order for status change detection
|
||||
const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]);
|
||||
@@ -213,25 +214,25 @@ async function updateOrder(id: number, data: { titel?: string; lieferant_id?: nu
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE bestellungen
|
||||
SET titel = COALESCE($1, titel),
|
||||
SET bezeichnung = COALESCE($1, bezeichnung),
|
||||
lieferant_id = COALESCE($2, lieferant_id),
|
||||
beschreibung = COALESCE($3, beschreibung),
|
||||
prioritaet = COALESCE($4, prioritaet),
|
||||
notizen = COALESCE($3, notizen),
|
||||
budget = COALESCE($4, budget),
|
||||
status = COALESCE($5, status),
|
||||
bestellt_am = $6,
|
||||
abgeschlossen_am = $7,
|
||||
aktualisiert_am = NOW()
|
||||
WHERE id = $8
|
||||
RETURNING *`,
|
||||
[data.titel, data.lieferant_id, data.beschreibung, data.prioritaet, data.status, bestellt_am, abgeschlossen_am, id]
|
||||
[data.bezeichnung, data.lieferant_id, data.notizen, data.budget, data.status, bestellt_am, abgeschlossen_am, id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const changes: string[] = [];
|
||||
if (data.titel) changes.push(`Titel geändert`);
|
||||
if (data.bezeichnung) changes.push(`Bezeichnung geändert`);
|
||||
if (data.lieferant_id) changes.push(`Lieferant geändert`);
|
||||
if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus} → ${data.status}`);
|
||||
if (data.prioritaet) changes.push(`Priorität geändert`);
|
||||
if (data.budget) changes.push(`Budget geändert`);
|
||||
|
||||
await logAction(id, 'Bestellung aktualisiert', changes.join(', ') || 'Bestellung bearbeitet', userId);
|
||||
return result.rows[0];
|
||||
@@ -275,12 +276,12 @@ async function deleteOrder(id: number, _userId: string) {
|
||||
}
|
||||
|
||||
const VALID_STATUS_TRANSITIONS: Record<string, string[]> = {
|
||||
entwurf: ['bestellt', 'storniert'],
|
||||
bestellt: ['teillieferung', 'vollstaendig', 'storniert'],
|
||||
teillieferung: ['vollstaendig', 'storniert'],
|
||||
entwurf: ['erstellt', 'bestellt'],
|
||||
erstellt: ['bestellt'],
|
||||
bestellt: ['teillieferung', 'vollstaendig'],
|
||||
teillieferung: ['vollstaendig'],
|
||||
vollstaendig: ['abgeschlossen'],
|
||||
abgeschlossen: [],
|
||||
storniert: ['entwurf'],
|
||||
};
|
||||
|
||||
async function updateOrderStatus(id: number, status: string, userId: string) {
|
||||
@@ -323,15 +324,15 @@ async function updateOrderStatus(id: number, status: string, userId: string) {
|
||||
// Line Items (Bestellpositionen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function addLineItem(bestellungId: number, data: { artikel: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
|
||||
async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO bestellpositionen (bestellung_id, artikel, menge, einheit, einzelpreis, notizen)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[bestellungId, data.artikel, data.menge, data.einheit || 'Stück', data.einzelpreis || 0, data.notizen || null]
|
||||
[bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null]
|
||||
);
|
||||
await logAction(bestellungId, 'Position hinzugefügt', `"${data.artikel}" x${data.menge}`, userId);
|
||||
await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('BestellungService.addLineItem failed', { error, bestellungId });
|
||||
@@ -339,23 +340,24 @@ async function addLineItem(bestellungId: number, data: { artikel: string; menge:
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLineItem(id: number, data: { artikel?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
|
||||
async function updateLineItem(id: number, data: { bezeichnung?: string; artikelnummer?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE bestellpositionen
|
||||
SET artikel = COALESCE($1, artikel),
|
||||
menge = COALESCE($2, menge),
|
||||
einheit = COALESCE($3, einheit),
|
||||
einzelpreis = COALESCE($4, einzelpreis),
|
||||
notizen = COALESCE($5, notizen)
|
||||
WHERE id = $6
|
||||
SET bezeichnung = COALESCE($1, bezeichnung),
|
||||
artikelnummer = COALESCE($2, artikelnummer),
|
||||
menge = COALESCE($3, menge),
|
||||
einheit = COALESCE($4, einheit),
|
||||
einzelpreis = COALESCE($5, einzelpreis),
|
||||
notizen = COALESCE($6, notizen)
|
||||
WHERE id = $7
|
||||
RETURNING *`,
|
||||
[data.artikel, data.menge, data.einheit, data.einzelpreis, data.notizen, id]
|
||||
[data.bezeichnung, data.artikelnummer, data.menge, data.einheit, data.einzelpreis, data.notizen, id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const item = result.rows[0];
|
||||
await logAction(item.bestellung_id, 'Position aktualisiert', `"${item.artikel}" bearbeitet`, userId);
|
||||
await logAction(item.bestellung_id, 'Position aktualisiert', `"${item.bezeichnung}" bearbeitet`, userId);
|
||||
return item;
|
||||
} catch (error) {
|
||||
logger.error('BestellungService.updateLineItem failed', { error, id });
|
||||
@@ -369,7 +371,7 @@ async function deleteLineItem(id: number, userId: string) {
|
||||
if (item.rows.length === 0) return false;
|
||||
|
||||
await pool.query(`DELETE FROM bestellpositionen WHERE id = $1`, [id]);
|
||||
await logAction(item.rows[0].bestellung_id, 'Position entfernt', `"${item.rows[0].artikel}" entfernt`, userId);
|
||||
await logAction(item.rows[0].bestellung_id, 'Position entfernt', `"${item.rows[0].bezeichnung}" entfernt`, userId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('BestellungService.deleteLineItem failed', { error, id });
|
||||
@@ -386,7 +388,7 @@ async function updateReceivedQuantity(id: number, menge: number, userId: string)
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const item = result.rows[0];
|
||||
await logAction(item.bestellung_id, 'Liefermenge aktualisiert', `"${item.artikel}": ${menge} von ${item.menge} erhalten`, userId);
|
||||
await logAction(item.bestellung_id, 'Liefermenge aktualisiert', `"${item.bezeichnung}": ${menge} von ${item.menge} erhalten`, userId);
|
||||
|
||||
// Check if all items for this order are fully received
|
||||
const allItems = await pool.query(
|
||||
@@ -477,15 +479,15 @@ async function getFilesByOrder(bestellungId: number) {
|
||||
// Reminders (Bestellung Erinnerungen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function addReminder(bestellungId: number, data: { titel: string; faellig_am: string; notizen?: string }, userId: string) {
|
||||
async function addReminder(bestellungId: number, data: { nachricht: string; faellig_am: string }, userId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO bestellung_erinnerungen (bestellung_id, titel, faellig_am, notizen, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`INSERT INTO bestellung_erinnerungen (bestellung_id, faellig_am, nachricht, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[bestellungId, data.titel, data.faellig_am, data.notizen || null, userId]
|
||||
[bestellungId, data.faellig_am, data.nachricht || null, userId]
|
||||
);
|
||||
await logAction(bestellungId, 'Erinnerung erstellt', `"${data.titel}" fällig am ${data.faellig_am}`, userId);
|
||||
await logAction(bestellungId, 'Erinnerung erstellt', `Erinnerung fällig am ${data.faellig_am}`, userId);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('BestellungService.addReminder failed', { error, bestellungId });
|
||||
@@ -502,7 +504,7 @@ async function markReminderDone(id: number, userId: string) {
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const reminder = result.rows[0];
|
||||
await logAction(reminder.bestellung_id, 'Erinnerung erledigt', `"${reminder.titel}"`, userId);
|
||||
await logAction(reminder.bestellung_id, 'Erinnerung erledigt', `Erinnerung #${reminder.id}`, userId);
|
||||
return reminder;
|
||||
} catch (error) {
|
||||
logger.error('BestellungService.markReminderDone failed', { error, id });
|
||||
@@ -526,7 +528,7 @@ async function deleteReminder(id: number) {
|
||||
async function getDueReminders() {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT e.*, b.titel AS bestellung_titel, b.erstellt_von AS besteller_id
|
||||
`SELECT e.*, b.bezeichnung AS bestellung_bezeichnung, b.erstellt_von AS besteller_id
|
||||
FROM bestellung_erinnerungen e
|
||||
JOIN bestellungen b ON b.id = e.bestellung_id
|
||||
WHERE e.faellig_am <= NOW() AND e.erledigt = FALSE
|
||||
@@ -546,9 +548,9 @@ async function getDueReminders() {
|
||||
async function logAction(bestellungId: number, aktion: string, details: string, userId: string) {
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO bestellung_historie (bestellung_id, benutzer_id, aktion, details)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[bestellungId, userId, aktion, details]
|
||||
`INSERT INTO bestellung_historie (bestellung_id, erstellt_von, aktion, details)
|
||||
VALUES ($1, $2, $3, $4::jsonb)`,
|
||||
[bestellungId, userId, aktion, JSON.stringify({ text: details })]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('BestellungService.logAction failed', { error, bestellungId, aktion });
|
||||
@@ -561,7 +563,7 @@ async function getHistory(bestellungId: number) {
|
||||
const result = await pool.query(
|
||||
`SELECT h.*, u.display_name AS benutzer_name
|
||||
FROM bestellung_historie h
|
||||
LEFT JOIN users u ON u.id = h.benutzer_id
|
||||
LEFT JOIN users u ON u.id = h.erstellt_von
|
||||
WHERE h.bestellung_id = $1
|
||||
ORDER BY h.erstellt_am DESC`,
|
||||
[bestellungId]
|
||||
|
||||
@@ -201,12 +201,14 @@ class BookingService {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Creates a new booking. Throws if the vehicle has a conflicting booking or is out of service. */
|
||||
async create(data: CreateBuchungData, userId: string): Promise<FahrzeugBuchung> {
|
||||
/** Creates a new booking. Throws if the vehicle has a conflicting booking or is out of service (unless overridden). */
|
||||
async create(data: CreateBuchungData, userId: string, ignoreOutOfService = false): Promise<FahrzeugBuchung> {
|
||||
if (!ignoreOutOfService) {
|
||||
const outOfService = await this.checkOutOfServiceConflict(data.fahrzeugId, data.beginn, data.ende);
|
||||
if (outOfService) {
|
||||
throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst');
|
||||
}
|
||||
}
|
||||
|
||||
const hasConflict = await this.checkConflict(
|
||||
data.fahrzeugId,
|
||||
@@ -219,9 +221,9 @@ class BookingService {
|
||||
|
||||
const query = `
|
||||
INSERT INTO fahrzeug_buchungen
|
||||
(fahrzeug_id, titel, beschreibung, beginn, ende, buchungs_art, gebucht_von, kontakt_person, kontakt_telefon)
|
||||
(fahrzeug_id, titel, beschreibung, beginn, ende, buchungs_art, gebucht_von, kontakt_person, kontakt_telefon, ganztaegig)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6::fahrzeug_buchung_art, $7, $8, $9)
|
||||
($1, $2, $3, $4, $5, $6::fahrzeug_buchung_art, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -235,6 +237,7 @@ class BookingService {
|
||||
userId,
|
||||
data.kontaktPerson ?? null,
|
||||
data.kontaktTelefon ?? null,
|
||||
data.ganztaegig ?? false,
|
||||
]);
|
||||
|
||||
const newId: string = rows[0].id;
|
||||
|
||||
131
backend/src/services/cleanup.service.ts
Normal file
131
backend/src/services/cleanup.service.ts
Normal 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();
|
||||
@@ -256,6 +256,13 @@ class EquipmentService {
|
||||
updatedBy: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get old status for history
|
||||
const oldResult = await pool.query(
|
||||
`SELECT status FROM ausruestung WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id]
|
||||
);
|
||||
const oldStatus = oldResult.rows[0]?.status;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE ausruestung
|
||||
SET status = $1, status_bemerkung = $2, updated_at = NOW()
|
||||
@@ -268,6 +275,15 @@ class EquipmentService {
|
||||
throw new Error('Equipment not found');
|
||||
}
|
||||
|
||||
// Record status change history
|
||||
if (oldStatus && oldStatus !== status) {
|
||||
await pool.query(
|
||||
`INSERT INTO ausruestung_status_historie (ausruestung_id, alter_status, neuer_status, bemerkung, geaendert_von)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[id, oldStatus, status, bemerkung || null, updatedBy]
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Equipment status updated', { id, status, by: updatedBy });
|
||||
} catch (error) {
|
||||
logger.error('EquipmentService.updateStatus failed', { error, id });
|
||||
@@ -422,6 +438,48 @@ class EquipmentService {
|
||||
throw new Error('Failed to fetch upcoming inspections');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STATUS HISTORY
|
||||
// =========================================================================
|
||||
|
||||
async getStatusHistory(equipmentId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT h.*, u.display_name AS geaendert_von_name
|
||||
FROM ausruestung_status_historie h
|
||||
LEFT JOIN users u ON u.id = h.geaendert_von
|
||||
WHERE h.ausruestung_id = $1
|
||||
ORDER BY h.erstellt_am DESC
|
||||
LIMIT 50`,
|
||||
[equipmentId]
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('EquipmentService.getStatusHistory failed', { error, equipmentId });
|
||||
throw new Error('Status-Historie konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WARTUNGSLOG FILE UPLOAD
|
||||
// =========================================================================
|
||||
|
||||
async updateWartungslogFile(wartungId: number, filePath: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE ausruestung_wartungslog SET dokument_url = $1 WHERE id = $2 RETURNING *`,
|
||||
[filePath, wartungId]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Wartungseintrag nicht gefunden');
|
||||
}
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('EquipmentService.updateWartungslogFile failed', { error, wartungId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new EquipmentService();
|
||||
|
||||
@@ -390,60 +390,63 @@ class EventsService {
|
||||
* Capped at 100 instances and 2 years from the start date. */
|
||||
private generateRecurrenceDates(startDate: Date, _endDate: Date, config: WiederholungConfig): Date[] {
|
||||
const dates: Date[] = [];
|
||||
const limitDate = new Date(config.bis);
|
||||
const limitDate = config.bis ? new Date(config.bis + 'T23:59:59Z') : new Date(0);
|
||||
const interval = config.intervall ?? 1;
|
||||
// Cap at 100 instances max, and 2 years
|
||||
const maxDate = new Date(startDate);
|
||||
maxDate.setFullYear(maxDate.getFullYear() + 2);
|
||||
maxDate.setUTCFullYear(maxDate.getUTCFullYear() + 2);
|
||||
const effectiveLimit = limitDate < maxDate ? limitDate : maxDate;
|
||||
|
||||
let current = new Date(startDate);
|
||||
const originalDay = startDate.getDate();
|
||||
// Work in UTC to avoid timezone shifts
|
||||
let currentMs = startDate.getTime();
|
||||
const originalDay = startDate.getUTCDate();
|
||||
const startHours = startDate.getUTCHours();
|
||||
const startMinutes = startDate.getUTCMinutes();
|
||||
|
||||
while (dates.length < 100) {
|
||||
let current = new Date(currentMs);
|
||||
// Advance to next occurrence
|
||||
switch (config.typ) {
|
||||
case 'wöchentlich':
|
||||
current = new Date(current);
|
||||
current.setDate(current.getDate() + 7 * interval);
|
||||
current.setUTCDate(current.getUTCDate() + 7 * interval);
|
||||
break;
|
||||
case 'zweiwöchentlich':
|
||||
current = new Date(current);
|
||||
current.setDate(current.getDate() + 14);
|
||||
current.setUTCDate(current.getUTCDate() + 14);
|
||||
break;
|
||||
case 'monatlich_datum': {
|
||||
current = new Date(current);
|
||||
const targetMonth = current.getMonth() + 1;
|
||||
current.setDate(1);
|
||||
current.setMonth(targetMonth);
|
||||
const lastDay = new Date(current.getFullYear(), current.getMonth() + 1, 0).getDate();
|
||||
current.setDate(Math.min(originalDay, lastDay));
|
||||
const targetMonth = current.getUTCMonth() + interval;
|
||||
current.setUTCDate(1);
|
||||
current.setUTCMonth(targetMonth);
|
||||
const lastDay = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() + 1, 0)).getUTCDate();
|
||||
current.setUTCDate(Math.min(originalDay, lastDay));
|
||||
current.setUTCHours(startHours, startMinutes, 0, 0);
|
||||
break;
|
||||
}
|
||||
case 'monatlich_erster_wochentag': {
|
||||
const targetWeekday = config.wochentag ?? 0; // 0=Mon
|
||||
current = new Date(current);
|
||||
current.setMonth(current.getMonth() + 1);
|
||||
current.setDate(1);
|
||||
current.setUTCMonth(current.getUTCMonth() + 1);
|
||||
current.setUTCDate(1);
|
||||
// Convert JS Sunday=0 to Monday=0: (getDay()+6)%7
|
||||
while ((current.getDay() + 6) % 7 !== targetWeekday) {
|
||||
current.setDate(current.getDate() + 1);
|
||||
while ((current.getUTCDay() + 6) % 7 !== targetWeekday) {
|
||||
current.setUTCDate(current.getUTCDate() + 1);
|
||||
}
|
||||
current.setUTCHours(startHours, startMinutes, 0, 0);
|
||||
break;
|
||||
}
|
||||
case 'monatlich_letzter_wochentag': {
|
||||
const targetWeekday = config.wochentag ?? 0;
|
||||
current = new Date(current);
|
||||
// Go to last day of next month
|
||||
current.setMonth(current.getMonth() + 2);
|
||||
current.setDate(0);
|
||||
while ((current.getDay() + 6) % 7 !== targetWeekday) {
|
||||
current.setDate(current.getDate() - 1);
|
||||
current.setUTCMonth(current.getUTCMonth() + 2);
|
||||
current.setUTCDate(0);
|
||||
while ((current.getUTCDay() + 6) % 7 !== targetWeekday) {
|
||||
current.setUTCDate(current.getUTCDate() - 1);
|
||||
}
|
||||
current.setUTCHours(startHours, startMinutes, 0, 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (current > effectiveLimit) break;
|
||||
currentMs = current.getTime();
|
||||
dates.push(new Date(current));
|
||||
}
|
||||
return dates;
|
||||
@@ -515,13 +518,11 @@ class EventsService {
|
||||
* Hard-deletes an event (and any recurrence children) from the database.
|
||||
* Returns true if the event was found and deleted, false if not found.
|
||||
*/
|
||||
async deleteEvent(id: string): Promise<boolean> {
|
||||
logger.info('Hard-deleting event', { id });
|
||||
// Delete recurrence children first (wiederholung_parent_id references)
|
||||
await pool.query(
|
||||
`DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`,
|
||||
[id]
|
||||
);
|
||||
async deleteEvent(id: string, mode: 'all' | 'single' | 'future' = 'all'): Promise<boolean> {
|
||||
logger.info('Hard-deleting event', { id, mode });
|
||||
|
||||
if (mode === 'single') {
|
||||
// Delete only this single instance
|
||||
const result = await pool.query(
|
||||
`DELETE FROM veranstaltungen WHERE id = $1`,
|
||||
[id]
|
||||
@@ -529,6 +530,55 @@ class EventsService {
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -603,9 +653,9 @@ class EventsService {
|
||||
FROM (
|
||||
SELECT unnest(authentik_groups) AS group_name
|
||||
FROM users
|
||||
WHERE is_active = true
|
||||
WHERE authentik_groups IS NOT NULL
|
||||
) g
|
||||
WHERE group_name LIKE 'dashboard_%'
|
||||
WHERE group_name != 'dashboard_admin'
|
||||
ORDER BY group_name`
|
||||
);
|
||||
|
||||
|
||||
@@ -36,17 +36,17 @@ async function createItem(
|
||||
bezeichnung: string;
|
||||
beschreibung?: string;
|
||||
kategorie?: string;
|
||||
geschaetzte_kosten?: number;
|
||||
geschaetzter_preis?: number;
|
||||
url?: string;
|
||||
aktiv?: boolean;
|
||||
},
|
||||
userId: string,
|
||||
) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzte_kosten, url, aktiv, erstellt_von)
|
||||
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, url, aktiv, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true), $7)
|
||||
RETURNING *`,
|
||||
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzte_kosten || null, data.url || null, data.aktiv ?? true, userId],
|
||||
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.url || null, data.aktiv ?? true, userId],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
@@ -57,7 +57,7 @@ async function updateItem(
|
||||
bezeichnung?: string;
|
||||
beschreibung?: string;
|
||||
kategorie?: string;
|
||||
geschaetzte_kosten?: number;
|
||||
geschaetzter_preis?: number;
|
||||
url?: string;
|
||||
aktiv?: boolean;
|
||||
},
|
||||
@@ -78,9 +78,9 @@ async function updateItem(
|
||||
params.push(data.kategorie);
|
||||
fields.push(`kategorie = $${params.length}`);
|
||||
}
|
||||
if (data.geschaetzte_kosten !== undefined) {
|
||||
params.push(data.geschaetzte_kosten);
|
||||
fields.push(`geschaetzte_kosten = $${params.length}`);
|
||||
if (data.geschaetzter_preis !== undefined) {
|
||||
params.push(data.geschaetzter_preis);
|
||||
fields.push(`geschaetzter_preis = $${params.length}`);
|
||||
}
|
||||
if (data.url !== undefined) {
|
||||
params.push(data.url);
|
||||
|
||||
@@ -299,6 +299,15 @@ class VehicleService {
|
||||
]
|
||||
);
|
||||
|
||||
// Record status change history
|
||||
if (oldStatus !== status) {
|
||||
await client.query(
|
||||
`INSERT INTO fahrzeug_status_historie (fahrzeug_id, alter_status, neuer_status, bemerkung, geaendert_von)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[id, oldStatus, status, bemerkung || null, updatedBy]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy });
|
||||
@@ -574,6 +583,48 @@ class VehicleService {
|
||||
throw new Error('Failed to fetch inspection alerts');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STATUS HISTORY
|
||||
// =========================================================================
|
||||
|
||||
async getStatusHistory(fahrzeugId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT h.*, u.display_name AS geaendert_von_name
|
||||
FROM fahrzeug_status_historie h
|
||||
LEFT JOIN users u ON u.id = h.geaendert_von
|
||||
WHERE h.fahrzeug_id = $1
|
||||
ORDER BY h.erstellt_am DESC
|
||||
LIMIT 50`,
|
||||
[fahrzeugId]
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.getStatusHistory failed', { error, fahrzeugId });
|
||||
throw new Error('Status-Historie konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WARTUNGSLOG FILE UPLOAD
|
||||
// =========================================================================
|
||||
|
||||
async updateWartungslogFile(wartungId: number, filePath: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE fahrzeug_wartungslog SET dokument_url = $1 WHERE id = $2 RETURNING *`,
|
||||
[filePath, wartungId]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Wartungseintrag nicht gefunden');
|
||||
}
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.updateWartungslogFile failed', { error, wartungId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new VehicleService();
|
||||
|
||||
170
frontend/src/components/admin/DataManagementTab.tsx
Normal file
170
frontend/src/components/admin/DataManagementTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const adminSubItems: SubItem[] = [
|
||||
{ text: 'FDISK Sync', path: '/admin?tab=6' },
|
||||
{ text: 'Berechtigungen', path: '/admin?tab=7' },
|
||||
{ text: 'Bestellungen', path: '/admin?tab=8' },
|
||||
{ text: 'Datenverwaltung', path: '/admin?tab=9' },
|
||||
];
|
||||
|
||||
const baseNavigationItems: NavigationItem[] = [
|
||||
|
||||
@@ -11,6 +11,7 @@ import ServiceModeTab from '../components/admin/ServiceModeTab';
|
||||
import FdiskSyncTab from '../components/admin/FdiskSyncTab';
|
||||
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
|
||||
import BestellungenTab from '../components/admin/BestellungenTab';
|
||||
import DataManagementTab from '../components/admin/DataManagementTab';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
|
||||
interface TabPanelProps {
|
||||
@@ -24,7 +25,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
|
||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
const ADMIN_TAB_COUNT = 9;
|
||||
const ADMIN_TAB_COUNT = 10;
|
||||
|
||||
function AdminDashboard() {
|
||||
const navigate = useNavigate();
|
||||
@@ -59,6 +60,7 @@ function AdminDashboard() {
|
||||
<Tab label="FDISK Sync" />
|
||||
<Tab label="Berechtigungen" />
|
||||
<Tab label="Bestellungen" />
|
||||
<Tab label="Datenverwaltung" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
@@ -89,6 +91,9 @@ function AdminDashboard() {
|
||||
<TabPanel value={tab} index={8}>
|
||||
<BestellungenTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={9}>
|
||||
<DataManagementTab />
|
||||
</TabPanel>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ export default function Bestellungen() {
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||
const [orderForm, setOrderForm] = useState<BestellungFormData>({ ...emptyOrderForm });
|
||||
const [inlineVendorOpen, setInlineVendorOpen] = useState(false);
|
||||
const [inlineVendorForm, setInlineVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
|
||||
|
||||
const [vendorDialogOpen, setVendorDialogOpen] = useState(false);
|
||||
const [vendorForm, setVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
|
||||
@@ -122,10 +124,17 @@ export default function Bestellungen() {
|
||||
|
||||
const createVendor = useMutation({
|
||||
mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data),
|
||||
onSuccess: () => {
|
||||
onSuccess: (newVendor) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lieferanten'] });
|
||||
showSuccess('Lieferant erstellt');
|
||||
// If inline vendor creation during order creation, auto-select the new vendor
|
||||
if (inlineVendorOpen) {
|
||||
setOrderForm((f) => ({ ...f, lieferant_id: newVendor.id }));
|
||||
setInlineVendorOpen(false);
|
||||
setInlineVendorForm({ ...emptyVendorForm });
|
||||
} else {
|
||||
closeVendorDialog();
|
||||
}
|
||||
},
|
||||
onError: () => showError('Fehler beim Erstellen des Lieferanten'),
|
||||
});
|
||||
@@ -264,14 +273,6 @@ export default function Bestellungen() {
|
||||
|
||||
{/* ── Tab 1: Vendors ── */}
|
||||
<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}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
@@ -315,6 +316,12 @@ export default function Bestellungen() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{hasPermission('bestellungen:manage_vendors') && (
|
||||
<ChatAwareFab onClick={() => setVendorDialogOpen(true)} aria-label="Lieferant hinzufügen">
|
||||
<AddIcon />
|
||||
</ChatAwareFab>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* ── Create Order Dialog ── */}
|
||||
@@ -327,13 +334,38 @@ export default function Bestellungen() {
|
||||
value={orderForm.bezeichnung}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<Autocomplete
|
||||
options={vendors}
|
||||
getOptionLabel={(o) => o.name}
|
||||
value={vendors.find((v) => v.id === orderForm.lieferant_id) || null}
|
||||
onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))}
|
||||
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
|
||||
label="Besteller"
|
||||
value={orderForm.besteller_id || ''}
|
||||
|
||||
@@ -21,11 +21,13 @@ import {
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Popover,
|
||||
Stack,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
@@ -83,6 +85,7 @@ const EMPTY_FORM: CreateBuchungInput = {
|
||||
buchungsArt: 'intern',
|
||||
kontaktPerson: '',
|
||||
kontaktTelefon: '',
|
||||
ganztaegig: false,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -218,6 +221,7 @@ function FahrzeugBuchungen() {
|
||||
const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM });
|
||||
const [dialogLoading, setDialogLoading] = useState(false);
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
const [overrideOutOfService, setOverrideOutOfService] = useState(false);
|
||||
const [availability, setAvailability] = useState<{
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
@@ -254,6 +258,7 @@ function FahrzeugBuchungen() {
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setDialogError(null);
|
||||
setAvailability(null);
|
||||
setOverrideOutOfService(false);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -265,6 +270,7 @@ function FahrzeugBuchungen() {
|
||||
setForm({ ...EMPTY_FORM, fahrzeugId: vehicleId, beginn: dateStr, ende: dateEndStr });
|
||||
setDialogError(null);
|
||||
setAvailability(null);
|
||||
setOverrideOutOfService(false);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -276,27 +282,33 @@ function FahrzeugBuchungen() {
|
||||
...form,
|
||||
beginn: new Date(form.beginn).toISOString(),
|
||||
ende: new Date(form.ende).toISOString(),
|
||||
ganztaegig: form.ganztaegig || false,
|
||||
};
|
||||
if (editingBooking) {
|
||||
await bookingApi.update(editingBooking.id, payload);
|
||||
notification.showSuccess('Buchung aktualisiert');
|
||||
} else {
|
||||
await bookingApi.create(payload);
|
||||
await bookingApi.create({ ...payload, ignoreOutOfService: overrideOutOfService } as any);
|
||||
notification.showSuccess('Buchung erstellt');
|
||||
}
|
||||
setDialogOpen(false);
|
||||
loadData();
|
||||
} catch (e: unknown) {
|
||||
try {
|
||||
const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string };
|
||||
if (axiosError?.response?.status === 409) {
|
||||
const reason = axiosError?.response?.data?.reason;
|
||||
if (reason === 'out_of_service') {
|
||||
setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst');
|
||||
} else {
|
||||
setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
|
||||
setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum bereits gebucht');
|
||||
}
|
||||
} 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 {
|
||||
setDialogLoading(false);
|
||||
@@ -495,11 +507,6 @@ function FahrzeugBuchungen() {
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{vehicle.bezeichnung}
|
||||
</Typography>
|
||||
{vehicle.amtliches_kennzeichen && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{vehicle.amtliches_kennzeichen}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
{weekDays.map((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
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Beginn"
|
||||
type="datetime-local"
|
||||
type={form.ganztaegig ? 'date' : 'datetime-local'}
|
||||
required
|
||||
value={form.beginn}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, beginn: e.target.value }))
|
||||
value={form.ganztaegig ? (form.beginn?.split('T')[0] || '') : form.beginn}
|
||||
onChange={(e) => {
|
||||
if (form.ganztaegig) {
|
||||
setForm((f) => ({ ...f, beginn: `${e.target.value}T00:00` }));
|
||||
} else {
|
||||
setForm((f) => ({ ...f, beginn: e.target.value }));
|
||||
}
|
||||
}}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
@@ -791,12 +821,16 @@ function FahrzeugBuchungen() {
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Ende"
|
||||
type="datetime-local"
|
||||
type={form.ganztaegig ? 'date' : 'datetime-local'}
|
||||
required
|
||||
value={form.ende}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, ende: e.target.value }))
|
||||
value={form.ganztaegig ? (form.ende?.split('T')[0] || '') : form.ende}
|
||||
onChange={(e) => {
|
||||
if (form.ganztaegig) {
|
||||
setForm((f) => ({ ...f, ende: `${e.target.value}T23:59` }));
|
||||
} else {
|
||||
setForm((f) => ({ ...f, ende: e.target.value }));
|
||||
}
|
||||
}}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
@@ -818,6 +852,7 @@ function FahrzeugBuchungen() {
|
||||
size="small"
|
||||
/>
|
||||
) : availability.reason === 'out_of_service' ? (
|
||||
<Box>
|
||||
<Chip
|
||||
icon={<Block />}
|
||||
label={
|
||||
@@ -828,6 +863,23 @@ function FahrzeugBuchungen() {
|
||||
color="error"
|
||||
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
|
||||
icon={<Warning />}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
DirectionsCar,
|
||||
Edit,
|
||||
Error as ErrorIcon,
|
||||
History,
|
||||
LocalFireDepartment,
|
||||
MoreHoriz,
|
||||
PauseCircle,
|
||||
@@ -121,6 +122,58 @@ function fmtDatetime(iso: string | Date | null | undefined): string {
|
||||
return fmtDate(iso ? new Date(iso).toISOString() : null);
|
||||
}
|
||||
|
||||
// ── Status History Section ────────────────────────────────────────────────────
|
||||
|
||||
const StatusHistorySection: React.FC<{ vehicleId: string }> = ({ vehicleId }) => {
|
||||
const [history, setHistory] = useState<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
vehiclesApi.getStatusHistory(vehicleId)
|
||||
.then(setHistory)
|
||||
.catch(() => setHistory([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [vehicleId]);
|
||||
|
||||
if (loading || history.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface UebersichtTabProps {
|
||||
@@ -148,7 +201,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
|
||||
const openDialog = () => {
|
||||
setNewStatus(vehicle.status);
|
||||
setBemerkung(vehicle.status_bemerkung ?? '');
|
||||
setBemerkung('');
|
||||
setAusserDienstVon(
|
||||
vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : ''
|
||||
);
|
||||
@@ -323,6 +376,9 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{/* Status history */}
|
||||
<StatusHistorySection vehicleId={vehicle.id} />
|
||||
|
||||
{/* Status change dialog */}
|
||||
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Fahrzeugstatus ändern</DialogTitle>
|
||||
@@ -475,6 +531,42 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
||||
entry.externe_werkstatt && entry.externe_werkstatt,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -167,8 +167,6 @@ function FahrzeugForm() {
|
||||
hersteller: form.hersteller.trim() || null,
|
||||
typ_schluessel: form.typ_schluessel.trim() || null,
|
||||
besatzung_soll: form.besatzung_soll.trim() || null,
|
||||
status: form.status,
|
||||
status_bemerkung: form.status_bemerkung.trim() || null,
|
||||
standort: form.standort.trim() || 'Feuerwehrhaus',
|
||||
bild_url: form.bild_url.trim() || null,
|
||||
paragraph57a_faellig_am: form.paragraph57a_faellig_am || null,
|
||||
@@ -186,8 +184,6 @@ function FahrzeugForm() {
|
||||
hersteller: form.hersteller.trim() || undefined,
|
||||
typ_schluessel: form.typ_schluessel.trim() || undefined,
|
||||
besatzung_soll: form.besatzung_soll.trim() || undefined,
|
||||
status: form.status,
|
||||
status_bemerkung: form.status_bemerkung.trim() || undefined,
|
||||
standort: form.standort.trim() || 'Feuerwehrhaus',
|
||||
bild_url: form.bild_url.trim() || undefined,
|
||||
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
|
||||
@@ -285,32 +281,6 @@ function FahrzeugForm() {
|
||||
</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>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
|
||||
@@ -236,8 +236,8 @@ function KatalogTab() {
|
||||
{/* Artikel create/edit dialog */}
|
||||
<Dialog open={artikelDialogOpen} onClose={() => setArtikelDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'}</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField label="Bezeichnung" required value={artikelForm.bezeichnung} onChange={e => setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} />
|
||||
<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 }))} fullWidth />
|
||||
<TextField label="Beschreibung" multiline rows={2} value={artikelForm.beschreibung ?? ''} onChange={e => setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} />
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
|
||||
@@ -300,7 +300,7 @@ function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps
|
||||
export default function VeranstaltungKategorien() {
|
||||
const { hasPermission } = usePermissionContext();
|
||||
|
||||
const canManage = hasPermission('kalender:manage_categories');
|
||||
const canManage = hasPermission('kalender:create');
|
||||
|
||||
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
|
||||
const [groups, setGroups] = useState<GroupInfo[]>([]);
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
InputLabel,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Checkbox,
|
||||
FormGroup,
|
||||
Stack,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -34,6 +32,9 @@ import {
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
Snackbar,
|
||||
Autocomplete,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
@@ -61,6 +62,7 @@ import type {
|
||||
GroupInfo,
|
||||
CreateVeranstaltungInput,
|
||||
ConflictEvent,
|
||||
WiederholungConfig,
|
||||
} from '../types/events.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -667,16 +669,6 @@ function EventFormDialog({
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleGroupToggle = (groupId: string) => {
|
||||
setForm((prev) => {
|
||||
const current = prev.zielgruppen;
|
||||
const updated = current.includes(groupId)
|
||||
? current.filter((g) => g !== groupId)
|
||||
: [...current, groupId];
|
||||
return { ...prev, zielgruppen: updated };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.titel.trim()) {
|
||||
notification.showError('Titel ist erforderlich');
|
||||
@@ -866,28 +858,33 @@ function EventFormDialog({
|
||||
label="Für alle Mitglieder sichtbar"
|
||||
/>
|
||||
|
||||
{/* Zielgruppen checkboxes */}
|
||||
{/* Zielgruppen multi-select */}
|
||||
{!form.alle_gruppen && groups.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
|
||||
Zielgruppen
|
||||
</Typography>
|
||||
<FormGroup>
|
||||
{groups.map((g) => (
|
||||
<FormControlLabel
|
||||
key={g.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={form.zielgruppen.includes(g.id)}
|
||||
onChange={() => handleGroupToggle(g.id)}
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={groups}
|
||||
getOptionLabel={(option) => option.label}
|
||||
value={groups.filter((g) => form.zielgruppen.includes(g.id))}
|
||||
onChange={(_, newValue) => {
|
||||
handleChange('zielgruppen', newValue.map((g) => g.id));
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Zielgruppen" placeholder="Gruppen auswählen" />
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
size="small"
|
||||
/>
|
||||
))
|
||||
}
|
||||
label={g.label}
|
||||
size="small"
|
||||
disableCloseOnSelect
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
@@ -929,6 +926,103 @@ function EventFormDialog({
|
||||
inputProps={{ min: 1 }}
|
||||
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>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -1105,6 +1199,7 @@ export default function Veranstaltungen() {
|
||||
// Delete dialog
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [deleteMode, setDeleteMode] = useState<'all' | 'single' | 'future'>('all');
|
||||
|
||||
// iCal dialog
|
||||
const [icalOpen, setIcalOpen] = useState(false);
|
||||
@@ -1215,8 +1310,9 @@ export default function Veranstaltungen() {
|
||||
if (!deleteId) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await eventsApi.deleteEvent(deleteId);
|
||||
await eventsApi.deleteEvent(deleteId, deleteMode);
|
||||
setDeleteId(null);
|
||||
setDeleteMode('all');
|
||||
loadData();
|
||||
notification.showSuccess('Veranstaltung wurde gelöscht');
|
||||
} catch (e: unknown) {
|
||||
@@ -1373,7 +1469,12 @@ export default function Veranstaltungen() {
|
||||
canWrite={canWrite}
|
||||
onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }}
|
||||
onCancel={(id) => { setCancelId(id); setCancelGrund(''); }}
|
||||
onDelete={(id) => setDeleteId(id)}
|
||||
onDelete={(id) => {
|
||||
const ev = events.find((e) => e.id === id);
|
||||
const isRecurring = ev && (ev.wiederholung_parent_id || ev.wiederholung);
|
||||
setDeleteMode(isRecurring ? 'single' : 'all');
|
||||
setDeleteId(id);
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
@@ -1444,15 +1545,38 @@ export default function Veranstaltungen() {
|
||||
</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>
|
||||
<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>
|
||||
Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</DialogContentText>
|
||||
);
|
||||
})()}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteId(null)}>Abbrechen</Button>
|
||||
<Button onClick={() => { setDeleteId(null); setDeleteMode('all'); }}>Abbrechen</Button>
|
||||
<Button variant="contained" color="error" onClick={handleDeleteEvent} disabled={deleteLoading}>
|
||||
{deleteLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'}
|
||||
</Button>
|
||||
|
||||
@@ -131,8 +131,8 @@ export const eventsApi = {
|
||||
},
|
||||
|
||||
/** Hard-delete an event permanently */
|
||||
deleteEvent(id: string): Promise<void> {
|
||||
return api.post(`/api/events/${id}/delete`).then(() => undefined);
|
||||
deleteEvent(id: string, mode: 'all' | 'single' | 'future' = 'all'): Promise<void> {
|
||||
return api.post(`/api/events/${id}/delete`, { mode }).then(() => undefined);
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -100,4 +100,17 @@ export const vehiclesApi = {
|
||||
});
|
||||
return response.data as Blob;
|
||||
},
|
||||
|
||||
async getStatusHistory(id: string): Promise<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]> {
|
||||
return unwrap(api.get(`/api/vehicles/${id}/status-history`));
|
||||
},
|
||||
|
||||
async uploadWartungFile(wartungId: number, file: File): Promise<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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,4 +71,5 @@ export interface CreateBuchungInput {
|
||||
buchungsArt: BuchungsArt;
|
||||
kontaktPerson?: string | null;
|
||||
kontaktTelefon?: string | null;
|
||||
ganztaegig?: boolean;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface FahrzeugWartungslog {
|
||||
kraftstoff_liter: number | null;
|
||||
kosten: number | null;
|
||||
externe_werkstatt: string | null;
|
||||
dokument_url: string | null;
|
||||
erfasst_von: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,23 @@ function mapDienstgrad(raw: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid Austrian/EU driving license class patterns.
|
||||
* Filters out non-class data that the scraper may pick up from FDISK form fields.
|
||||
*/
|
||||
const VALID_LICENSE_CLASSES = new Set([
|
||||
'A', 'A1', 'A2', 'AM',
|
||||
'B', 'B1', 'BE',
|
||||
'C', 'C1', 'CE', 'C1E',
|
||||
'D', 'D1', 'DE', 'D1E',
|
||||
'F', 'G', 'L', 'T',
|
||||
]);
|
||||
|
||||
function isValidLicenseClass(klasse: string): boolean {
|
||||
const normalized = klasse.trim().toUpperCase();
|
||||
return VALID_LICENSE_CLASSES.has(normalized);
|
||||
}
|
||||
|
||||
export async function syncToDatabase(
|
||||
pool: Pool,
|
||||
members: FdiskMember[],
|
||||
@@ -362,6 +379,13 @@ async function syncFahrgenehmigungen(
|
||||
}
|
||||
|
||||
for (const f of fahrgenehmigungen) {
|
||||
// J2: Filter out non-class data that the scraper may pick up
|
||||
if (!f.klasse || !isValidLicenseClass(f.klasse)) {
|
||||
log(`Skipping Fahrgenehmigung: invalid klasse "${f.klasse}" for StNr ${f.standesbuchNr}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await client.query<{ user_id: string }>(
|
||||
`SELECT user_id FROM mitglieder_profile WHERE fdisk_standesbuch_nr = $1`,
|
||||
[f.standesbuchNr]
|
||||
|
||||
@@ -241,9 +241,25 @@ export async function scrapeAll(username: string, password: string, knownStNrs:
|
||||
const idPersonen = urlObj.searchParams.get('id_personen');
|
||||
const idInstanzen = urlObj.searchParams.get('id_instanzen') ?? ID_INSTANZEN;
|
||||
|
||||
// Ausbildungen — disabled: requires different page/approach (TODO)
|
||||
// const quals = await scrapeAusbildungenFromDetailPage(mainFrame, member, idMitgliedschaft, idPersonen);
|
||||
// ausbildungen.push(...quals);
|
||||
// Ausbildungen
|
||||
if (idMitgliedschaft && idPersonen) {
|
||||
try {
|
||||
const quals = await scrapeAusbildungenFromDetailPage(mainFrame, member, idMitgliedschaft, idPersonen);
|
||||
ausbildungen.push(...quals);
|
||||
log(` ${member.vorname} ${member.zuname}: ${quals.length} Ausbildungen`);
|
||||
} catch (err: any) {
|
||||
log(` WARN: Ausbildungen scrape failed for ${member.vorname} ${member.zuname} (StNr ${member.standesbuchNr}): ${err.message}`);
|
||||
// Always dump HTML on failure for diagnosis
|
||||
try {
|
||||
const debugDir = path.resolve(process.cwd(), 'debug');
|
||||
fs.mkdirSync(debugDir, { recursive: true });
|
||||
const html = await mainFrame.content();
|
||||
const filePath = path.join(debugDir, `ausbildungen_error_StNr${member.standesbuchNr}.html`);
|
||||
fs.writeFileSync(filePath, html, 'utf-8');
|
||||
log(` [debug] saved error HTML → ${filePath}`);
|
||||
} catch { /* ignore dump errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Beförderungen
|
||||
const befos = (idMitgliedschaft && idPersonen)
|
||||
@@ -1034,20 +1050,110 @@ async function scrapeMemberUntersuchungen(
|
||||
+ `?search=1&searchid_mitgliedschaften=${idMitgliedschaft}&id_personen=${idPersonen}`
|
||||
+ `&id_mitgliedschaften=${idMitgliedschaft}&searchid_personen=${idPersonen}&searchid_maskmode=`;
|
||||
|
||||
const result = await navigateAndGetTableRows(frame, url);
|
||||
if (!result) return [];
|
||||
// Always dump for diagnosis when debug is on
|
||||
await frame_goto(frame, url);
|
||||
|
||||
const landed = frame.url();
|
||||
const title = await frame.title().catch(() => '');
|
||||
if (landed.includes('BLError') || landed.includes('support.aspx') || title.toLowerCase().includes('fehler')) {
|
||||
log(` → Untersuchungen ERROR page: ${landed}`);
|
||||
await dumpHtml(frame, `untersuchungen_error_StNr${standesbuchNr}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Show all rows
|
||||
await selectAlleAnzeige(frame);
|
||||
|
||||
// Dump HTML for diagnosis (always when debug enabled)
|
||||
await dumpHtml(frame, `untersuchungen_StNr${standesbuchNr}`);
|
||||
|
||||
// Try to navigate to history/detail view if available
|
||||
// FDISK may show only the most recent per exam type on the list page.
|
||||
// Look for a "Verlauf" or "Detail" or "Alle anzeigen" link/button
|
||||
const hasHistoryLink = await frame.evaluate(() => {
|
||||
const links = Array.from(document.querySelectorAll('a, input[type="button"], button'));
|
||||
for (const el of links) {
|
||||
const text = (el.textContent || '').toLowerCase();
|
||||
const title = (el.getAttribute('title') || '').toLowerCase();
|
||||
if (text.includes('verlauf') || text.includes('historie') || text.includes('alle anzeigen')
|
||||
|| title.includes('verlauf') || title.includes('historie')) {
|
||||
return (el as HTMLElement).id || (el as HTMLAnchorElement).href || text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}).catch(() => null);
|
||||
|
||||
if (hasHistoryLink) {
|
||||
log(` → Found history link: ${hasHistoryLink}`);
|
||||
}
|
||||
|
||||
// Parse the table using navigateAndGetTableRows logic (reuse existing page state)
|
||||
// Re-collect rows from the already-loaded page
|
||||
const allRows = await frame.evaluate(() => {
|
||||
const results: Array<{ cells: string[]; tableClass: string }> = [];
|
||||
for (const table of Array.from(document.querySelectorAll('table'))) {
|
||||
const cls = table.className || '';
|
||||
for (const tr of Array.from(table.querySelectorAll('tbody tr, tr'))) {
|
||||
if (tr.closest('table') !== table) continue;
|
||||
const tds = Array.from(tr.querySelectorAll('td'));
|
||||
if (tds.length < 2) continue;
|
||||
results.push({
|
||||
tableClass: cls,
|
||||
cells: tds.map(td => {
|
||||
const input = td.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null;
|
||||
if (input) return input.value?.trim() ?? '';
|
||||
const sel = td.querySelector('select') as HTMLSelectElement | null;
|
||||
if (sel) {
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
return (opt?.text || opt?.value || '').trim();
|
||||
}
|
||||
const anchor = td.querySelector('a');
|
||||
const atitle = anchor?.getAttribute('title')?.trim();
|
||||
if (atitle) return atitle;
|
||||
return td.textContent?.trim() ?? '';
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}).catch(() => [] as Array<{ cells: string[]; tableClass: string }>);
|
||||
|
||||
const fdcRows = allRows.filter(r => r.tableClass.includes('FdcLayList'));
|
||||
const resultRows = fdcRows.length > 0 ? fdcRows : allRows;
|
||||
const mapped = resultRows.map(r => ({
|
||||
cells: r.cells.map(c => c.replace(/\u00A0/g, ' ').trim()),
|
||||
}));
|
||||
|
||||
// Find date column
|
||||
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/;
|
||||
let dateColIdx = -1;
|
||||
for (const r of mapped) {
|
||||
for (let ci = 0; ci < r.cells.length; ci++) {
|
||||
if (datePattern.test(r.cells[ci] ?? '')) {
|
||||
dateColIdx = ci;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (dateColIdx >= 0) break;
|
||||
}
|
||||
|
||||
const dataRows = dateColIdx >= 0
|
||||
? mapped.filter(r => datePattern.test(r.cells[dateColIdx] ?? ''))
|
||||
: [];
|
||||
|
||||
log(` → Untersuchungen: ${allRows.length} total rows, ${dataRows.length} data rows (date in col ${dateColIdx})`);
|
||||
|
||||
if (dataRows.length === 0) {
|
||||
await dumpHtml(frame, `untersuchungen_empty_StNr${standesbuchNr}`);
|
||||
}
|
||||
|
||||
const { rows, dateColIdx } = result;
|
||||
const results: FdiskUntersuchung[] = [];
|
||||
for (const row of rows) {
|
||||
// Collect non-empty values from columns after the date column
|
||||
for (const row of dataRows) {
|
||||
const valueCols: string[] = [];
|
||||
for (let ci = dateColIdx + 1; ci < row.cells.length; ci++) {
|
||||
const v = cellText(row.cells[ci]);
|
||||
if (v !== null) valueCols.push(v);
|
||||
}
|
||||
// Original layout: 0=Datum, 1=Anmerkungen, 2=Untersuchungsart, 3=Tauglichkeitsstufe
|
||||
// With spacer columns the date may not be at 0; use relative offsets from collected values
|
||||
const anmerkungen = valueCols[0] ?? null;
|
||||
const art = valueCols[1] ?? null;
|
||||
const ergebnis = valueCols[2] ?? null;
|
||||
|
||||
Reference in New Issue
Block a user