feat(admin): move integration URLs and credentials to GUI settings
This commit is contained in:
@@ -39,9 +39,14 @@ class ConfigController {
|
|||||||
|
|
||||||
async getExternalLinks(_req: Request, res: Response): Promise<void> {
|
async getExternalLinks(_req: Request, res: Response): Promise<void> {
|
||||||
const envLinks: Record<string, string> = {};
|
const envLinks: Record<string, string> = {};
|
||||||
if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl;
|
|
||||||
if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url;
|
const nextcloudUrl = await settingsService.getSettingOrEnv('integration_nextcloud_url', environment.nextcloudUrl);
|
||||||
if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url;
|
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();
|
const linkCollections = await settingsService.getExternalLinks();
|
||||||
|
|
||||||
|
|||||||
19
backend/src/database/migrations/098_integration_settings.sql
Normal file
19
backend/src/database/migrations/098_integration_settings.sql
Normal file
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE checklist_faelligkeit ADD COLUMN IF NOT EXISTS verfuegbar_ab DATE;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import pool from '../config/database';
|
import pool from '../config/database';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
import settingsService from './settings.service';
|
||||||
import {
|
import {
|
||||||
FahrzeugBuchung,
|
FahrzeugBuchung,
|
||||||
FahrzeugBuchungListItem,
|
FahrzeugBuchungListItem,
|
||||||
@@ -362,7 +363,7 @@ class BookingService {
|
|||||||
logger.info('Created new iCal token for user', { userId });
|
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}`;
|
const subscribeUrl = `${baseUrl}/api/bookings/calendar.ics?token=${token}`;
|
||||||
return { token, subscribeUrl };
|
return { token, subscribeUrl };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,33 +5,102 @@ import logger from '../utils/logger';
|
|||||||
// Helpers
|
// 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();
|
const now = new Date();
|
||||||
if (intervall_tage && intervall_tage > 0) {
|
if (intervall === 'custom' && intervall_tage && intervall_tage > 0) {
|
||||||
now.setDate(now.getDate() + intervall_tage);
|
const start = new Date(now);
|
||||||
return 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) {
|
switch (intervall) {
|
||||||
case 'weekly':
|
case 'weekly': {
|
||||||
now.setDate(now.getDate() + 7);
|
// Next Monday → next Sunday
|
||||||
return now;
|
const day = now.getDay(); // 0=Sun
|
||||||
case 'monthly':
|
const daysUntilNextMon = day === 0 ? 1 : 8 - day;
|
||||||
now.setMonth(now.getMonth() + 1);
|
const nextMon = new Date(now);
|
||||||
return now;
|
nextMon.setDate(now.getDate() + daysUntilNextMon);
|
||||||
case 'quarterly':
|
const nextSun = new Date(nextMon);
|
||||||
now.setMonth(now.getMonth() + 3);
|
nextSun.setDate(nextMon.getDate() + 6);
|
||||||
return now;
|
return { verfuegbar_ab: nextMon, faellig_am: nextSun };
|
||||||
case 'halfyearly':
|
}
|
||||||
now.setMonth(now.getMonth() + 6);
|
case 'monthly': {
|
||||||
return now;
|
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
case 'yearly':
|
const lastDay = new Date(nextMonth.getFullYear(), nextMonth.getMonth() + 1, 0);
|
||||||
now.setFullYear(now.getFullYear() + 1);
|
return { verfuegbar_ab: nextMonth, faellig_am: lastDay };
|
||||||
return now;
|
}
|
||||||
|
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:
|
default:
|
||||||
return null;
|
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
|
// Subquery fragments for junction table arrays
|
||||||
const JUNCTION_SUBQUERIES = `
|
const JUNCTION_SUBQUERIES = `
|
||||||
ARRAY(SELECT fahrzeug_typ_id FROM checklist_vorlage_fahrzeug_typen WHERE vorlage_id = v.id) AS fahrzeug_typ_ids,
|
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() {
|
async function getOverviewItems() {
|
||||||
try {
|
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(`
|
const vehiclesResult = await pool.query(`
|
||||||
SELECT f.id, COALESCE(f.bezeichnung, f.kurzname) AS name,
|
SELECT f.id, COALESCE(f.bezeichnung, f.kurzname) AS name,
|
||||||
json_agg(DISTINCT jsonb_build_object(
|
json_agg(DISTINCT jsonb_build_object(
|
||||||
'vorlage_id', cv.id,
|
'vorlage_id', cv.id,
|
||||||
'vorlage_name', cv.name,
|
'vorlage_name', cv.name,
|
||||||
'intervall', cv.intervall,
|
'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
|
)) AS checklists
|
||||||
FROM fahrzeuge f
|
FROM fahrzeuge f
|
||||||
JOIN checklist_vorlagen cv ON cv.aktiv = true
|
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_typen WHERE vorlage_id = cv.id)
|
||||||
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id)
|
AND NOT EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung WHERE vorlage_id = cv.id)
|
||||||
AND (
|
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_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
|
WHERE f.deleted_at IS NULL
|
||||||
GROUP BY f.id, f.bezeichnung, f.kurzname
|
GROUP BY f.id, f.bezeichnung, f.kurzname
|
||||||
ORDER BY f.bezeichnung ASC, f.kurzname ASC
|
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(`
|
const equipmentResult = await pool.query(`
|
||||||
SELECT a.id, a.bezeichnung AS name,
|
SELECT a.id, a.bezeichnung AS name,
|
||||||
json_agg(DISTINCT jsonb_build_object(
|
json_agg(DISTINCT jsonb_build_object(
|
||||||
'vorlage_id', cv.id,
|
'vorlage_id', cv.id,
|
||||||
'vorlage_name', cv.name,
|
'vorlage_name', cv.name,
|
||||||
'intervall', cv.intervall,
|
'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
|
)) AS checklists
|
||||||
FROM ausruestung a
|
FROM ausruestung a
|
||||||
JOIN checklist_vorlagen cv ON cv.aktiv = true
|
JOIN checklist_vorlagen cv ON cv.aktiv = true
|
||||||
|
AND cv.intervall IS NOT NULL
|
||||||
AND (
|
AND (
|
||||||
EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = cv.id AND cva.ausruestung_id = a.id)
|
EXISTS (SELECT 1 FROM checklist_vorlage_ausruestung cva WHERE cva.vorlage_id = cv.id AND cva.ausruestung_id = a.id)
|
||||||
OR EXISTS (
|
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
|
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
|
GROUP BY a.id, a.bezeichnung
|
||||||
ORDER BY a.bezeichnung ASC
|
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<string, any>();
|
||||||
|
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 {
|
return {
|
||||||
vehicles: vehiclesResult.rows,
|
vehicles,
|
||||||
equipment: equipmentResult.rows,
|
equipment,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ChecklistService.getOverviewItems failed', { error });
|
logger.error('ChecklistService.getOverviewItems failed', { error });
|
||||||
@@ -812,6 +1007,12 @@ async function submitExecution(
|
|||||||
notizen: string | null,
|
notizen: string | null,
|
||||||
_userId: string,
|
_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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
@@ -856,23 +1057,23 @@ async function submitExecution(
|
|||||||
const { vorlage_id, fahrzeug_id, ausruestung_id } = exec.rows[0];
|
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]);
|
const vorlage = await client.query(`SELECT intervall, intervall_tage FROM checklist_vorlagen WHERE id = $1`, [vorlage_id]);
|
||||||
if (vorlage.rows.length > 0) {
|
if (vorlage.rows.length > 0) {
|
||||||
const nextDue = calculateNextDueDate(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage);
|
const nextPeriod = calculateNextPeriod(vorlage.rows[0].intervall, vorlage.rows[0].intervall_tage);
|
||||||
if (nextDue) {
|
if (nextPeriod) {
|
||||||
if (ausruestung_id) {
|
if (ausruestung_id) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO checklist_faelligkeit (ausruestung_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
|
`INSERT INTO checklist_faelligkeit (ausruestung_id, vorlage_id, naechste_faellig_am, verfuegbar_ab, letzte_ausfuehrung_id)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
ON CONFLICT (vorlage_id, ausruestung_id) WHERE ausruestung_id IS NOT NULL AND fahrzeug_id IS NULL
|
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`,
|
DO UPDATE SET naechste_faellig_am = $3, verfuegbar_ab = $4, letzte_ausfuehrung_id = $5`,
|
||||||
[ausruestung_id, vorlage_id, nextDue, id]
|
[ausruestung_id, vorlage_id, nextPeriod.faellig_am, nextPeriod.verfuegbar_ab, id]
|
||||||
);
|
);
|
||||||
} else if (fahrzeug_id) {
|
} else if (fahrzeug_id) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, letzte_ausfuehrung_id)
|
`INSERT INTO checklist_faelligkeit (fahrzeug_id, vorlage_id, naechste_faellig_am, verfuegbar_ab, letzte_ausfuehrung_id)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
ON CONFLICT (vorlage_id, fahrzeug_id) WHERE fahrzeug_id IS NOT NULL AND ausruestung_id IS NULL
|
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`,
|
DO UPDATE SET naechste_faellig_am = $3, verfuegbar_ab = $4, letzte_ausfuehrung_id = $5`,
|
||||||
[fahrzeug_id, vorlage_id, nextDue, id]
|
[fahrzeug_id, vorlage_id, nextPeriod.faellig_am, nextPeriod.verfuegbar_ab, id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pool from '../config/database';
|
import pool from '../config/database';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
import settingsService from './settings.service';
|
||||||
import {
|
import {
|
||||||
VeranstaltungKategorie,
|
VeranstaltungKategorie,
|
||||||
Veranstaltung,
|
Veranstaltung,
|
||||||
@@ -671,7 +672,7 @@ class EventsService {
|
|||||||
token = inserted.rows[0].token;
|
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}`;
|
const subscribeUrl = `${baseUrl}/api/events/calendar.ics?token=${token}`;
|
||||||
|
|
||||||
return { token, subscribeUrl };
|
return { token, subscribeUrl };
|
||||||
|
|||||||
@@ -3,22 +3,57 @@ import pool from '../config/database';
|
|||||||
export interface AppSetting {
|
export interface AppSetting {
|
||||||
key: string;
|
key: string;
|
||||||
value: any;
|
value: any;
|
||||||
|
is_set?: boolean;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
updated_by: string | null;
|
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 {
|
class SettingsService {
|
||||||
async getAll(): Promise<AppSetting[]> {
|
async getAll(): Promise<AppSetting[]> {
|
||||||
const result = await pool.query('SELECT * FROM app_settings ORDER BY key');
|
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<AppSetting | null> {
|
async get(key: string): Promise<AppSetting | null> {
|
||||||
const result = await pool.query('SELECT * FROM app_settings WHERE key = $1', [key]);
|
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<string | null> {
|
||||||
|
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<string> {
|
||||||
|
const val = await this.getSecret(key);
|
||||||
|
if (val !== null && val !== '' && val !== '""') return val;
|
||||||
|
return envFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: any, userId: string): Promise<AppSetting> {
|
async set(key: string, value: any, userId: string): Promise<AppSetting> {
|
||||||
|
if (value === '__UNCHANGED__') {
|
||||||
|
const existing = await this.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
}
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO app_settings (key, value, updated_by, updated_at)
|
`INSERT INTO app_settings (key, value, updated_by, updated_at)
|
||||||
VALUES ($1, $2, $3, NOW())
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ async function getBookstackConfig(): Promise<BookstackConfig> {
|
|||||||
|
|
||||||
const db = await getDbConfig('tool_config_bookstack');
|
const db = await getDbConfig('tool_config_bookstack');
|
||||||
const config: BookstackConfig = {
|
const config: BookstackConfig = {
|
||||||
url: db.url || environment.bookstack.url,
|
url: db.url || await settingsService.getSettingOrEnv('integration_bookstack_url', environment.bookstack.url),
|
||||||
tokenId: db.tokenId || environment.bookstack.tokenId,
|
tokenId: db.tokenId || await settingsService.getSettingOrEnv('integration_bookstack_token_id', environment.bookstack.tokenId),
|
||||||
tokenSecret: db.tokenSecret || environment.bookstack.tokenSecret,
|
tokenSecret: db.tokenSecret || await settingsService.getSettingOrEnv('integration_bookstack_token_secret', environment.bookstack.tokenSecret),
|
||||||
};
|
};
|
||||||
|
|
||||||
bookstackCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
|
bookstackCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||||
@@ -58,8 +58,8 @@ async function getVikunjaConfig(): Promise<VikunjaConfig> {
|
|||||||
|
|
||||||
const db = await getDbConfig('tool_config_vikunja');
|
const db = await getDbConfig('tool_config_vikunja');
|
||||||
const config: VikunjaConfig = {
|
const config: VikunjaConfig = {
|
||||||
url: db.url || environment.vikunja.url,
|
url: db.url || await settingsService.getSettingOrEnv('integration_vikunja_url', environment.vikunja.url),
|
||||||
apiToken: db.apiToken || environment.vikunja.apiToken,
|
apiToken: db.apiToken || await settingsService.getSettingOrEnv('integration_vikunja_api_token', environment.vikunja.apiToken),
|
||||||
};
|
};
|
||||||
|
|
||||||
vikunjaCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
|
vikunjaCache = { data: config, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||||
@@ -73,7 +73,7 @@ async function getNextcloudConfig(): Promise<NextcloudConfig> {
|
|||||||
|
|
||||||
const db = await getDbConfig('tool_config_nextcloud');
|
const db = await getDbConfig('tool_config_nextcloud');
|
||||||
const config: NextcloudConfig = {
|
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_username: db.bot_username || undefined,
|
||||||
bot_app_password: db.bot_app_password || undefined,
|
bot_app_password: db.bot_app_password || undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tab,
|
Tab,
|
||||||
|
Chip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Delete,
|
Delete,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
PictureAsPdf as PdfIcon,
|
PictureAsPdf as PdfIcon,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Checkroom as CheckroomIcon,
|
Checkroom as CheckroomIcon,
|
||||||
|
Extension as ExtensionIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
@@ -156,6 +158,27 @@ function AdminSettings() {
|
|||||||
// State for app logo
|
// State for app logo
|
||||||
const [appLogo, setAppLogo] = useState('');
|
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
|
// Fetch all settings
|
||||||
const { data: settings, isLoading } = useQuery({
|
const { data: settings, isLoading } = useQuery({
|
||||||
queryKey: ['admin-settings'],
|
queryKey: ['admin-settings'],
|
||||||
@@ -195,6 +218,40 @@ function AdminSettings() {
|
|||||||
if (pdfOrgNameSetting?.value != null) setPdfOrgName(pdfOrgNameSetting.value);
|
if (pdfOrgNameSetting?.value != null) setPdfOrgName(pdfOrgNameSetting.value);
|
||||||
const appLogoSetting = settings.find((s) => s.key === 'app_logo');
|
const appLogoSetting = settings.find((s) => s.key === 'app_logo');
|
||||||
if (appLogoSetting?.value != null) setAppLogo(appLogoSetting.value);
|
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]);
|
}, [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) {
|
if (!canAccess) {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
@@ -677,7 +826,238 @@ function AdminSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 5: Info */}
|
{/* Section 5: Integrationen */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<ExtensionIcon color="primary" sx={{ mr: 2 }} />
|
||||||
|
<Typography variant="h6">Integrationen</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
URLs und Zugangsdaten für externe Dienste. Leere URL-Felder verwenden die Umgebungsvariable als Fallback.
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{/* BookStack */}
|
||||||
|
<Typography variant="subtitle2" gutterBottom>BookStack</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="URL"
|
||||||
|
value={bookstackUrl}
|
||||||
|
onChange={(e) => setBookstackUrl(e.target.value)}
|
||||||
|
placeholder="Aus Umgebungsvariable"
|
||||||
|
size="small"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Token ID"
|
||||||
|
type="password"
|
||||||
|
value={bookstackTokenId}
|
||||||
|
onChange={(e) => setBookstackTokenId(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={bookstackTokenIdIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||||
|
color={bookstackTokenIdIsSet ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Token Secret"
|
||||||
|
type="password"
|
||||||
|
value={bookstackTokenSecret}
|
||||||
|
onChange={(e) => setBookstackTokenSecret(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={bookstackTokenSecretIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||||
|
color={bookstackTokenSecretIsSet ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveBookstack}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={isSavingBookstack}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
{/* Nextcloud */}
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Nextcloud</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="URL"
|
||||||
|
value={nextcloudUrl}
|
||||||
|
onChange={(e) => setNextcloudUrl(e.target.value)}
|
||||||
|
placeholder="Aus Umgebungsvariable"
|
||||||
|
size="small"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveNextcloud}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={isSavingNextcloud}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
{/* Vikunja */}
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Vikunja</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="URL"
|
||||||
|
value={vikunjaUrl}
|
||||||
|
onChange={(e) => setVikunjaUrl(e.target.value)}
|
||||||
|
placeholder="Aus Umgebungsvariable"
|
||||||
|
size="small"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="API Token"
|
||||||
|
type="password"
|
||||||
|
value={vikunjaApiToken}
|
||||||
|
onChange={(e) => setVikunjaApiToken(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={vikunjaApiTokenIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||||
|
color={vikunjaApiTokenIsSet ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveVikunja}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={isSavingVikunja}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
{/* iCal */}
|
||||||
|
<Typography variant="subtitle2" gutterBottom>iCal Abonnements</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Basis-URL"
|
||||||
|
value={icalBaseUrl}
|
||||||
|
onChange={(e) => 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 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveIcal}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={isSavingIcal}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
{/* FDISK */}
|
||||||
|
<Typography variant="subtitle2" gutterBottom>FDISK Sync</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Basis-URL"
|
||||||
|
value={fdiskBaseUrl}
|
||||||
|
onChange={(e) => setFdiskBaseUrl(e.target.value)}
|
||||||
|
placeholder="Aus Umgebungsvariable"
|
||||||
|
size="small"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Feuerwehr-ID"
|
||||||
|
value={fdiskIdFeuerwehren}
|
||||||
|
onChange={(e) => setFdiskIdFeuerwehren(e.target.value)}
|
||||||
|
placeholder="Aus Umgebungsvariable"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Instanz-ID"
|
||||||
|
value={fdiskIdInstanzen}
|
||||||
|
onChange={(e) => setFdiskIdInstanzen(e.target.value)}
|
||||||
|
placeholder="Aus Umgebungsvariable"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Benutzername"
|
||||||
|
type="password"
|
||||||
|
value={fdiskUsername}
|
||||||
|
onChange={(e) => setFdiskUsername(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={fdiskUsernameIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||||
|
color={fdiskUsernameIsSet ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
value={fdiskPassword}
|
||||||
|
onChange={(e) => setFdiskPassword(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={fdiskPasswordIsSet ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||||
|
color={fdiskPasswordIsSet ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveFdisk}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={isSavingFdisk}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 6: Info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
|||||||
@@ -5,17 +5,14 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Chip,
|
Checkbox,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Divider,
|
Divider,
|
||||||
Paper,
|
Paper,
|
||||||
Radio,
|
|
||||||
RadioGroup,
|
|
||||||
FormControlLabel,
|
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} 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 { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
@@ -34,7 +31,6 @@ const formatDate = (iso?: string) =>
|
|||||||
const ERGEBNIS_ICONS: Record<string, JSX.Element> = {
|
const ERGEBNIS_ICONS: Record<string, JSX.Element> = {
|
||||||
ok: <CheckCircle fontSize="small" color="success" />,
|
ok: <CheckCircle fontSize="small" color="success" />,
|
||||||
nok: <Cancel fontSize="small" color="error" />,
|
nok: <Cancel fontSize="small" color="error" />,
|
||||||
na: <RemoveCircle fontSize="small" color="disabled" />,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -86,7 +82,7 @@ export default function ChecklistAusfuehrung() {
|
|||||||
if (!execution?.items) return;
|
if (!execution?.items) return;
|
||||||
const results: Record<number, { ergebnis: 'ok' | 'nok' | 'na'; kommentar: string }> = {};
|
const results: Record<number, { ergebnis: 'ok' | 'nok' | 'na'; kommentar: string }> = {};
|
||||||
for (const item of execution.items) {
|
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);
|
setItemResults(results);
|
||||||
setNotizen(execution.notizen ?? '');
|
setNotizen(execution.notizen ?? '');
|
||||||
@@ -109,9 +105,14 @@ export default function ChecklistAusfuehrung() {
|
|||||||
.filter((i) => i.parent_ausfuehrung_item_id != null)
|
.filter((i) => i.parent_ausfuehrung_item_id != null)
|
||||||
.map((i) => i.parent_ausfuehrung_item_id!)
|
.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!, {
|
return checklistenApi.submitExecution(id!, {
|
||||||
items: Object.entries(itemResults)
|
items: leafItems
|
||||||
.filter(([itemId]) => !parentIds.has(Number(itemId)))
|
|
||||||
.map(([itemId, r]) => ({ itemId: Number(itemId), ergebnis: r.ergebnis, kommentar: r.kommentar || undefined })),
|
.map(([itemId, r]) => ({ itemId: Number(itemId), ergebnis: r.ergebnis, kommentar: r.kommentar || undefined })),
|
||||||
notizen: notizen || undefined,
|
notizen: notizen || undefined,
|
||||||
});
|
});
|
||||||
@@ -122,7 +123,13 @@ export default function ChecklistAusfuehrung() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['checklisten-faellig'] });
|
queryClient.invalidateQueries({ queryKey: ['checklisten-faellig'] });
|
||||||
showSuccess('Checkliste abgeschlossen');
|
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 ──
|
// ── Approve ──
|
||||||
@@ -185,34 +192,36 @@ export default function ChecklistAusfuehrung() {
|
|||||||
{isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]}
|
{isReadOnly && result?.ergebnis && ERGEBNIS_ICONS[result.ergebnis]}
|
||||||
</Box>
|
</Box>
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<Box>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Chip
|
<Checkbox
|
||||||
label={result?.ergebnis === 'ok' ? 'OK' : result?.ergebnis === 'nok' ? 'Nicht OK' : 'N/A'}
|
checked={result?.ergebnis === 'ok'}
|
||||||
color={result?.ergebnis === 'ok' ? 'success' : result?.ergebnis === 'nok' ? 'error' : 'default'}
|
disabled
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
{result?.kommentar && (
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{result.kommentar}</Typography>
|
{result?.ergebnis === 'na' && (
|
||||||
)}
|
<Typography variant="caption" color="text.secondary">(N/A)</Typography>
|
||||||
|
)}
|
||||||
|
{result?.kommentar && (
|
||||||
|
<Typography variant="body2" color="text.secondary">{result.kommentar}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||||
<RadioGroup
|
<Checkbox
|
||||||
row
|
checked={result?.ergebnis === 'ok'}
|
||||||
value={result?.ergebnis ?? ''}
|
onChange={(e) => setItemResult(item.id, e.target.checked ? 'ok' : 'nok')}
|
||||||
onChange={(e) => setItemResult(item.id, e.target.value as 'ok' | 'nok' | 'na')}
|
/>
|
||||||
>
|
|
||||||
<FormControlLabel value="ok" control={<Radio size="small" />} label="OK" />
|
|
||||||
<FormControlLabel value="nok" control={<Radio size="small" />} label="Nicht OK" />
|
|
||||||
<FormControlLabel value="na" control={<Radio size="small" />} label="N/A" />
|
|
||||||
</RadioGroup>
|
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Kommentar (optional)"
|
placeholder={result?.ergebnis === 'nok' ? 'Kommentar (erforderlich)' : 'Kommentar (optional)'}
|
||||||
fullWidth
|
fullWidth
|
||||||
value={result?.kommentar ?? ''}
|
value={result?.kommentar ?? ''}
|
||||||
onChange={(e) => setItemComment(item.id, e.target.value)}
|
onChange={(e) => 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'}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -257,13 +257,85 @@ function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabPro
|
|||||||
return <Alert severity="info" sx={{ mt: 1 }}>Keine Checklisten zugewiesen</Alert>;
|
return <Alert severity="info" sx={{ mt: 1 }}>Keine Checklisten zugewiesen</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1.5 }}>
|
||||||
|
Offene Prüfungen ({openChecks.length})
|
||||||
|
</Typography>
|
||||||
|
<List disablePadding>
|
||||||
|
{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 (
|
||||||
|
<ListItemButton
|
||||||
|
key={`${cl.targetId}-${cl.vorlage_id}-${cl.ausruestung_id ?? ''}`}
|
||||||
|
onClick={() => 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' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={primaryText}
|
||||||
|
secondary={secondaryText}
|
||||||
|
primaryTypographyProps={{ variant: 'body2' }}
|
||||||
|
secondaryTypographyProps={{ variant: 'caption' }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={label}
|
||||||
|
color={color}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ml: 1, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
{canExecute && <PlayArrow fontSize="small" color="action" sx={{ ml: 1, opacity: 0.5 }} />}
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Normal accordion view (no open checks) ──
|
||||||
|
|
||||||
const renderChecklistRow = (cl: ChecklistOverviewChecklist, itemId: string, type: 'fahrzeug' | 'ausruestung', index: number) => {
|
const renderChecklistRow = (cl: ChecklistOverviewChecklist, itemId: string, type: 'fahrzeug' | 'ausruestung', index: number) => {
|
||||||
const color = getDueColor(cl.next_due, cl.intervall);
|
const color = getDueColor(cl.next_due, cl.intervall);
|
||||||
const label = getDueLabel(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 (
|
return (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
key={cl.vorlage_id}
|
key={`${cl.vorlage_id}-${cl.ausruestung_id ?? ''}`}
|
||||||
onClick={() => canExecute && navigate(`/checklisten/ausfuehrung/new?${param}&vorlage=${cl.vorlage_id}`)}
|
onClick={() => canExecute && navigate(`/checklisten/ausfuehrung/new?${param}&vorlage=${cl.vorlage_id}`)}
|
||||||
sx={{
|
sx={{
|
||||||
py: 0.75,
|
py: 0.75,
|
||||||
@@ -274,8 +346,10 @@ function OverviewTab({ overview, loading, canExecute, navigate }: OverviewTabPro
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={cl.vorlage_name}
|
primary={primaryText}
|
||||||
|
secondary={secondaryText}
|
||||||
primaryTypographyProps={{ variant: 'body2' }}
|
primaryTypographyProps={{ variant: 'body2' }}
|
||||||
|
secondaryTypographyProps={{ variant: 'caption' }}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label={label}
|
label={label}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from './api';
|
|||||||
interface AppSetting {
|
interface AppSetting {
|
||||||
key: string;
|
key: string;
|
||||||
value: any;
|
value: any;
|
||||||
|
is_set?: boolean;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,11 @@ export interface ChecklistOverviewChecklist {
|
|||||||
vorlage_name: string;
|
vorlage_name: string;
|
||||||
intervall?: string | null;
|
intervall?: string | null;
|
||||||
next_due?: string | null;
|
next_due?: string | null;
|
||||||
|
verfuegbar_ab?: string | null;
|
||||||
|
letzte_ausfuehrung_am?: string | null;
|
||||||
|
ist_faellig?: boolean;
|
||||||
|
ausruestung_id?: string;
|
||||||
|
ausruestung_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChecklistOverviewItem {
|
export interface ChecklistOverviewItem {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { scrapeAll } from './scraper';
|
import { scrapeAll, FdiskConfig } from './scraper';
|
||||||
import { syncToDatabase, syncLehrgangToAtemschutz, syncUntersuchungenToAtemschutz } from './db';
|
import { syncToDatabase, syncLehrgangToAtemschutz, syncUntersuchungenToAtemschutz } from './db';
|
||||||
|
|
||||||
// In-memory log ring buffer — last 500 lines captured from all modules
|
// In-memory log ring buffer — last 500 lines captured from all modules
|
||||||
@@ -52,8 +52,6 @@ async function runSync(force = false): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
syncRunning = true;
|
syncRunning = true;
|
||||||
const username = requireEnv('FDISK_USERNAME');
|
|
||||||
const password = requireEnv('FDISK_PASSWORD');
|
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
host: requireEnv('DB_HOST'),
|
host: requireEnv('DB_HOST'),
|
||||||
@@ -64,10 +62,32 @@ async function runSync(force = false): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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<string, string> = {};
|
||||||
|
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');
|
if (force) log('Force mode: ON');
|
||||||
log('Starting FDISK sync');
|
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);
|
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`);
|
log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen, ${befoerderungen.length} Beförderungen, ${untersuchungen.length} Untersuchungen, ${fahrgenehmigungen.length} Fahrgenehmigungen`);
|
||||||
await syncLehrgangToAtemschutz(pool);
|
await syncLehrgangToAtemschutz(pool);
|
||||||
|
|||||||
@@ -7,12 +7,20 @@ import {
|
|||||||
FdiskFahrgenehmigung,
|
FdiskFahrgenehmigung,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const BASE_URL = process.env.FDISK_BASE_URL ?? 'https://app.fdisk.at';
|
export interface FdiskConfig {
|
||||||
const ID_FEUERWEHREN = process.env.FDISK_ID_FEUERWEHREN ?? '164';
|
baseUrl: string;
|
||||||
const ID_INSTANZEN = process.env.FDISK_ID_INSTANZEN ?? '2853';
|
idFeuerwehren: string;
|
||||||
|
idInstanzen: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
const LOGIN_URL = `${BASE_URL}/fdisk/module/vws/logins/logins.aspx`;
|
let BASE_URL = process.env.FDISK_BASE_URL ?? 'https://app.fdisk.at';
|
||||||
const MEMBERS_URL = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/meine_Mitglieder.aspx`;
|
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.
|
* Maps a raw FDISK status string to a dashboard status value.
|
||||||
@@ -173,13 +181,20 @@ async function scrapeKnownMembers(
|
|||||||
return members;
|
return members;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scrapeAll(username: string, password: string): Promise<{
|
export async function scrapeAll(config: FdiskConfig): Promise<{
|
||||||
members: FdiskMember[];
|
members: FdiskMember[];
|
||||||
ausbildungen: FdiskAusbildung[];
|
ausbildungen: FdiskAusbildung[];
|
||||||
befoerderungen: FdiskBefoerderung[];
|
befoerderungen: FdiskBefoerderung[];
|
||||||
untersuchungen: FdiskUntersuchung[];
|
untersuchungen: FdiskUntersuchung[];
|
||||||
fahrgenehmigungen: FdiskFahrgenehmigung[];
|
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({
|
const browser = await chromium.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: ['--disable-gpu', '--disable-software-rasterizer'],
|
args: ['--disable-gpu', '--disable-software-rasterizer'],
|
||||||
@@ -190,7 +205,7 @@ export async function scrapeAll(username: string, password: string): Promise<{
|
|||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(page, username, password);
|
await login(page, config.username, config.password);
|
||||||
|
|
||||||
// After login, page is on Start.aspx (frameset).
|
// After login, page is on Start.aspx (frameset).
|
||||||
// Direct navigation to MitgliedschaftenList.aspx causes a server BLError because
|
// Direct navigation to MitgliedschaftenList.aspx causes a server BLError because
|
||||||
|
|||||||
Reference in New Issue
Block a user