This commit is contained in:
Matthias Hochmeister
2026-03-13 19:23:39 +01:00
parent 02d9d808b2
commit bc6d09200a
15 changed files with 610 additions and 74 deletions

View File

@@ -25,8 +25,12 @@ function handleZodError(res: Response, err: ZodError): void {
} }
function handleConflictError(res: Response, err: Error): boolean { function handleConflictError(res: Response, err: Error): boolean {
if (err.message?.includes('außer Dienst')) {
res.status(409).json({ success: false, message: err.message, reason: 'out_of_service' });
return true;
}
if (err.message?.includes('bereits gebucht')) { if (err.message?.includes('bereits gebucht')) {
res.status(409).json({ success: false, message: err.message }); res.status(409).json({ success: false, message: err.message, reason: 'booking_conflict' });
return true; return true;
} }
return false; return false;
@@ -88,10 +92,27 @@ class BookingController {
.json({ success: false, message: 'fahrzeugId, from und to sind erforderlich' }); .json({ success: false, message: 'fahrzeugId, from und to sind erforderlich' });
return; return;
} }
const beginn = new Date(from as string);
const ende = new Date(to as string);
const outOfService = await bookingService.checkOutOfServiceConflict(
fahrzeugId as string, beginn, ende
);
if (outOfService) {
res.json({
success: true,
data: {
available: false,
reason: 'out_of_service',
ausserDienstVon: outOfService.ausser_dienst_von.toISOString(),
ausserDienstBis: outOfService.ausser_dienst_bis.toISOString(),
},
});
return;
}
const hasConflict = await bookingService.checkConflict( const hasConflict = await bookingService.checkConflict(
fahrzeugId as string, fahrzeugId as string, beginn, ende
new Date(from as string),
new Date(to as string)
); );
res.json({ success: true, data: { available: !hasConflict } }); res.json({ success: true, data: { available: !hasConflict } });
} catch (error) { } catch (error) {

View File

@@ -16,7 +16,6 @@ const FahrzeugStatusEnum = z.enum([
FahrzeugStatus.Einsatzbereit, FahrzeugStatus.Einsatzbereit,
FahrzeugStatus.AusserDienstWartung, FahrzeugStatus.AusserDienstWartung,
FahrzeugStatus.AusserDienstSchaden, FahrzeugStatus.AusserDienstSchaden,
FahrzeugStatus.InLehrgang,
]); ]);
const isoDate = z.string().regex( const isoDate = z.string().regex(
@@ -64,10 +63,27 @@ const UpdateFahrzeugSchema = z.object({
naechste_wartung_am: isoDate.nullable().optional(), naechste_wartung_am: isoDate.nullable().optional(),
}); });
const isoDatetime = z.string().datetime({ offset: true, message: 'Erwartet ISO-8601 Datum mit Zeitzone' });
const UpdateStatusSchema = z.object({ const UpdateStatusSchema = z.object({
status: FahrzeugStatusEnum, status: FahrzeugStatusEnum,
bemerkung: z.string().max(500).optional().default(''), bemerkung: z.string().max(500).optional().default(''),
}); ausserDienstVon: isoDatetime.optional(),
ausserDienstBis: isoDatetime.optional(),
}).refine(
(d) => {
const isAusserDienst = d.status === FahrzeugStatus.AusserDienstWartung || d.status === FahrzeugStatus.AusserDienstSchaden;
if (!isAusserDienst) return true;
return !!d.ausserDienstVon && !!d.ausserDienstBis;
},
{ message: 'Außer-Dienst-Zeitraum (von + bis) ist bei diesem Status erforderlich', path: ['ausserDienstVon'] }
).refine(
(d) => {
if (!d.ausserDienstVon || !d.ausserDienstBis) return true;
return new Date(d.ausserDienstBis) > new Date(d.ausserDienstVon);
},
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
);
const CreateWartungslogSchema = z.object({ const CreateWartungslogSchema = z.object({
datum: isoDate, datum: isoDate,
@@ -211,10 +227,16 @@ class VehicleController {
return; return;
} }
const io = req.app.get('io') ?? undefined; const io = req.app.get('io') ?? undefined;
await vehicleService.updateVehicleStatus( const result = await vehicleService.updateVehicleStatus(
id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io id,
parsed.data.status,
parsed.data.bemerkung,
getUserId(req),
io,
parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null,
parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null,
); );
res.status(200).json({ success: true, message: 'Status aktualisiert' }); res.status(200).json({ success: true, data: result });
} catch (error: any) { } catch (error: any) {
if (error?.message === 'Vehicle not found') { if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });

View File

@@ -0,0 +1,127 @@
-- =============================================================================
-- Migration 029: Vehicle Out-of-Service Timeframe + Lehrgang Booking Type
--
-- 1. Add ausser_dienst_von / ausser_dienst_bis columns to fahrzeuge
-- 2. Add 'lehrgang' value to fahrzeug_buchung_art enum
-- 3. Remove 'in_lehrgang' from vehicle status CHECK constraint
-- 4. Refresh fahrzeuge_mit_pruefstatus view to include new columns
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. Add out-of-service timeframe columns
-- -----------------------------------------------------------------------------
ALTER TABLE fahrzeuge
ADD COLUMN IF NOT EXISTS ausser_dienst_von TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS ausser_dienst_bis TIMESTAMPTZ;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'fahrzeuge'
AND constraint_name = 'chk_ausser_dienst_zeitraum'
) THEN
ALTER TABLE fahrzeuge
ADD CONSTRAINT chk_ausser_dienst_zeitraum
CHECK (
ausser_dienst_von IS NULL
OR ausser_dienst_bis IS NULL
OR ausser_dienst_bis > ausser_dienst_von
);
END IF;
END
$$;
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_ausser_dienst_zeitraum
ON fahrzeuge(id, ausser_dienst_von, ausser_dienst_bis)
WHERE ausser_dienst_von IS NOT NULL;
-- -----------------------------------------------------------------------------
-- 2. Add 'lehrgang' to fahrzeug_buchung_art enum
-- -----------------------------------------------------------------------------
DO $$
BEGIN
ALTER TYPE fahrzeug_buchung_art ADD VALUE IF NOT EXISTS 'lehrgang';
EXCEPTION
WHEN others THEN
IF SQLERRM NOT LIKE '%already exists%' THEN
RAISE;
END IF;
END
$$;
-- -----------------------------------------------------------------------------
-- 3. Remove 'in_lehrgang' from vehicle status CHECK
-- -----------------------------------------------------------------------------
-- Migrate existing in_lehrgang rows first
UPDATE fahrzeuge SET status = 'einsatzbereit' WHERE status = 'in_lehrgang';
-- Drop old check constraint (created in migration 005 as fahrzeuge_status_check)
ALTER TABLE fahrzeuge DROP CONSTRAINT IF EXISTS fahrzeuge_status_check;
-- Re-add with only 3 valid values
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'fahrzeuge'
AND constraint_name = 'chk_fahrzeuge_status'
) THEN
ALTER TABLE fahrzeuge
ADD CONSTRAINT chk_fahrzeuge_status
CHECK (status IN (
'einsatzbereit',
'ausser_dienst_wartung',
'ausser_dienst_schaden'
));
END IF;
END
$$;
-- -----------------------------------------------------------------------------
-- 4. Refresh fahrzeuge_mit_pruefstatus view to include new columns
-- -----------------------------------------------------------------------------
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
SELECT
f.id,
f.bezeichnung,
f.kurzname,
f.amtliches_kennzeichen,
f.fahrgestellnummer,
f.baujahr,
f.hersteller,
f.typ_schluessel,
f.besatzung_soll,
f.status,
f.status_bemerkung,
f.ausser_dienst_von,
f.ausser_dienst_bis,
f.standort,
f.bild_url,
f.created_at,
f.updated_at,
f.paragraph57a_faellig_am,
CASE
WHEN f.paragraph57a_faellig_am IS NOT NULL
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
ELSE NULL
END AS paragraph57a_tage_bis_faelligkeit,
f.naechste_wartung_am,
CASE
WHEN f.naechste_wartung_am IS NOT NULL
THEN f.naechste_wartung_am::date - CURRENT_DATE
ELSE NULL
END AS wartung_tage_bis_faelligkeit,
LEAST(
CASE WHEN f.paragraph57a_faellig_am IS NOT NULL
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
ELSE NULL END,
CASE WHEN f.naechste_wartung_am IS NOT NULL
THEN f.naechste_wartung_am::date - CURRENT_DATE
ELSE NULL END
) AS naechste_pruefung_tage
FROM fahrzeuge f
WHERE f.deleted_at IS NULL;

View File

@@ -4,7 +4,7 @@ import { z } from 'zod';
// Enums // Enums
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const BUCHUNGS_ARTEN = ['intern', 'extern', 'wartung', 'reservierung', 'sonstiges'] as const; export const BUCHUNGS_ARTEN = ['intern', 'extern', 'wartung', 'reservierung', 'sonstiges', 'lehrgang'] as const;
export type BuchungsArt = (typeof BUCHUNGS_ARTEN)[number]; export type BuchungsArt = (typeof BUCHUNGS_ARTEN)[number];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -8,14 +8,12 @@ export enum FahrzeugStatus {
Einsatzbereit = 'einsatzbereit', Einsatzbereit = 'einsatzbereit',
AusserDienstWartung = 'ausser_dienst_wartung', AusserDienstWartung = 'ausser_dienst_wartung',
AusserDienstSchaden = 'ausser_dienst_schaden', AusserDienstSchaden = 'ausser_dienst_schaden',
InLehrgang = 'in_lehrgang',
} }
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = { export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit', [FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)', [FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
[FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)', [FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)',
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
}; };
export type WartungslogArt = export type WartungslogArt =
@@ -25,6 +23,12 @@ export type WartungslogArt =
// ── Core Entities ───────────────────────────────────────────────────────────── // ── Core Entities ─────────────────────────────────────────────────────────────
export interface AktiverLehrgang {
titel: string;
beginn: Date;
ende: Date;
}
export interface Fahrzeug { export interface Fahrzeug {
id: string; id: string;
bezeichnung: string; bezeichnung: string;
@@ -37,12 +41,15 @@ export interface Fahrzeug {
besatzung_soll: string | null; besatzung_soll: string | null;
status: FahrzeugStatus; status: FahrzeugStatus;
status_bemerkung: string | null; status_bemerkung: string | null;
ausser_dienst_von: Date | null;
ausser_dienst_bis: Date | null;
standort: string; standort: string;
bild_url: string | null; bild_url: string | null;
paragraph57a_faellig_am: Date | null; paragraph57a_faellig_am: Date | null;
naechste_wartung_am: Date | null; naechste_wartung_am: Date | null;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
aktiver_lehrgang?: AktiverLehrgang | null;
} }
export interface FahrzeugWartungslog { export interface FahrzeugWartungslog {
@@ -71,12 +78,15 @@ export interface FahrzeugListItem {
besatzung_soll: string | null; besatzung_soll: string | null;
status: FahrzeugStatus; status: FahrzeugStatus;
status_bemerkung: string | null; status_bemerkung: string | null;
ausser_dienst_von: Date | null;
ausser_dienst_bis: Date | null;
bild_url: string | null; bild_url: string | null;
paragraph57a_faellig_am: Date | null; paragraph57a_faellig_am: Date | null;
paragraph57a_tage_bis_faelligkeit: number | null; paragraph57a_tage_bis_faelligkeit: number | null;
naechste_wartung_am: Date | null; naechste_wartung_am: Date | null;
wartung_tage_bis_faelligkeit: number | null; wartung_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null; naechste_pruefung_tage: number | null;
aktiver_lehrgang?: AktiverLehrgang | null;
} }
// ── Detail View ─────────────────────────────────────────────────────────────── // ── Detail View ───────────────────────────────────────────────────────────────
@@ -94,11 +104,19 @@ export interface VehicleStats {
total: number; total: number;
einsatzbereit: number; einsatzbereit: number;
ausserDienst: number; ausserDienst: number;
inLehrgang: number; inLehrgang: number; // derived from active lehrgang bookings
inspectionsDue: number; inspectionsDue: number;
inspectionsOverdue: number; inspectionsOverdue: number;
} }
export interface OverlappingBooking {
id: string;
titel: string;
beginn: Date;
ende: Date;
gebucht_von_name: string;
}
// ── Inspection Alert ────────────────────────────────────────────────────────── // ── Inspection Alert ──────────────────────────────────────────────────────────
export type InspectionAlertType = '57a' | 'wartung'; export type InspectionAlertType = '57a' | 'wartung';

View File

@@ -141,6 +141,35 @@ class BookingService {
return rowToBuchung(rows[0]); return rowToBuchung(rows[0]);
} }
/**
* Checks whether a vehicle is out of service during the given interval.
* Returns the out-of-service period if there IS a conflict, null otherwise.
*/
async checkOutOfServiceConflict(
fahrzeugId: string,
beginn: Date,
ende: Date
): Promise<{ ausser_dienst_von: Date; ausser_dienst_bis: Date } | null> {
const query = `
SELECT ausser_dienst_von, ausser_dienst_bis
FROM fahrzeuge
WHERE id = $1
AND deleted_at IS NULL
AND status IN ('ausser_dienst_wartung', 'ausser_dienst_schaden')
AND ausser_dienst_von IS NOT NULL
AND ausser_dienst_bis IS NOT NULL
AND ($2::timestamptz, $3::timestamptz) OVERLAPS (ausser_dienst_von, ausser_dienst_bis)
LIMIT 1
`;
const { rows } = await pool.query(query, [fahrzeugId, beginn, ende]);
if (rows.length === 0) return null;
return {
ausser_dienst_von: new Date(rows[0].ausser_dienst_von),
ausser_dienst_bis: new Date(rows[0].ausser_dienst_bis),
};
}
/** /**
* Checks whether a vehicle is already booked for the given interval. * Checks whether a vehicle is already booked for the given interval.
* Returns true if there IS a conflict. * Returns true if there IS a conflict.
@@ -171,8 +200,13 @@ class BookingService {
return rows.length > 0; return rows.length > 0;
} }
/** Creates a new booking. Throws if the vehicle has a conflicting booking. */ /** Creates a new booking. Throws if the vehicle has a conflicting booking or is out of service. */
async create(data: CreateBuchungData, userId: string): Promise<FahrzeugBuchung> { async create(data: CreateBuchungData, userId: string): Promise<FahrzeugBuchung> {
const outOfService = await this.checkOutOfServiceConflict(data.fahrzeugId, data.beginn, data.ende);
if (outOfService) {
throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst');
}
const hasConflict = await this.checkConflict( const hasConflict = await this.checkConflict(
data.fahrzeugId, data.fahrzeugId,
data.beginn, data.beginn,
@@ -225,6 +259,15 @@ class BookingService {
data.fahrzeugId != null || data.beginn != null || data.ende != null; data.fahrzeugId != null || data.beginn != null || data.ende != null;
if (timingChanged) { if (timingChanged) {
const outOfService = await this.checkOutOfServiceConflict(
effectiveFahrzeugId,
effectiveBeginn,
effectiveEnde
);
if (outOfService) {
throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst');
}
const hasConflict = await this.checkConflict( const hasConflict = await this.checkConflict(
effectiveFahrzeugId, effectiveFahrzeugId,
effectiveBeginn, effectiveBeginn,

View File

@@ -11,6 +11,7 @@ import {
FahrzeugStatus, FahrzeugStatus,
VehicleStats, VehicleStats,
InspectionAlert, InspectionAlert,
OverlappingBooking,
} from '../models/vehicle.model'; } from '../models/vehicle.model';
class VehicleService { class VehicleService {
@@ -24,12 +25,30 @@ class VehicleService {
SELECT SELECT
id, bezeichnung, kurzname, amtliches_kennzeichen, id, bezeichnung, kurzname, amtliches_kennzeichen,
baujahr, hersteller, besatzung_soll, status, status_bemerkung, baujahr, hersteller, besatzung_soll, status, status_bemerkung,
ausser_dienst_von, ausser_dienst_bis,
bild_url, paragraph57a_faellig_am, paragraph57a_tage_bis_faelligkeit, bild_url, paragraph57a_faellig_am, paragraph57a_tage_bis_faelligkeit,
naechste_wartung_am, wartung_tage_bis_faelligkeit, naechste_pruefung_tage naechste_wartung_am, wartung_tage_bis_faelligkeit, naechste_pruefung_tage
FROM fahrzeuge_mit_pruefstatus FROM fahrzeuge_mit_pruefstatus
ORDER BY bezeichnung ASC ORDER BY bezeichnung ASC
`); `);
// Fetch active lehrgang bookings for all vehicles in one query
const lehrgangs = await pool.query(`
SELECT DISTINCT ON (b.fahrzeug_id)
b.fahrzeug_id, b.titel, b.beginn, b.ende
FROM fahrzeug_buchungen b
WHERE b.buchungs_art = 'lehrgang'
AND b.abgesagt = FALSE
AND b.beginn <= NOW()
AND b.ende >= NOW()
ORDER BY b.fahrzeug_id, b.beginn ASC
`);
const lehrgangsMap = new Map<string, { titel: string; beginn: Date; ende: Date }>();
for (const r of lehrgangs.rows) {
lehrgangsMap.set(r.fahrzeug_id, { titel: r.titel, beginn: new Date(r.beginn), ende: new Date(r.ende) });
}
return result.rows.map((row) => ({ return result.rows.map((row) => ({
...row, ...row,
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
@@ -38,6 +57,7 @@ class VehicleService {
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null, ? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
naechste_pruefung_tage: row.naechste_pruefung_tage != null naechste_pruefung_tage: row.naechste_pruefung_tage != null
? parseInt(row.naechste_pruefung_tage, 10) : null, ? parseInt(row.naechste_pruefung_tage, 10) : null,
aktiver_lehrgang: lehrgangsMap.get(row.id) ?? null,
})) as FahrzeugListItem[]; })) as FahrzeugListItem[];
} catch (error) { } catch (error) {
logger.error('VehicleService.getAllVehicles failed', { error }); logger.error('VehicleService.getAllVehicles failed', { error });
@@ -79,6 +99,8 @@ class VehicleService {
besatzung_soll: row.besatzung_soll, besatzung_soll: row.besatzung_soll,
status: row.status as FahrzeugStatus, status: row.status as FahrzeugStatus,
status_bemerkung: row.status_bemerkung, status_bemerkung: row.status_bemerkung,
ausser_dienst_von: row.ausser_dienst_von ?? null,
ausser_dienst_bis: row.ausser_dienst_bis ?? null,
standort: row.standort, standort: row.standort,
bild_url: row.bild_url, bild_url: row.bild_url,
paragraph57a_faellig_am: row.paragraph57a_faellig_am ?? null, paragraph57a_faellig_am: row.paragraph57a_faellig_am ?? null,
@@ -97,6 +119,23 @@ class VehicleService {
})) as FahrzeugWartungslog[], })) as FahrzeugWartungslog[],
}; };
// Fetch active lehrgang booking if any
const lehrgang = await pool.query(`
SELECT titel, beginn, ende
FROM fahrzeug_buchungen
WHERE fahrzeug_id = $1
AND buchungs_art = 'lehrgang'
AND abgesagt = FALSE
AND beginn <= NOW()
AND ende >= NOW()
ORDER BY beginn ASC
LIMIT 1
`, [id]);
vehicle.aktiver_lehrgang = lehrgang.rows.length > 0
? { titel: lehrgang.rows[0].titel, beginn: lehrgang.rows[0].beginn, ende: lehrgang.rows[0].ende }
: null;
return vehicle; return vehicle;
} catch (error) { } catch (error) {
logger.error('VehicleService.getVehicleById failed', { error, id }); logger.error('VehicleService.getVehicleById failed', { error, id });
@@ -224,8 +263,10 @@ class VehicleService {
status: FahrzeugStatus, status: FahrzeugStatus,
bemerkung: string, bemerkung: string,
updatedBy: string, updatedBy: string,
io?: any io?: any,
): Promise<void> { ausserDienstVon?: Date | null,
ausserDienstBis?: Date | null,
): Promise<{ overlappingBookings: OverlappingBooking[] }> {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
@@ -242,15 +283,41 @@ class VehicleService {
const { bezeichnung, status: oldStatus } = oldResult.rows[0]; const { bezeichnung, status: oldStatus } = oldResult.rows[0];
const isAusserDienst = status === FahrzeugStatus.AusserDienstWartung || status === FahrzeugStatus.AusserDienstSchaden;
await client.query( await client.query(
`UPDATE fahrzeuge SET status = $1, status_bemerkung = $2 WHERE id = $3`, `UPDATE fahrzeuge
[status, bemerkung || null, id] SET status = $1, status_bemerkung = $2,
ausser_dienst_von = $3, ausser_dienst_bis = $4
WHERE id = $5`,
[
status,
bemerkung || null,
isAusserDienst ? (ausserDienstVon ?? null) : null,
isAusserDienst ? (ausserDienstBis ?? null) : null,
id,
]
); );
await client.query('COMMIT'); await client.query('COMMIT');
logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy }); logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy });
// Find bookings that overlap with the out-of-service period
let overlappingBookings: OverlappingBooking[] = [];
if (isAusserDienst && ausserDienstVon && ausserDienstBis) {
const overlap = await pool.query(
`SELECT b.id, b.titel, b.beginn, b.ende, u.name AS gebucht_von_name
FROM fahrzeug_buchungen b
JOIN users u ON u.id = b.gebucht_von
WHERE b.fahrzeug_id = $1
AND b.abgesagt = FALSE
AND ($2::timestamptz, $3::timestamptz) OVERLAPS (b.beginn, b.ende)`,
[id, ausserDienstVon, ausserDienstBis]
);
overlappingBookings = overlap.rows;
}
if (io) { if (io) {
io.emit('vehicle:statusChanged', { io.emit('vehicle:statusChanged', {
vehicleId: id, vehicleId: id,
@@ -258,10 +325,14 @@ class VehicleService {
oldStatus, oldStatus,
newStatus: status, newStatus: status,
bemerkung: bemerkung || null, bemerkung: bemerkung || null,
ausserDienstVon: isAusserDienst ? ausserDienstVon ?? null : null,
ausserDienstBis: isAusserDienst ? ausserDienstBis ?? null : null,
updatedBy, updatedBy,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
return { overlappingBookings };
} catch (error) { } catch (error) {
await client.query('ROLLBACK').catch(() => {}); await client.query('ROLLBACK').catch(() => {});
logger.error('VehicleService.updateVehicleStatus failed', { error, id }); logger.error('VehicleService.updateVehicleStatus failed', { error, id });
@@ -347,12 +418,21 @@ class VehicleService {
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit, COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
COUNT(*) FILTER ( COUNT(*) FILTER (
WHERE status IN ('ausser_dienst_wartung','ausser_dienst_schaden') WHERE status IN ('ausser_dienst_wartung','ausser_dienst_schaden')
) AS ausser_dienst, ) AS ausser_dienst
COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang
FROM fahrzeuge FROM fahrzeuge
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
`); `);
// Count vehicles with an active lehrgang booking
const lehrgangsResult = await pool.query(`
SELECT COUNT(DISTINCT b.fahrzeug_id) AS in_lehrgang
FROM fahrzeug_buchungen b
WHERE b.buchungs_art = 'lehrgang'
AND b.abgesagt = FALSE
AND b.beginn <= NOW()
AND b.ende >= NOW()
`);
const alertResult = await pool.query(` const alertResult = await pool.query(`
SELECT SELECT
COUNT(*) FILTER ( COUNT(*) FILTER (
@@ -373,14 +453,15 @@ class VehicleService {
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
`); `);
const totals = totalsResult.rows[0] ?? { total: '0', einsatzbereit: '0', ausser_dienst: '0', in_lehrgang: '0' }; const totals = totalsResult.rows[0] ?? { total: '0', einsatzbereit: '0', ausser_dienst: '0' };
const lehrgangs = lehrgangsResult.rows[0] ?? { in_lehrgang: '0' };
const alerts = alertResult.rows[0] ?? { inspections_due: '0', inspections_overdue: '0' }; const alerts = alertResult.rows[0] ?? { inspections_due: '0', inspections_overdue: '0' };
return { return {
total: parseInt(totals.total ?? '0', 10), total: parseInt(totals.total ?? '0', 10),
einsatzbereit: parseInt(totals.einsatzbereit ?? '0', 10), einsatzbereit: parseInt(totals.einsatzbereit ?? '0', 10),
ausserDienst: parseInt(totals.ausser_dienst ?? '0', 10), ausserDienst: parseInt(totals.ausser_dienst ?? '0', 10),
inLehrgang: parseInt(totals.in_lehrgang ?? '0', 10), inLehrgang: parseInt(lehrgangs.in_lehrgang ?? '0', 10),
inspectionsDue: parseInt(alerts.inspections_due ?? '0', 10), inspectionsDue: parseInt(alerts.inspections_due ?? '0', 10),
inspectionsOverdue: parseInt(alerts.inspections_overdue ?? '0', 10), inspectionsOverdue: parseInt(alerts.inspections_overdue ?? '0', 10),
}; };

View File

@@ -40,6 +40,7 @@ import {
IosShare, IosShare,
CheckCircle, CheckCircle,
Warning, Warning,
Block,
} from '@mui/icons-material'; } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -62,6 +63,7 @@ import {
isToday, isToday,
parseISO, parseISO,
isSameDay, isSameDay,
isWithinInterval,
} from 'date-fns'; } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
@@ -156,6 +158,18 @@ function FahrzeugBuchungen() {
}); });
}; };
const isOutOfService = (vehicle: Fahrzeug, day: Date): boolean => {
if (!vehicle.ausser_dienst_von || !vehicle.ausser_dienst_bis) return false;
try {
return isWithinInterval(day, {
start: parseISO(vehicle.ausser_dienst_von),
end: parseISO(vehicle.ausser_dienst_bis),
});
} catch {
return false;
}
};
// ── Create / Edit dialog ────────────────────────────────────────────────── // ── Create / Edit dialog ──────────────────────────────────────────────────
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingBooking, setEditingBooking] = const [editingBooking, setEditingBooking] =
@@ -163,7 +177,11 @@ function FahrzeugBuchungen() {
const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM }); const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM });
const [dialogLoading, setDialogLoading] = useState(false); const [dialogLoading, setDialogLoading] = useState(false);
const [dialogError, setDialogError] = useState<string | null>(null); const [dialogError, setDialogError] = useState<string | null>(null);
const [availability, setAvailability] = useState<boolean | null>(null); const [availability, setAvailability] = useState<{
available: boolean;
reason?: string;
ausserDienstBis?: string;
} | null>(null);
// Check availability whenever the relevant form fields change // Check availability whenever the relevant form fields change
useEffect(() => { useEffect(() => {
@@ -178,8 +196,8 @@ function FahrzeugBuchungen() {
new Date(form.beginn), new Date(form.beginn),
new Date(form.ende) new Date(form.ende)
) )
.then(({ available }) => { .then((result) => {
if (!cancelled) setAvailability(available); if (!cancelled) setAvailability(result);
}) })
.catch(() => { .catch(() => {
if (!cancelled) setAvailability(null); if (!cancelled) setAvailability(null);
@@ -227,9 +245,14 @@ function FahrzeugBuchungen() {
setDialogOpen(false); setDialogOpen(false);
loadData(); loadData();
} catch (e: unknown) { } catch (e: unknown) {
const axiosError = e as { response?: { status?: number }; message?: string }; const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string };
if (axiosError?.response?.status === 409) { if (axiosError?.response?.status === 409) {
const reason = axiosError?.response?.data?.reason;
if (reason === 'out_of_service') {
setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst');
} else {
setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
}
} else { } else {
setDialogError(axiosError?.message || 'Fehler beim Speichern'); setDialogError(axiosError?.message || 'Fehler beim Speichern');
} }
@@ -437,7 +460,8 @@ function FahrzeugBuchungen() {
</TableCell> </TableCell>
{weekDays.map((day) => { {weekDays.map((day) => {
const cellBookings = getBookingsForCell(vehicle.id, day); const cellBookings = getBookingsForCell(vehicle.id, day);
const isFree = cellBookings.length === 0; const oos = isOutOfService(vehicle, day);
const isFree = cellBookings.length === 0 && !oos;
return ( return (
<TableCell <TableCell
key={day.toISOString()} key={day.toISOString()}
@@ -445,8 +469,12 @@ function FahrzeugBuchungen() {
isFree ? handleCellClick(vehicle.id, day) : undefined isFree ? handleCellClick(vehicle.id, day) : undefined
} }
sx={{ sx={{
bgcolor: isFree ? (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50' : undefined, bgcolor: oos
cursor: isFree && canCreate ? 'pointer' : 'default', ? (theme) => theme.palette.mode === 'dark' ? 'error.900' : 'error.50'
: isFree
? (theme) => theme.palette.mode === 'dark' ? 'success.900' : 'success.50'
: undefined,
cursor: isFree && canCreate ? 'pointer' : oos ? 'not-allowed' : 'default',
'&:hover': isFree && canCreate '&:hover': isFree && canCreate
? { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.800' : 'success.100' } ? { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'success.800' : 'success.100' }
: {}, : {},
@@ -454,6 +482,18 @@ function FahrzeugBuchungen() {
verticalAlign: 'top', verticalAlign: 'top',
}} }}
> >
{oos && (
<Tooltip title="Fahrzeug außer Dienst">
<Chip
icon={<Block fontSize="small" />}
label="Außer Dienst"
size="small"
color="error"
variant="outlined"
sx={{ fontSize: '0.6rem', height: 18, mb: 0.25, width: '100%' }}
/>
</Tooltip>
)}
{cellBookings.map((b) => ( {cellBookings.map((b) => (
<Tooltip <Tooltip
key={b.id} key={b.id}
@@ -519,6 +559,19 @@ function FahrzeugBuchungen() {
/> />
<Typography variant="caption">Frei</Typography> <Typography variant="caption">Frei</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 16,
height: 16,
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'error.900' : 'error.50',
border: '1px solid',
borderColor: (theme) => theme.palette.mode === 'dark' ? 'error.700' : 'error.300',
borderRadius: 0.5,
}}
/>
<Typography variant="caption">Außer Dienst</Typography>
</Box>
{(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map( {(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
([art, label]) => ( ([art, label]) => (
<Box key={art} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box key={art} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -702,15 +755,29 @@ function FahrzeugBuchungen() {
Verfügbarkeit wird geprüft... Verfügbarkeit wird geprüft...
</Typography> </Typography>
</Box> </Box>
) : availability.available ? (
<Chip
icon={<CheckCircle />}
label="Fahrzeug verfügbar"
color="success"
size="small"
/>
) : availability.reason === 'out_of_service' ? (
<Chip
icon={<Block />}
label={
availability.ausserDienstBis
? `Außer Dienst bis ${new Date(availability.ausserDienstBis).toLocaleDateString('de-DE')} (geschätzt)`
: 'Fahrzeug ist außer Dienst'
}
color="error"
size="small"
/>
) : ( ) : (
<Chip <Chip
icon={availability ? <CheckCircle /> : <Warning />} icon={<Warning />}
label={ label="Konflikt: bereits gebucht"
availability color="error"
? 'Fahrzeug verfügbar'
: 'Konflikt: bereits gebucht'
}
color={availability ? 'success' : 'error'}
size="small" size="small"
/> />
)} )}

View File

@@ -66,6 +66,7 @@ import {
CreateWartungslogPayload, CreateWartungslogPayload,
UpdateStatusPayload, UpdateStatusPayload,
WartungslogArt, WartungslogArt,
OverlappingBooking,
} from '../types/vehicle.types'; } from '../types/vehicle.types';
import type { AusruestungListItem } from '../types/equipment.types'; import type { AusruestungListItem } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types'; import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
@@ -92,14 +93,12 @@ const STATUS_ICONS: Record<FahrzeugStatus, React.ReactElement> = {
[FahrzeugStatus.Einsatzbereit]: <CheckCircle color="success" />, [FahrzeugStatus.Einsatzbereit]: <CheckCircle color="success" />,
[FahrzeugStatus.AusserDienstWartung]: <PauseCircle color="warning" />, [FahrzeugStatus.AusserDienstWartung]: <PauseCircle color="warning" />,
[FahrzeugStatus.AusserDienstSchaden]: <ErrorIcon color="error" />, [FahrzeugStatus.AusserDienstSchaden]: <ErrorIcon color="error" />,
[FahrzeugStatus.InLehrgang]: <School color="info" />,
}; };
const STATUS_CHIP_COLOR: Record<FahrzeugStatus, 'success' | 'warning' | 'error' | 'info'> = { const STATUS_CHIP_COLOR: Record<FahrzeugStatus, 'success' | 'warning' | 'error' | 'info'> = {
[FahrzeugStatus.Einsatzbereit]: 'success', [FahrzeugStatus.Einsatzbereit]: 'success',
[FahrzeugStatus.AusserDienstWartung]: 'warning', [FahrzeugStatus.AusserDienstWartung]: 'warning',
[FahrzeugStatus.AusserDienstSchaden]: 'error', [FahrzeugStatus.AusserDienstSchaden]: 'error',
[FahrzeugStatus.InLehrgang]: 'info',
}; };
// ── Date helpers ────────────────────────────────────────────────────────────── // ── Date helpers ──────────────────────────────────────────────────────────────
@@ -118,6 +117,10 @@ function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'err
return 'success'; return 'success';
} }
function fmtDatetime(iso: string | Date | null | undefined): string {
return fmtDate(iso ? new Date(iso).toISOString() : null);
}
// ── Übersicht Tab ───────────────────────────────────────────────────────────── // ── Übersicht Tab ─────────────────────────────────────────────────────────────
interface UebersichtTabProps { interface UebersichtTabProps {
@@ -130,13 +133,30 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status); const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? ''); const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
const [ausserDienstVon, setAusserDienstVon] = useState(
vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : ''
);
const [ausserDienstBis, setAusserDienstBis] = useState(
vehicle.ausser_dienst_bis ? new Date(vehicle.ausser_dienst_bis).toISOString().slice(0, 16) : ''
);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [overlappingBookings, setOverlappingBookings] = useState<OverlappingBooking[]>([]);
const isAusserDienst = (s: FahrzeugStatus) =>
s === FahrzeugStatus.AusserDienstWartung || s === FahrzeugStatus.AusserDienstSchaden;
const openDialog = () => { const openDialog = () => {
setNewStatus(vehicle.status); setNewStatus(vehicle.status);
setBemerkung(vehicle.status_bemerkung ?? ''); setBemerkung(vehicle.status_bemerkung ?? '');
setAusserDienstVon(
vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : ''
);
setAusserDienstBis(
vehicle.ausser_dienst_bis ? new Date(vehicle.ausser_dienst_bis).toISOString().slice(0, 16) : ''
);
setSaveError(null); setSaveError(null);
setOverlappingBookings([]);
setStatusDialogOpen(true); setStatusDialogOpen(true);
}; };
@@ -149,9 +169,19 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
try { try {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
const payload: UpdateStatusPayload = { status: newStatus, bemerkung }; const payload: UpdateStatusPayload = {
await vehiclesApi.updateStatus(vehicle.id, payload); status: newStatus,
bemerkung,
...(isAusserDienst(newStatus) && ausserDienstVon && ausserDienstBis
? {
ausserDienstVon: new Date(ausserDienstVon).toISOString(),
ausserDienstBis: new Date(ausserDienstBis).toISOString(),
}
: {}),
};
const result = await vehiclesApi.updateStatus(vehicle.id, payload);
setStatusDialogOpen(false); setStatusDialogOpen(false);
setOverlappingBookings(result.overlappingBookings ?? []);
onStatusUpdated(); onStatusUpdated();
} catch (err: any) { } catch (err: any) {
setSaveError(err?.response?.data?.message || 'Status konnte nicht gespeichert werden.'); setSaveError(err?.response?.data?.message || 'Status konnte nicht gespeichert werden.');
@@ -160,6 +190,8 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
} }
}; };
const canSave = !isAusserDienst(newStatus) || (!!ausserDienstVon && !!ausserDienstBis);
const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden; const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden;
// Inspection deadline badges // Inspection deadline badges
@@ -177,6 +209,20 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
</Alert> </Alert>
)} )}
{overlappingBookings.length > 0 && (
<Alert severity="warning" sx={{ mb: 2 }}>
<strong>Folgende Buchungen überschneiden sich mit dem Außer-Dienst-Zeitraum:</strong>
<Box component="ul" sx={{ mt: 1, mb: 0, pl: 2 }}>
{overlappingBookings.map((b) => (
<li key={b.id}>
<strong>{b.titel}</strong> · {fmtDate(b.beginn as unknown as string)} {fmtDate(b.ende as unknown as string)} · {b.gebucht_von_name}
</li>
))}
</Box>
Bitte prüfe, ob diese storniert werden müssen.
</Alert>
)}
{/* Status panel */} {/* Status panel */}
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}> <Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -184,11 +230,27 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
{STATUS_ICONS[vehicle.status]} {STATUS_ICONS[vehicle.status]}
<Box> <Box>
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography> <Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Chip <Chip
label={FahrzeugStatusLabel[vehicle.status]} label={FahrzeugStatusLabel[vehicle.status]}
color={STATUS_CHIP_COLOR[vehicle.status]} color={STATUS_CHIP_COLOR[vehicle.status]}
size="small" size="small"
/> />
{vehicle.aktiver_lehrgang && (
<Chip
icon={<School fontSize="small" />}
label="In Lehrgang"
color="info"
size="small"
/>
)}
</Box>
{(vehicle.status === FahrzeugStatus.AusserDienstWartung || vehicle.status === FahrzeugStatus.AusserDienstSchaden) &&
vehicle.ausser_dienst_von && vehicle.ausser_dienst_bis && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Außer Dienst: {fmtDatetime(vehicle.ausser_dienst_von)} {fmtDatetime(vehicle.ausser_dienst_bis)} (geschätzt)
</Typography>
)}
{vehicle.status_bemerkung && ( {vehicle.status_bemerkung && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{vehicle.status_bemerkung} {vehicle.status_bemerkung}
@@ -279,6 +341,33 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
))} ))}
</Select> </Select>
</FormControl> </FormControl>
{isAusserDienst(newStatus) && (
<>
<TextField
label="Außer Dienst von *"
type="datetime-local"
fullWidth
sx={{ mb: 2 }}
value={ausserDienstVon}
onChange={(e) => setAusserDienstVon(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Geschätztes Ende *"
type="datetime-local"
fullWidth
sx={{ mb: 1 }}
value={ausserDienstBis}
onChange={(e) => setAusserDienstBis(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
Zeitangabe ist eine Schätzung
</Typography>
</>
)}
<TextField <TextField
label="Bemerkung (optional)" label="Bemerkung (optional)"
fullWidth fullWidth
@@ -294,7 +383,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
<Button <Button
variant="contained" variant="contained"
onClick={handleSaveStatus} onClick={handleSaveStatus}
disabled={saving} disabled={saving || !canSave}
startIcon={saving ? <CircularProgress size={16} /> : undefined} startIcon={saving ? <CircularProgress size={16} /> : undefined}
> >
Speichern Speichern

View File

@@ -49,7 +49,6 @@ const STATUS_CONFIG: Record<
[FahrzeugStatus.Einsatzbereit]: { color: 'success', icon: <CheckCircle fontSize="small" /> }, [FahrzeugStatus.Einsatzbereit]: { color: 'success', icon: <CheckCircle fontSize="small" /> },
[FahrzeugStatus.AusserDienstWartung]: { color: 'warning', icon: <PauseCircle fontSize="small" /> }, [FahrzeugStatus.AusserDienstWartung]: { color: 'warning', icon: <PauseCircle fontSize="small" /> },
[FahrzeugStatus.AusserDienstSchaden]: { color: 'error', icon: <ErrorIcon fontSize="small" /> }, [FahrzeugStatus.AusserDienstSchaden]: { color: 'error', icon: <ErrorIcon fontSize="small" /> },
[FahrzeugStatus.InLehrgang]: { color: 'info', icon: <School fontSize="small" /> },
}; };
// ── Inspection badge helpers ────────────────────────────────────────────────── // ── Inspection badge helpers ──────────────────────────────────────────────────
@@ -177,6 +176,7 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick, warnings =
</Box> </Box>
<Box sx={{ mb: 1 }}> <Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<Chip <Chip
icon={statusCfg.icon} icon={statusCfg.icon}
label={FahrzeugStatusLabel[status]} label={FahrzeugStatusLabel[status]}
@@ -184,6 +184,22 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick, warnings =
size="small" size="small"
variant="outlined" variant="outlined"
/> />
{vehicle.aktiver_lehrgang && (
<Chip
icon={<School fontSize="small" />}
label="In Lehrgang"
color="info"
size="small"
variant="outlined"
/>
)}
</Box>
{(status === FahrzeugStatus.AusserDienstWartung || status === FahrzeugStatus.AusserDienstSchaden) &&
vehicle.ausser_dienst_bis && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Bis ca. {new Date(vehicle.ausser_dienst_bis).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</Typography>
)}
</Box> </Box>
{inspBadges.length > 0 && ( {inspBadges.length > 0 && (

View File

@@ -50,9 +50,9 @@ export const bookingApi = {
fahrzeugId: string, fahrzeugId: string,
from: Date, from: Date,
to: Date to: Date
): Promise<{ available: boolean }> { ): Promise<{ available: boolean; reason?: string; ausserDienstVon?: string; ausserDienstBis?: string }> {
return api return api
.get<ApiResponse<{ available: boolean }>>('/api/bookings/availability', { .get<ApiResponse<{ available: boolean; reason?: string; ausserDienstVon?: string; ausserDienstBis?: string }>>('/api/bookings/availability', {
params: { params: {
fahrzeugId, fahrzeugId,
from: from.toISOString(), from: from.toISOString(),

View File

@@ -9,6 +9,7 @@ import type {
UpdateFahrzeugPayload, UpdateFahrzeugPayload,
UpdateStatusPayload, UpdateStatusPayload,
CreateWartungslogPayload, CreateWartungslogPayload,
StatusUpdateResponse,
} from '../types/vehicle.types'; } from '../types/vehicle.types';
async function unwrap<T>( async function unwrap<T>(
@@ -68,8 +69,12 @@ export const vehiclesApi = {
await api.delete(`/api/vehicles/${id}`); await api.delete(`/api/vehicles/${id}`);
}, },
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> { async updateStatus(id: string, payload: UpdateStatusPayload): Promise<StatusUpdateResponse> {
await api.patch(`/api/vehicles/${id}/status`, payload); const response = await api.patch<{ success: boolean; data: StatusUpdateResponse }>(
`/api/vehicles/${id}/status`,
payload
);
return response.data?.data ?? { overlappingBookings: [] };
}, },
async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> { async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> {

View File

@@ -1,4 +1,4 @@
export type BuchungsArt = 'intern' | 'extern' | 'wartung' | 'reservierung' | 'sonstiges'; export type BuchungsArt = 'intern' | 'extern' | 'wartung' | 'reservierung' | 'sonstiges' | 'lehrgang';
export const BUCHUNGS_ART_LABELS: Record<BuchungsArt, string> = { export const BUCHUNGS_ART_LABELS: Record<BuchungsArt, string> = {
intern: 'Intern', intern: 'Intern',
@@ -6,6 +6,7 @@ export const BUCHUNGS_ART_LABELS: Record<BuchungsArt, string> = {
wartung: 'Wartung/Service', wartung: 'Wartung/Service',
reservierung: 'Reservierung', reservierung: 'Reservierung',
sonstiges: 'Sonstiges', sonstiges: 'Sonstiges',
lehrgang: 'Lehrgang',
}; };
export const BUCHUNGS_ART_COLORS: Record<BuchungsArt, string> = { export const BUCHUNGS_ART_COLORS: Record<BuchungsArt, string> = {
@@ -14,6 +15,7 @@ export const BUCHUNGS_ART_COLORS: Record<BuchungsArt, string> = {
wartung: '#616161', wartung: '#616161',
reservierung: '#7b1fa2', reservierung: '#7b1fa2',
sonstiges: '#00695c', sonstiges: '#00695c',
lehrgang: '#0288d1',
}; };
export interface FahrzeugBuchungListItem { export interface FahrzeugBuchungListItem {
@@ -45,6 +47,8 @@ export interface Fahrzeug {
kurzname: string | null; kurzname: string | null;
amtliches_kennzeichen: string | null; amtliches_kennzeichen: string | null;
status: string; status: string;
ausser_dienst_von: string | null;
ausser_dienst_bis: string | null;
} }
export interface CreateBuchungInput { export interface CreateBuchungInput {

View File

@@ -6,14 +6,12 @@ export enum FahrzeugStatus {
Einsatzbereit = 'einsatzbereit', Einsatzbereit = 'einsatzbereit',
AusserDienstWartung = 'ausser_dienst_wartung', AusserDienstWartung = 'ausser_dienst_wartung',
AusserDienstSchaden = 'ausser_dienst_schaden', AusserDienstSchaden = 'ausser_dienst_schaden',
InLehrgang = 'in_lehrgang',
} }
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = { export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit', [FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)', [FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
[FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)', [FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)',
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
}; };
export type WartungslogArt = export type WartungslogArt =
@@ -23,6 +21,12 @@ export type WartungslogArt =
// ── API Response Shapes ─────────────────────────────────────────────────────── // ── API Response Shapes ───────────────────────────────────────────────────────
export interface AktiverLehrgang {
titel: string;
beginn: string;
ende: string;
}
export interface FahrzeugListItem { export interface FahrzeugListItem {
id: string; id: string;
bezeichnung: string; bezeichnung: string;
@@ -33,12 +37,15 @@ export interface FahrzeugListItem {
besatzung_soll: string | null; besatzung_soll: string | null;
status: FahrzeugStatus; status: FahrzeugStatus;
status_bemerkung: string | null; status_bemerkung: string | null;
ausser_dienst_von: string | null;
ausser_dienst_bis: string | null;
bild_url: string | null; bild_url: string | null;
paragraph57a_faellig_am: string | null; paragraph57a_faellig_am: string | null;
paragraph57a_tage_bis_faelligkeit: number | null; paragraph57a_tage_bis_faelligkeit: number | null;
naechste_wartung_am: string | null; naechste_wartung_am: string | null;
wartung_tage_bis_faelligkeit: number | null; wartung_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null; naechste_pruefung_tage: number | null;
aktiver_lehrgang: AktiverLehrgang | null;
} }
export interface FahrzeugWartungslog { export interface FahrzeugWartungslog {
@@ -67,6 +74,8 @@ export interface FahrzeugDetail {
besatzung_soll: string | null; besatzung_soll: string | null;
status: FahrzeugStatus; status: FahrzeugStatus;
status_bemerkung: string | null; status_bemerkung: string | null;
ausser_dienst_von: string | null;
ausser_dienst_bis: string | null;
standort: string; standort: string;
bild_url: string | null; bild_url: string | null;
created_at: string; created_at: string;
@@ -76,6 +85,7 @@ export interface FahrzeugDetail {
naechste_wartung_am: string | null; naechste_wartung_am: string | null;
wartung_tage_bis_faelligkeit: number | null; wartung_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null; naechste_pruefung_tage: number | null;
aktiver_lehrgang: AktiverLehrgang | null;
wartungslog: FahrzeugWartungslog[]; wartungslog: FahrzeugWartungslog[];
} }
@@ -125,6 +135,20 @@ export type UpdateFahrzeugPayload = {
export interface UpdateStatusPayload { export interface UpdateStatusPayload {
status: FahrzeugStatus; status: FahrzeugStatus;
bemerkung?: string; bemerkung?: string;
ausserDienstVon?: string; // ISO datetime, required when status is ausser_dienst_*
ausserDienstBis?: string; // ISO datetime, required when status is ausser_dienst_*
}
export interface OverlappingBooking {
id: string;
titel: string;
beginn: string;
ende: string;
gebucht_von_name: string;
}
export interface StatusUpdateResponse {
overlappingBookings: OverlappingBooking[];
} }
export interface CreateWartungslogPayload { export interface CreateWartungslogPayload {

View File

@@ -139,11 +139,30 @@ async function navigateToMemberList(page: Page): Promise<Frame> {
async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> { async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
log(`Scraping member list from: ${frame.url()}`); log(`Scraping member list from: ${frame.url()}`);
// If the page landed on a search form (not results yet), submit it // Clear the Standesbuchnummer filter if the search form is present.
// FDISK pre-fills the logged-in user's own Standesbuchnummer, which limits results to 1 member.
// We clear it before submitting so all members of the fire station are returned.
const hasForm = await frame.$('form[name="frmsearch"]') !== null; const hasForm = await frame.$('form[name="frmsearch"]') !== null;
const hasTable = await frame.$('table.FdcLayList') !== null; if (hasForm) {
if (hasForm && !hasTable) { const cleared = await frame.evaluate(() => {
log('Search form found without results — submitting...'); const form = (document as any).forms['frmsearch'];
if (!form) return [];
const clearedFields: string[] = [];
for (const el of Array.from(form.elements) as HTMLInputElement[]) {
const name = (el.name ?? '').toLowerCase();
const id = (el.id ?? '').toLowerCase();
if (name.includes('standesbuch') || id.includes('standesbuch')) {
el.value = '';
clearedFields.push(el.name || el.id);
}
}
return clearedFields;
});
if (cleared.length > 0) {
log(`Cleared Standesbuchnummer filter fields: ${cleared.join(', ')}`);
} else {
log('Search form found — no Standesbuchnummer field detected, submitting as-is');
}
await frame.evaluate(() => { (document as any).forms['frmsearch'].submit(); }); await frame.evaluate(() => { (document as any).forms['frmsearch'].submit(); });
await frame.waitForLoadState('networkidle'); await frame.waitForLoadState('networkidle');
log(`After form submit: ${frame.url()}`); log(`After form submit: ${frame.url()}`);