update
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user