From c55ec55e1b11ec636c41cef9b20bcbf1c779a02a Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 20 Apr 2026 16:29:12 +0200 Subject: [PATCH] feat(admin): move integration URLs and credentials to GUI settings --- backend/src/controllers/config.controller.ts | 11 +- .../migrations/098_integration_settings.sql | 19 + .../099_checklist_period_alignment.sql | 1 + backend/src/services/booking.service.ts | 3 +- backend/src/services/checklist.service.ts | 273 +++++++++++-- backend/src/services/events.service.ts | 3 +- backend/src/services/settings.service.ts | 39 +- backend/src/services/toolConfig.service.ts | 12 +- frontend/src/pages/AdminSettings.tsx | 382 +++++++++++++++++- frontend/src/pages/ChecklistAusfuehrung.tsx | 67 +-- frontend/src/pages/Checklisten.tsx | 80 +++- frontend/src/services/settings.ts | 1 + frontend/src/types/checklist.types.ts | 5 + sync/src/index.ts | 28 +- sync/src/scraper.ts | 29 +- 15 files changed, 860 insertions(+), 93 deletions(-) create mode 100644 backend/src/database/migrations/098_integration_settings.sql create mode 100644 backend/src/database/migrations/099_checklist_period_alignment.sql diff --git a/backend/src/controllers/config.controller.ts b/backend/src/controllers/config.controller.ts index e0aea85..8eeeb39 100644 --- a/backend/src/controllers/config.controller.ts +++ b/backend/src/controllers/config.controller.ts @@ -39,9 +39,14 @@ class ConfigController { async getExternalLinks(_req: Request, res: Response): Promise { const envLinks: Record = {}; - if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl; - if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url; - if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url; + + const nextcloudUrl = await settingsService.getSettingOrEnv('integration_nextcloud_url', environment.nextcloudUrl); + const bookstackUrl = await settingsService.getSettingOrEnv('integration_bookstack_url', environment.bookstack.url); + const vikunjaUrl = await settingsService.getSettingOrEnv('integration_vikunja_url', environment.vikunja.url); + + if (nextcloudUrl) envLinks.nextcloud = nextcloudUrl; + if (bookstackUrl) envLinks.bookstack = bookstackUrl; + if (vikunjaUrl) envLinks.vikunja = vikunjaUrl; const linkCollections = await settingsService.getExternalLinks(); diff --git a/backend/src/database/migrations/098_integration_settings.sql b/backend/src/database/migrations/098_integration_settings.sql new file mode 100644 index 0000000..efb823d --- /dev/null +++ b/backend/src/database/migrations/098_integration_settings.sql @@ -0,0 +1,19 @@ +-- Migration: 098_integration_settings +-- Seeds integration URL and credential keys into app_settings so admins +-- can configure them via the GUI instead of requiring env var changes. +-- Empty JSON string '""' means "use env var fallback". + +INSERT INTO app_settings (key, value) VALUES ('integration_bookstack_url', '""') ON CONFLICT (key) DO NOTHING; +INSERT INTO app_settings (key, value) VALUES ('integration_nextcloud_url', '""') ON CONFLICT (key) DO NOTHING; +INSERT INTO app_settings (key, value) VALUES ('integration_vikunja_url', '""') ON CONFLICT (key) DO NOTHING; +INSERT INTO app_settings (key, value) VALUES ('integration_ical_base_url', '""') ON CONFLICT (key) DO NOTHING; + +INSERT INTO app_settings (key, value) VALUES ('integration_bookstack_token_id', '""') ON CONFLICT (key) DO NOTHING; +INSERT INTO app_settings (key, value) VALUES ('integration_bookstack_token_secret', '""') ON CONFLICT (key) DO NOTHING; +INSERT INTO app_settings (key, value) VALUES ('integration_vikunja_api_token', '""') ON CONFLICT (key) DO NOTHING; + +INSERT INTO app_settings (key, value) VALUES ('fdisk_base_url', '""') ON CONFLICT (key) DO NOTHING; +INSERT INTO app_settings (key, value) VALUES ('fdisk_id_feuerwehren', '""') ON CONFLICT (key) DO NOTHING; +INSERT INTO app_settings (key, value) VALUES ('fdisk_id_instanzen', '""') ON CONFLICT (key) DO NOTHING; +INSERT INTO app_settings (key, value) VALUES ('fdisk_username', '""') ON CONFLICT (key) DO NOTHING; +INSERT INTO app_settings (key, value) VALUES ('fdisk_password', '""') ON CONFLICT (key) DO NOTHING; diff --git a/backend/src/database/migrations/099_checklist_period_alignment.sql b/backend/src/database/migrations/099_checklist_period_alignment.sql new file mode 100644 index 0000000..5b52e80 --- /dev/null +++ b/backend/src/database/migrations/099_checklist_period_alignment.sql @@ -0,0 +1 @@ +ALTER TABLE checklist_faelligkeit ADD COLUMN IF NOT EXISTS verfuegbar_ab DATE; diff --git a/backend/src/services/booking.service.ts b/backend/src/services/booking.service.ts index beea60e..349bff9 100644 --- a/backend/src/services/booking.service.ts +++ b/backend/src/services/booking.service.ts @@ -1,5 +1,6 @@ import pool from '../config/database'; import logger from '../utils/logger'; +import settingsService from './settings.service'; import { FahrzeugBuchung, FahrzeugBuchungListItem, @@ -362,7 +363,7 @@ class BookingService { logger.info('Created new iCal token for user', { userId }); } - const baseUrl = (process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || 'http://localhost:3000').replace(/\/$/, ''); + const baseUrl = (await settingsService.getSettingOrEnv('integration_ical_base_url', process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || 'http://localhost:3000')).replace(/\/$/, ''); const subscribeUrl = `${baseUrl}/api/bookings/calendar.ics?token=${token}`; return { token, subscribeUrl }; } diff --git a/backend/src/services/checklist.service.ts b/backend/src/services/checklist.service.ts index dd8044a..e5b57a8 100644 --- a/backend/src/services/checklist.service.ts +++ b/backend/src/services/checklist.service.ts @@ -5,33 +5,102 @@ import logger from '../utils/logger'; // Helpers // --------------------------------------------------------------------------- -function calculateNextDueDate(intervall: string | null, intervall_tage: number | null): Date | null { +interface PeriodBounds { + verfuegbar_ab: Date; + faellig_am: Date; +} + +/** Returns the NEXT period boundaries (the period after today). */ +function calculateNextPeriod(intervall: string | null, intervall_tage: number | null): PeriodBounds | null { const now = new Date(); - if (intervall_tage && intervall_tage > 0) { - now.setDate(now.getDate() + intervall_tage); - return now; + if (intervall === 'custom' && intervall_tage && intervall_tage > 0) { + const start = new Date(now); + start.setDate(start.getDate() + 1); + const end = new Date(start); + end.setDate(end.getDate() + intervall_tage - 1); + return { verfuegbar_ab: start, faellig_am: end }; } switch (intervall) { - case 'weekly': - now.setDate(now.getDate() + 7); - return now; - case 'monthly': - now.setMonth(now.getMonth() + 1); - return now; - case 'quarterly': - now.setMonth(now.getMonth() + 3); - return now; - case 'halfyearly': - now.setMonth(now.getMonth() + 6); - return now; - case 'yearly': - now.setFullYear(now.getFullYear() + 1); - return now; + case 'weekly': { + // Next Monday → next Sunday + const day = now.getDay(); // 0=Sun + const daysUntilNextMon = day === 0 ? 1 : 8 - day; + const nextMon = new Date(now); + nextMon.setDate(now.getDate() + daysUntilNextMon); + const nextSun = new Date(nextMon); + nextSun.setDate(nextMon.getDate() + 6); + return { verfuegbar_ab: nextMon, faellig_am: nextSun }; + } + case 'monthly': { + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const lastDay = new Date(nextMonth.getFullYear(), nextMonth.getMonth() + 1, 0); + return { verfuegbar_ab: nextMonth, faellig_am: lastDay }; + } + case 'quarterly': { + const currentQ = Math.floor(now.getMonth() / 3); + const nextQStart = new Date(now.getFullYear(), (currentQ + 1) * 3, 1); + const nextQEnd = new Date(nextQStart.getFullYear(), nextQStart.getMonth() + 3, 0); + return { verfuegbar_ab: nextQStart, faellig_am: nextQEnd }; + } + case 'halfyearly': { + const currentH = now.getMonth() < 6 ? 0 : 1; + const nextHStart = currentH === 0 + ? new Date(now.getFullYear(), 6, 1) + : new Date(now.getFullYear() + 1, 0, 1); + const nextHEnd = new Date(nextHStart.getFullYear(), nextHStart.getMonth() + 6, 0); + return { verfuegbar_ab: nextHStart, faellig_am: nextHEnd }; + } + case 'yearly': { + const nextYear = new Date(now.getFullYear() + 1, 0, 1); + const yearEnd = new Date(now.getFullYear() + 1, 11, 31); + return { verfuegbar_ab: nextYear, faellig_am: yearEnd }; + } default: return null; } } +/** Returns the CURRENT period boundaries containing today. */ +function calculateCurrentPeriod(intervall: string | null, _intervall_tage: number | null): PeriodBounds | null { + const now = new Date(); + switch (intervall) { + case 'weekly': { + const day = now.getDay(); // 0=Sun + const diffToMon = day === 0 ? -6 : 1 - day; + const mon = new Date(now); + mon.setDate(now.getDate() + diffToMon); + const sun = new Date(mon); + sun.setDate(mon.getDate() + 6); + return { verfuegbar_ab: mon, faellig_am: sun }; + } + case 'monthly': { + const first = new Date(now.getFullYear(), now.getMonth(), 1); + const last = new Date(now.getFullYear(), now.getMonth() + 1, 0); + return { verfuegbar_ab: first, faellig_am: last }; + } + case 'quarterly': { + const qStart = Math.floor(now.getMonth() / 3) * 3; + const first = new Date(now.getFullYear(), qStart, 1); + const last = new Date(now.getFullYear(), qStart + 3, 0); + return { verfuegbar_ab: first, faellig_am: last }; + } + case 'halfyearly': { + const hStart = now.getMonth() < 6 ? 0 : 6; + const first = new Date(now.getFullYear(), hStart, 1); + const last = new Date(now.getFullYear(), hStart + 6, 0); + return { verfuegbar_ab: first, faellig_am: last }; + } + case 'yearly': { + const first = new Date(now.getFullYear(), 0, 1); + const last = new Date(now.getFullYear(), 11, 31); + return { verfuegbar_ab: first, faellig_am: last }; + } + default: + // custom intervals have no meaningful "current period" + return null; + } +} + // Subquery fragments for junction table arrays const JUNCTION_SUBQUERIES = ` ARRAY(SELECT fahrzeug_typ_id FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = v.id) AS fahrzeug_typ_ids, @@ -611,17 +680,24 @@ async function getTemplatesForEquipment(ausruestungId: string) { async function getOverviewItems() { try { - // All vehicles with their assigned templates (direct, by type, or global) + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // All vehicles with their assigned scheduled templates (direct, by type, or global) const vehiclesResult = await pool.query(` SELECT f.id, COALESCE(f.bezeichnung, f.kurzname) AS name, json_agg(DISTINCT jsonb_build_object( 'vorlage_id', cv.id, 'vorlage_name', cv.name, 'intervall', cv.intervall, - 'next_due', cf.naechste_faellig_am + 'intervall_tage', cv.intervall_tage, + 'next_due', cf.naechste_faellig_am, + 'verfuegbar_ab', cf.verfuegbar_ab, + 'letzte_ausfuehrung_am', ca.ausgefuehrt_am )) AS checklists FROM fahrzeuge f JOIN checklist_vorlagen cv ON cv.aktiv = true + AND cv.intervall IS NOT NULL AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id) AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id) AND ( @@ -639,22 +715,27 @@ async function getOverviewItems() { ) ) LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.fahrzeug_id = f.id + LEFT JOIN checklist_ausfuehrungen ca ON ca.id = cf.letzte_ausfuehrung_id WHERE f.deleted_at IS NULL GROUP BY f.id, f.bezeichnung, f.kurzname ORDER BY f.bezeichnung ASC, f.kurzname ASC `); - // All equipment with their assigned templates + // Standalone equipment (not assigned to a vehicle) const equipmentResult = await pool.query(` SELECT a.id, a.bezeichnung AS name, json_agg(DISTINCT jsonb_build_object( 'vorlage_id', cv.id, 'vorlage_name', cv.name, 'intervall', cv.intervall, - 'next_due', cf.naechste_faellig_am + 'intervall_tage', cv.intervall_tage, + 'next_due', cf.naechste_faellig_am, + 'verfuegbar_ab', cf.verfuegbar_ab, + 'letzte_ausfuehrung_am', ca.ausgefuehrt_am )) AS checklists FROM ausruestung a JOIN checklist_vorlagen cv ON cv.aktiv = true + AND cv.intervall IS NOT NULL AND ( EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = cv.id AND cva.ausruestung_id = a.id) OR EXISTS ( @@ -670,14 +751,128 @@ async function getOverviewItems() { ) ) LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.ausruestung_id = a.id - WHERE a.deleted_at IS NULL + LEFT JOIN checklist_ausfuehrungen ca ON ca.id = cf.letzte_ausfuehrung_id + WHERE a.deleted_at IS NULL AND a.fahrzeug_id IS NULL GROUP BY a.id, a.bezeichnung ORDER BY a.bezeichnung ASC `); + // Equipment assigned to vehicles — will be merged under their parent vehicle + const equipmentWithVehicleResult = await pool.query(` + SELECT a.id AS ausruestung_id, a.bezeichnung AS ausruestung_name, a.fahrzeug_id, + json_agg(DISTINCT jsonb_build_object( + 'vorlage_id', cv.id, + 'vorlage_name', cv.name, + 'intervall', cv.intervall, + 'intervall_tage', cv.intervall_tage, + 'next_due', cf.naechste_faellig_am, + 'verfuegbar_ab', cf.verfuegbar_ab, + 'letzte_ausfuehrung_am', ca.ausgefuehrt_am, + 'ausruestung_id', a.id, + 'ausruestung_name', a.bezeichnung + )) AS checklists + FROM ausruestung a + JOIN checklist_vorlagen cv ON cv.aktiv = true + AND cv.intervall IS NOT NULL + AND ( + EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = cv.id AND cva.ausruestung_id = a.id) + OR EXISTS ( + SELECT 1 FROM checklist_vorlage_ausruestung_typen cvat + WHERE cvat.vorlage_id = cv.id + AND cvat.ausruestung_typ_id IN (SELECT ausruestung_typ_id FROM ausruestung_ausruestung_typen WHERE ausruestung_id = a.id) + ) + OR ( + NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_fahrzeuge WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung_typen WHERE vorlage_id = cv.id) + AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id) + ) + ) + LEFT JOIN checklist_faelligkeit cf ON cf.vorlage_id = cv.id AND cf.ausruestung_id = a.id + LEFT JOIN checklist_ausfuehrungen ca ON ca.id = cf.letzte_ausfuehrung_id + WHERE a.deleted_at IS NULL AND a.fahrzeug_id IS NOT NULL + GROUP BY a.id, a.bezeichnung, a.fahrzeug_id + ORDER BY a.bezeichnung ASC + `); + + // Build vehicle map for merging equipment checklists + const vehicleMap = new Map(); + for (const v of vehiclesResult.rows) { + vehicleMap.set(v.id, v); + } + + // Merge equipment-with-vehicle checklists into parent vehicles + for (const eq of equipmentWithVehicleResult.rows) { + let parent = vehicleMap.get(eq.fahrzeug_id); + if (!parent) { + // Vehicle has no own checklists but has equipment with checklists — create stub entry + const vehRes = await pool.query( + `SELECT id, COALESCE(bezeichnung, kurzname) AS name FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL`, + [eq.fahrzeug_id] + ); + if (vehRes.rows.length > 0) { + parent = { id: vehRes.rows[0].id, name: vehRes.rows[0].name, checklists: [] }; + vehicleMap.set(eq.fahrzeug_id, parent); + vehiclesResult.rows.push(parent); + } + } + if (parent) { + parent.checklists = parent.checklists.concat(eq.checklists); + } + } + + // Post-process: compute verfuegbar_ab fallback and ist_faellig for each checklist + const processChecklists = (checklists: any[]) => { + return checklists.map((cl: any) => { + let verfuegbar = cl.verfuegbar_ab ? new Date(cl.verfuegbar_ab) : null; + let nextDue = cl.next_due ? new Date(cl.next_due) : null; + const letzteAusfuehrung = cl.letzte_ausfuehrung_am || null; + + if (!verfuegbar && nextDue) { + // Old faelligkeit row without verfuegbar_ab — compute from current period + const period = calculateCurrentPeriod(cl.intervall, cl.intervall_tage ?? null); + if (period) { + verfuegbar = period.verfuegbar_ab; + } + } + + if (!nextDue) { + // Never executed — use current period + const period = calculateCurrentPeriod(cl.intervall, cl.intervall_tage ?? null); + if (period) { + nextDue = period.faellig_am; + verfuegbar = period.verfuegbar_ab; + } + } + + const istFaellig = verfuegbar ? verfuegbar <= today : false; + + return { + vorlage_id: cl.vorlage_id, + vorlage_name: cl.vorlage_name, + intervall: cl.intervall, + next_due: nextDue ? nextDue.toISOString().split('T')[0] : null, + verfuegbar_ab: verfuegbar ? verfuegbar.toISOString().split('T')[0] : null, + letzte_ausfuehrung_am: letzteAusfuehrung, + ist_faellig: istFaellig, + ...(cl.ausruestung_id ? { ausruestung_id: cl.ausruestung_id, ausruestung_name: cl.ausruestung_name } : {}), + }; + }); + }; + + const vehicles = vehiclesResult.rows.map((v: any) => ({ + ...v, + checklists: processChecklists(v.checklists), + })); + + const equipment = equipmentResult.rows.map((e: any) => ({ + ...e, + checklists: processChecklists(e.checklists), + })); + return { - vehicles: vehiclesResult.rows, - equipment: equipmentResult.rows, + vehicles, + equipment, }; } catch (error) { logger.error('ChecklistService.getOverviewItems failed', { error }); @@ -812,6 +1007,12 @@ async function submitExecution( notizen: string | null, _userId: string, ) { + // Validate: NOK items must have a comment + const nokWithoutComment = items.filter(i => i.ergebnis === 'nok' && (!i.kommentar || !i.kommentar.trim())); + if (nokWithoutComment.length > 0) { + throw new Error('Kommentar ist erforderlich für nicht bestandene Prüfpunkte'); + } + const client = await pool.connect(); try { await client.query('BEGIN'); @@ -856,23 +1057,23 @@ async function submitExecution( const { vorlage_id, fahrzeug_id, ausruestung_id } = exec.rows[0]; const vorlage = await client.query(`SELECT intervall, intervall_tage FROM checklist_vorlagen WHERE id = $1`, [vorlage_id]); if (vorlage.rows.length > 0) { - const nextDue = calculateNextDueDate(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage); - if (nextDue) { + const nextPeriod = calculateNextPeriod(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage); + if (nextPeriod) { if (ausruestung_id) { await client.query( - `INSERT INTO checklist_faelligkeit (ausruestung_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id) - VALUES ($1, $2, $3, $4) + `INSERT INTO checklist_faelligkeit (ausruestung_id, vorlage_id, naechste_faellig_am, verfuegbar_ab, letzte_ausfuehrung_id) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (vorlage_id, ausruestung_id) WHERE ausruestung_id IS NOT NULL AND fahrzeug_id IS NULL - DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`, - [ausruestung_id, vorlage_id, nextDue, id] + DO UPDATE SET naechste_faellig_am = $3, verfuegbar_ab = $4, letzte_ausfuehrung_id = $5`, + [ausruestung_id, vorlage_id, nextPeriod.faellig_am, nextPeriod.verfuegbar_ab, id] ); } else if (fahrzeug_id) { await client.query( - `INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id) - VALUES ($1, $2, $3, $4) + `INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, verfuegbar_ab, letzte_ausfuehrung_id) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (vorlage_id, fahrzeug_id) WHERE fahrzeug_id IS NOT NULL AND ausruestung_id IS NULL - DO UPDATE SET naechste_faellig_am = $3, letzte_ausfuehrung_id = $4`, - [fahrzeug_id, vorlage_id, nextDue, id] + DO UPDATE SET naechste_faellig_am = $3, verfuegbar_ab = $4, letzte_ausfuehrung_id = $5`, + [fahrzeug_id, vorlage_id, nextPeriod.faellig_am, nextPeriod.verfuegbar_ab, id] ); } } diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index 54f8fbf..1280bfa 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -1,5 +1,6 @@ import pool from '../config/database'; import logger from '../utils/logger'; +import settingsService from './settings.service'; import { VeranstaltungKategorie, Veranstaltung, @@ -671,7 +672,7 @@ class EventsService { token = inserted.rows[0].token; } - const baseUrl = (process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || '').replace(/\/$/, ''); + const baseUrl = (await settingsService.getSettingOrEnv('integration_ical_base_url', process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || '')).replace(/\/$/, ''); const subscribeUrl = `${baseUrl}/api/events/calendar.ics?token=${token}`; return { token, subscribeUrl }; diff --git a/backend/src/services/settings.service.ts b/backend/src/services/settings.service.ts index a8dda65..2212c06 100644 --- a/backend/src/services/settings.service.ts +++ b/backend/src/services/settings.service.ts @@ -3,22 +3,57 @@ import pool from '../config/database'; export interface AppSetting { key: string; value: any; + is_set?: boolean; updated_at: string; updated_by: string | null; } +export const SENSITIVE_KEYS = new Set([ + 'integration_bookstack_token_id', + 'integration_bookstack_token_secret', + 'integration_vikunja_api_token', + 'fdisk_username', + 'fdisk_password', +]); + class SettingsService { async getAll(): Promise { const result = await pool.query('SELECT * FROM app_settings ORDER BY key'); - return result.rows; + return result.rows.map((row: AppSetting) => { + if (SENSITIVE_KEYS.has(row.key)) { + return { ...row, value: null, is_set: row.value !== '' && row.value !== null && row.value !== '""' }; + } + return row; + }); } async get(key: string): Promise { const result = await pool.query('SELECT * FROM app_settings WHERE key = $1', [key]); - return result.rows[0] ?? null; + const row: AppSetting | undefined = result.rows[0]; + if (!row) return null; + if (SENSITIVE_KEYS.has(row.key)) { + return { ...row, value: null, is_set: row.value !== '' && row.value !== null && row.value !== '""' }; + } + return row; + } + + async getSecret(key: string): Promise { + const result = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key]); + if (!result.rows[0]) return null; + return result.rows[0].value as string; + } + + async getSettingOrEnv(key: string, envFallback: string): Promise { + const val = await this.getSecret(key); + if (val !== null && val !== '' && val !== '""') return val; + return envFallback; } async set(key: string, value: any, userId: string): Promise { + if (value === '__UNCHANGED__') { + const existing = await this.get(key); + if (existing) return existing; + } const result = await pool.query( `INSERT INTO app_settings (key, value, updated_by, updated_at) VALUES ($1, $2, $3, NOW()) diff --git a/backend/src/services/toolConfig.service.ts b/backend/src/services/toolConfig.service.ts index d973076..029b239 100644 --- a/backend/src/services/toolConfig.service.ts +++ b/backend/src/services/toolConfig.service.ts @@ -42,9 +42,9 @@ async function getBookstackConfig(): Promise { const db = await getDbConfig('tool_config_bookstack'); const config: BookstackConfig = { - url: db.url || environment.bookstack.url, - tokenId: db.tokenId || environment.bookstack.tokenId, - tokenSecret: db.tokenSecret || environment.bookstack.tokenSecret, + url: db.url || await settingsService.getSettingOrEnv('integration_bookstack_url', environment.bookstack.url), + tokenId: db.tokenId || await settingsService.getSettingOrEnv('integration_bookstack_token_id', environment.bookstack.tokenId), + tokenSecret: db.tokenSecret || await settingsService.getSettingOrEnv('integration_bookstack_token_secret', environment.bookstack.tokenSecret), }; bookstackCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS }; @@ -58,8 +58,8 @@ async function getVikunjaConfig(): Promise { const db = await getDbConfig('tool_config_vikunja'); const config: VikunjaConfig = { - url: db.url || environment.vikunja.url, - apiToken: db.apiToken || environment.vikunja.apiToken, + url: db.url || await settingsService.getSettingOrEnv('integration_vikunja_url', environment.vikunja.url), + apiToken: db.apiToken || await settingsService.getSettingOrEnv('integration_vikunja_api_token', environment.vikunja.apiToken), }; vikunjaCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS }; @@ -73,7 +73,7 @@ async function getNextcloudConfig(): Promise { const db = await getDbConfig('tool_config_nextcloud'); const config: NextcloudConfig = { - url: db.url || environment.nextcloudUrl, + url: db.url || await settingsService.getSettingOrEnv('integration_nextcloud_url', environment.nextcloudUrl), bot_username: db.bot_username || undefined, bot_app_password: db.bot_app_password || undefined, }; diff --git a/frontend/src/pages/AdminSettings.tsx b/frontend/src/pages/AdminSettings.tsx index f5f8387..3a56693 100644 --- a/frontend/src/pages/AdminSettings.tsx +++ b/frontend/src/pages/AdminSettings.tsx @@ -20,6 +20,7 @@ import { AccordionDetails, Tabs, Tab, + Chip, } from '@mui/material'; import { Delete, @@ -31,6 +32,7 @@ import { PictureAsPdf as PdfIcon, Settings as SettingsIcon, Checkroom as CheckroomIcon, + Extension as ExtensionIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'; @@ -156,6 +158,27 @@ function AdminSettings() { // State for app logo const [appLogo, setAppLogo] = useState(''); + // State for integration settings — URL fields + const [bookstackUrl, setBookstackUrl] = useState(''); + const [nextcloudUrl, setNextcloudUrl] = useState(''); + const [vikunjaUrl, setVikunjaUrl] = useState(''); + const [icalBaseUrl, setIcalBaseUrl] = useState(''); + const [fdiskBaseUrl, setFdiskBaseUrl] = useState(''); + const [fdiskIdFeuerwehren, setFdiskIdFeuerwehren] = useState(''); + const [fdiskIdInstanzen, setFdiskIdInstanzen] = useState(''); + + // State for integration settings — secret fields (value + isSet flag) + const [bookstackTokenId, setBookstackTokenId] = useState(''); + const [bookstackTokenIdIsSet, setBookstackTokenIdIsSet] = useState(false); + const [bookstackTokenSecret, setBookstackTokenSecret] = useState(''); + const [bookstackTokenSecretIsSet, setBookstackTokenSecretIsSet] = useState(false); + const [vikunjaApiToken, setVikunjaApiToken] = useState(''); + const [vikunjaApiTokenIsSet, setVikunjaApiTokenIsSet] = useState(false); + const [fdiskUsername, setFdiskUsername] = useState(''); + const [fdiskUsernameIsSet, setFdiskUsernameIsSet] = useState(false); + const [fdiskPassword, setFdiskPassword] = useState(''); + const [fdiskPasswordIsSet, setFdiskPasswordIsSet] = useState(false); + // Fetch all settings const { data: settings, isLoading } = useQuery({ queryKey: ['admin-settings'], @@ -195,6 +218,40 @@ function AdminSettings() { if (pdfOrgNameSetting?.value != null) setPdfOrgName(pdfOrgNameSetting.value); const appLogoSetting = settings.find((s) => s.key === 'app_logo'); if (appLogoSetting?.value != null) setAppLogo(appLogoSetting.value); + + // Integration settings — URL fields + for (const s of settings) { + switch (s.key) { + case 'integration_bookstack_url': setBookstackUrl((s.value as string) || ''); break; + case 'integration_nextcloud_url': setNextcloudUrl((s.value as string) || ''); break; + case 'integration_vikunja_url': setVikunjaUrl((s.value as string) || ''); break; + case 'integration_ical_base_url': setIcalBaseUrl((s.value as string) || ''); break; + case 'fdisk_base_url': setFdiskBaseUrl((s.value as string) || ''); break; + case 'fdisk_id_feuerwehren': setFdiskIdFeuerwehren((s.value as string) || ''); break; + case 'fdisk_id_instanzen': setFdiskIdInstanzen((s.value as string) || ''); break; + // Secret fields — never pre-fill value, only track is_set + case 'integration_bookstack_token_id': + setBookstackTokenId(''); + setBookstackTokenIdIsSet(s.is_set ?? false); + break; + case 'integration_bookstack_token_secret': + setBookstackTokenSecret(''); + setBookstackTokenSecretIsSet(s.is_set ?? false); + break; + case 'integration_vikunja_api_token': + setVikunjaApiToken(''); + setVikunjaApiTokenIsSet(s.is_set ?? false); + break; + case 'fdisk_username': + setFdiskUsername(''); + setFdiskUsernameIsSet(s.is_set ?? false); + break; + case 'fdisk_password': + setFdiskPassword(''); + setFdiskPasswordIsSet(s.is_set ?? false); + break; + } + } } }, [settings]); @@ -310,6 +367,98 @@ function AdminSettings() { }, }); + // Helper: resolve secret value for save + const resolveSecret = (newValue: string, isSet: boolean): string => { + if (newValue !== '') return newValue; + if (isSet) return '__UNCHANGED__'; + return ''; + }; + + // Integration save states + const [isSavingBookstack, setIsSavingBookstack] = useState(false); + const [isSavingNextcloud, setIsSavingNextcloud] = useState(false); + const [isSavingVikunja, setIsSavingVikunja] = useState(false); + const [isSavingIcal, setIsSavingIcal] = useState(false); + const [isSavingFdisk, setIsSavingFdisk] = useState(false); + + const handleSaveBookstack = async () => { + setIsSavingBookstack(true); + try { + await Promise.all([ + settingsApi.update('integration_bookstack_url', bookstackUrl), + settingsApi.update('integration_bookstack_token_id', resolveSecret(bookstackTokenId, bookstackTokenIdIsSet)), + settingsApi.update('integration_bookstack_token_secret', resolveSecret(bookstackTokenSecret, bookstackTokenSecretIsSet)), + ]); + showSuccess('BookStack-Einstellungen gespeichert'); + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + } catch { + showError('Fehler beim Speichern der BookStack-Einstellungen'); + } finally { + setIsSavingBookstack(false); + } + }; + + const handleSaveNextcloud = async () => { + setIsSavingNextcloud(true); + try { + await settingsApi.update('integration_nextcloud_url', nextcloudUrl); + showSuccess('Nextcloud-Einstellungen gespeichert'); + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + } catch { + showError('Fehler beim Speichern der Nextcloud-Einstellungen'); + } finally { + setIsSavingNextcloud(false); + } + }; + + const handleSaveVikunja = async () => { + setIsSavingVikunja(true); + try { + await Promise.all([ + settingsApi.update('integration_vikunja_url', vikunjaUrl), + settingsApi.update('integration_vikunja_api_token', resolveSecret(vikunjaApiToken, vikunjaApiTokenIsSet)), + ]); + showSuccess('Vikunja-Einstellungen gespeichert'); + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + } catch { + showError('Fehler beim Speichern der Vikunja-Einstellungen'); + } finally { + setIsSavingVikunja(false); + } + }; + + const handleSaveIcal = async () => { + setIsSavingIcal(true); + try { + await settingsApi.update('integration_ical_base_url', icalBaseUrl); + showSuccess('iCal-Einstellungen gespeichert'); + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + } catch { + showError('Fehler beim Speichern der iCal-Einstellungen'); + } finally { + setIsSavingIcal(false); + } + }; + + const handleSaveFdisk = async () => { + setIsSavingFdisk(true); + try { + await Promise.all([ + settingsApi.update('fdisk_base_url', fdiskBaseUrl), + settingsApi.update('fdisk_id_feuerwehren', fdiskIdFeuerwehren), + settingsApi.update('fdisk_id_instanzen', fdiskIdInstanzen), + settingsApi.update('fdisk_username', resolveSecret(fdiskUsername, fdiskUsernameIsSet)), + settingsApi.update('fdisk_password', resolveSecret(fdiskPassword, fdiskPasswordIsSet)), + ]); + showSuccess('FDISK-Einstellungen gespeichert'); + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + } catch { + showError('Fehler beim Speichern der FDISK-Einstellungen'); + } finally { + setIsSavingFdisk(false); + } + }; + if (!canAccess) { return ; } @@ -677,7 +826,238 @@ function AdminSettings() { - {/* Section 5: Info */} + {/* Section 5: Integrationen */} + + + + + Integrationen + + + URLs und Zugangsdaten für externe Dienste. Leere URL-Felder verwenden die Umgebungsvariable als Fallback. + + + + {/* BookStack */} + BookStack + setBookstackUrl(e.target.value)} + placeholder="Aus Umgebungsvariable" + size="small" + sx={{ mb: 2 }} + /> + + setBookstackTokenId(e.target.value)} + size="small" + /> + + + + setBookstackTokenSecret(e.target.value)} + size="small" + /> + + + + + + + + + {/* Nextcloud */} + Nextcloud + setNextcloudUrl(e.target.value)} + placeholder="Aus Umgebungsvariable" + size="small" + sx={{ mb: 2 }} + /> + + + + + + + {/* Vikunja */} + Vikunja + setVikunjaUrl(e.target.value)} + placeholder="Aus Umgebungsvariable" + size="small" + sx={{ mb: 2 }} + /> + + setVikunjaApiToken(e.target.value)} + size="small" + /> + + + + + + + + + {/* iCal */} + iCal Abonnements + setIcalBaseUrl(e.target.value)} + placeholder="Aus Umgebungsvariable" + helperText="Wird als Basis-URL für Kalender-Abonnements genutzt. Leer = Basis-URL der App." + size="small" + sx={{ mb: 2 }} + /> + + + + + + + {/* FDISK */} + FDISK Sync + setFdiskBaseUrl(e.target.value)} + placeholder="Aus Umgebungsvariable" + size="small" + sx={{ mb: 2 }} + /> + + setFdiskIdFeuerwehren(e.target.value)} + placeholder="Aus Umgebungsvariable" + size="small" + /> + setFdiskIdInstanzen(e.target.value)} + placeholder="Aus Umgebungsvariable" + size="small" + /> + + + setFdiskUsername(e.target.value)} + size="small" + /> + + + + setFdiskPassword(e.target.value)} + size="small" + /> + + + + + + + + + {/* Section 6: Info */} diff --git a/frontend/src/pages/ChecklistAusfuehrung.tsx b/frontend/src/pages/ChecklistAusfuehrung.tsx index 2cf55e1..7f6c169 100644 --- a/frontend/src/pages/ChecklistAusfuehrung.tsx +++ b/frontend/src/pages/ChecklistAusfuehrung.tsx @@ -5,17 +5,14 @@ import { Button, Card, CardContent, - Chip, + Checkbox, CircularProgress, Divider, Paper, - Radio, - RadioGroup, - FormControlLabel, TextField, Typography, } from '@mui/material'; -import { ArrowBack, CheckCircle, Cancel, RemoveCircle } from '@mui/icons-material'; +import { ArrowBack, CheckCircle, Cancel } from '@mui/icons-material'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; @@ -34,7 +31,6 @@ const formatDate = (iso?: string) => const ERGEBNIS_ICONS: Record = { ok: , nok: , - na: , }; // ══════════════════════════════════════════════════════════════════════════════ @@ -86,7 +82,7 @@ export default function ChecklistAusfuehrung() { if (!execution?.items) return; const results: Record = {}; for (const item of execution.items) { - results[item.id] = { ergebnis: item.ergebnis ?? 'ok', kommentar: item.kommentar ?? '' }; + results[item.id] = { ergebnis: item.ergebnis ?? 'nok', kommentar: item.kommentar ?? '' }; } setItemResults(results); setNotizen(execution.notizen ?? ''); @@ -109,9 +105,14 @@ export default function ChecklistAusfuehrung() { .filter((i) => i.parent_ausfuehrung_item_id != null) .map((i) => i.parent_ausfuehrung_item_id!) ); + // Validate: all nok items must have comments + const leafItems = Object.entries(itemResults).filter(([id]) => !parentIds.has(Number(id))); + const missingComments = leafItems.filter(([, r]) => r.ergebnis === 'nok' && !r.kommentar?.trim()); + if (missingComments.length > 0) { + throw new Error('VALIDATION'); + } return checklistenApi.submitExecution(id!, { - items: Object.entries(itemResults) - .filter(([itemId]) => !parentIds.has(Number(itemId))) + items: leafItems .map(([itemId, r]) => ({ itemId: Number(itemId), ergebnis: r.ergebnis, kommentar: r.kommentar || undefined })), notizen: notizen || undefined, }); @@ -122,7 +123,13 @@ export default function ChecklistAusfuehrung() { queryClient.invalidateQueries({ queryKey: ['checklisten-faellig'] }); showSuccess('Checkliste abgeschlossen'); }, - onError: () => showError('Fehler beim Abschließen'), + onError: (err) => { + if (err instanceof Error && err.message === 'VALIDATION') { + showError('Alle nicht bestandenen Prüfpunkte benötigen einen Kommentar'); + return; + } + showError('Fehler beim Abschließen'); + }, }); // ── Approve ── @@ -185,34 +192,36 @@ export default function ChecklistAusfuehrung() { {isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]} {isReadOnly ? ( - - + - {result?.kommentar && ( - {result.kommentar} - )} + + {result?.ergebnis === 'na' && ( + (N/A) + )} + {result?.kommentar && ( + {result.kommentar} + )} + ) : ( - - setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')} - > - } label="OK" /> - } label="Nicht OK" /> - } label="N/A" /> - + + setItemResult(item.id, e.target.checked ? 'ok' : 'nok')} + /> setItemComment(item.id, e.target.value)} - sx={{ mt: 0.5 }} + error={result?.ergebnis === 'nok' && !result?.kommentar?.trim()} + helperText={result?.ergebnis === 'nok' && !result?.kommentar?.trim() ? 'Kommentar erforderlich' : undefined} + required={result?.ergebnis === 'nok'} /> )} diff --git a/frontend/src/pages/Checklisten.tsx b/frontend/src/pages/Checklisten.tsx index 53c0cf1..bf76b52 100644 --- a/frontend/src/pages/Checklisten.tsx +++ b/frontend/src/pages/Checklisten.tsx @@ -257,13 +257,85 @@ function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabPro return Keine Checklisten zugewiesen; } + // Flatten all checklists and detect open checks + const allChecklists = [ + ...vehicles.flatMap((v) => v.checklists.map((cl) => ({ ...cl, targetName: v.name, targetId: v.id, targetType: 'fahrzeug' as const }))), + ...equipment.flatMap((e) => e.checklists.map((cl) => ({ ...cl, targetName: e.name, targetId: e.id, targetType: 'ausruestung' as const }))), + ]; + const openChecks = allChecklists.filter((cl) => cl.ist_faellig); + const hasOpenChecks = openChecks.length > 0; + + // ── Open checks flat list ── + if (hasOpenChecks) { + return ( + + + Offene Prüfungen ({openChecks.length}) + + + {openChecks.map((cl, idx) => { + const color = getDueColor(cl.next_due, cl.intervall); + const label = getDueLabel(cl.next_due, cl.intervall); + const param = cl.ausruestung_id + ? `ausruestung=${cl.ausruestung_id}` + : cl.targetType === 'ausruestung' + ? `ausruestung=${cl.targetId}` + : `fahrzeug=${cl.targetId}`; + const primaryText = cl.targetName + ' — ' + cl.vorlage_name + (cl.ausruestung_name ? ` (${cl.ausruestung_name})` : ''); + const secondaryText = cl.letzte_ausfuehrung_am + ? `Letzte Prüfung: ${formatDate(cl.letzte_ausfuehrung_am)}` + : 'Noch nie geprüft'; + return ( + canExecute && navigate(`/checklisten/ausfuehrung/new?${param}&vorlage=${cl.vorlage_id}`)} + sx={{ + py: 1, + px: 2, + bgcolor: idx % 2 === 0 ? 'action.hover' : 'transparent', + cursor: canExecute ? 'pointer' : 'default', + '&:hover': canExecute ? undefined : { bgcolor: idx % 2 === 0 ? 'action.hover' : 'transparent' }, + }} + > + + + {canExecute && } + + ); + })} + + + ); + } + + // ── Normal accordion view (no open checks) ── + const renderChecklistRow = (cl: ChecklistOverviewChecklist, itemId: string, type: 'fahrzeug' | 'ausruestung', index: number) => { const color = getDueColor(cl.next_due, cl.intervall); const label = getDueLabel(cl.next_due, cl.intervall); - const param = type === 'fahrzeug' ? `fahrzeug=${itemId}` : `ausruestung=${itemId}`; + const param = cl.ausruestung_id + ? `ausruestung=${cl.ausruestung_id}` + : type === 'fahrzeug' + ? `fahrzeug=${itemId}` + : `ausruestung=${itemId}`; + const primaryText = cl.vorlage_name + (cl.ausruestung_name ? ` (${cl.ausruestung_name})` : ''); + const secondaryText = cl.letzte_ausfuehrung_am + ? `Letzte Prüfung: ${formatDate(cl.letzte_ausfuehrung_am)}` + : 'Noch nie geprüft'; return ( canExecute && navigate(`/checklisten/ausfuehrung/new?${param}&vorlage=${cl.vorlage_id}`)} sx={{ py: 0.75, @@ -274,8 +346,10 @@ function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabPro }} > { return; } syncRunning = true; - const username = requireEnv('FDISK_USERNAME'); - const password = requireEnv('FDISK_PASSWORD'); const pool = new Pool({ host: requireEnv('DB_HOST'), @@ -64,10 +62,32 @@ async function runSync(force = false): Promise { }); try { + // Load FDISK config from app_settings, fall back to env vars + const settingKeys = ['fdisk_base_url', 'fdisk_id_feuerwehren', 'fdisk_id_instanzen', 'fdisk_username', 'fdisk_password']; + const rows = await pool.query(`SELECT key, value FROM app_settings WHERE key = ANY($1)`, [settingKeys]); + const dbSettings: Record = {}; + for (const r of rows.rows) { dbSettings[r.key] = r.value as string; } + + const getOrEnv = (key: string, envFallback: string) => + (dbSettings[key] && dbSettings[key] !== '' && dbSettings[key] !== '""') ? dbSettings[key] : envFallback; + + const fdiskConfig: FdiskConfig = { + baseUrl: getOrEnv('fdisk_base_url', process.env.FDISK_BASE_URL || 'https://app.fdisk.at'), + idFeuerwehren: getOrEnv('fdisk_id_feuerwehren', process.env.FDISK_ID_FEUERWEHREN || '164'), + idInstanzen: getOrEnv('fdisk_id_instanzen', process.env.FDISK_ID_INSTANZEN || '2853'), + username: getOrEnv('fdisk_username', process.env.FDISK_USERNAME || ''), + password: getOrEnv('fdisk_password', process.env.FDISK_PASSWORD || ''), + }; + + if (!fdiskConfig.username || !fdiskConfig.password) { + log('WARN: FDISK username/password not configured in DB or env vars — skipping sync'); + return; + } + if (force) log('Force mode: ON'); log('Starting FDISK sync'); - const { members, ausbildungen, befoerderungen, untersuchungen, fahrgenehmigungen } = await scrapeAll(username, password); + const { members, ausbildungen, befoerderungen, untersuchungen, fahrgenehmigungen } = await scrapeAll(fdiskConfig); await syncToDatabase(pool, members, ausbildungen, befoerderungen, untersuchungen, fahrgenehmigungen, force); log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen, ${befoerderungen.length} Beförderungen, ${untersuchungen.length} Untersuchungen, ${fahrgenehmigungen.length} Fahrgenehmigungen`); await syncLehrgangToAtemschutz(pool); diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index 99a585a..9b0cacf 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -7,12 +7,20 @@ import { FdiskFahrgenehmigung, } from './types'; -const BASE_URL = process.env.FDISK_BASE_URL ?? 'https://app.fdisk.at'; -const ID_FEUERWEHREN = process.env.FDISK_ID_FEUERWEHREN ?? '164'; -const ID_INSTANZEN = process.env.FDISK_ID_INSTANZEN ?? '2853'; +export interface FdiskConfig { + baseUrl: string; + idFeuerwehren: string; + idInstanzen: string; + username: string; + password: string; +} -const LOGIN_URL = `${BASE_URL}/fdisk/module/vws/logins/logins.aspx`; -const MEMBERS_URL = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/meine_Mitglieder.aspx`; +let BASE_URL = process.env.FDISK_BASE_URL ?? 'https://app.fdisk.at'; +let ID_FEUERWEHREN = process.env.FDISK_ID_FEUERWEHREN ?? '164'; +let ID_INSTANZEN = process.env.FDISK_ID_INSTANZEN ?? '2853'; + +let LOGIN_URL = `${BASE_URL}/fdisk/module/vws/logins/logins.aspx`; +let MEMBERS_URL = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/meine_Mitglieder.aspx`; /** * Maps a raw FDISK status string to a dashboard status value. @@ -173,13 +181,20 @@ async function scrapeKnownMembers( return members; } -export async function scrapeAll(username: string, password: string): Promise<{ +export async function scrapeAll(config: FdiskConfig): Promise<{ members: FdiskMember[]; ausbildungen: FdiskAusbildung[]; befoerderungen: FdiskBefoerderung[]; untersuchungen: FdiskUntersuchung[]; fahrgenehmigungen: FdiskFahrgenehmigung[]; }> { + // Apply config to module-level variables used by helper functions + BASE_URL = config.baseUrl; + ID_FEUERWEHREN = config.idFeuerwehren; + ID_INSTANZEN = config.idInstanzen; + LOGIN_URL = `${BASE_URL}/fdisk/module/vws/logins/logins.aspx`; + MEMBERS_URL = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/meine_Mitglieder.aspx`; + const browser = await chromium.launch({ headless: true, args: ['--disable-gpu', '--disable-software-rasterizer'], @@ -190,7 +205,7 @@ export async function scrapeAll(username: string, password: string): Promise<{ const page = await context.newPage(); try { - await login(page, username, password); + await login(page, config.username, config.password); // After login, page is on Start.aspx (frameset). // Direct navigation to MitgliedschaftenList.aspx causes a server BLError because