rework vehicle handling

This commit is contained in:
Matthias Hochmeister
2026-02-28 13:34:16 +01:00
parent 84cf505511
commit 41fc41bee4
13 changed files with 931 additions and 1228 deletions

View File

@@ -1,9 +1,15 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import vehicleService from '../services/vehicle.service'; import vehicleService from '../services/vehicle.service';
import { FahrzeugStatus, PruefungArt } from '../models/vehicle.model'; import { FahrzeugStatus } from '../models/vehicle.model';
import logger from '../utils/logger'; import logger from '../utils/logger';
// ── UUID validation ───────────────────────────────────────────────────────────
function isValidUUID(s: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
}
// ── Zod Validation Schemas ──────────────────────────────────────────────────── // ── Zod Validation Schemas ────────────────────────────────────────────────────
const FahrzeugStatusEnum = z.enum([ const FahrzeugStatusEnum = z.enum([
@@ -13,19 +19,9 @@ const FahrzeugStatusEnum = z.enum([
FahrzeugStatus.InLehrgang, FahrzeugStatus.InLehrgang,
]); ]);
const PruefungArtEnum = z.enum([
PruefungArt.HU,
PruefungArt.AU,
PruefungArt.UVV,
PruefungArt.Leiter,
PruefungArt.Kran,
PruefungArt.Seilwinde,
PruefungArt.Sonstiges,
]);
const isoDate = z.string().regex( const isoDate = z.string().regex(
/^\d{4}-\d{2}-\d{2}$/, /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
'Expected ISO date format YYYY-MM-DD' 'Erwartet ISO-Datum im Format YYYY-MM-DD'
); );
const CreateFahrzeugSchema = z.object({ const CreateFahrzeugSchema = z.object({
@@ -39,30 +35,40 @@ const CreateFahrzeugSchema = z.object({
besatzung_soll: z.string().max(10).optional(), besatzung_soll: z.string().max(10).optional(),
status: FahrzeugStatusEnum.optional(), status: FahrzeugStatusEnum.optional(),
status_bemerkung: z.string().max(500).optional(), status_bemerkung: z.string().max(500).optional(),
standort: z.string().max(100).optional(), standort: z.string().min(1).max(100).optional(),
bild_url: z.string().url().max(500).optional(), bild_url: z.string().url().max(500).refine(
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).optional(),
paragraph57a_faellig_am: isoDate.optional(), paragraph57a_faellig_am: isoDate.optional(),
naechste_wartung_am: isoDate.optional(), naechste_wartung_am: isoDate.optional(),
}); });
const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial(); const UpdateFahrzeugSchema = z.object({
bezeichnung: z.string().min(1).max(100).optional(),
kurzname: z.string().max(20).nullable().optional(),
amtliches_kennzeichen: z.string().max(20).nullable().optional(),
fahrgestellnummer: z.string().max(50).nullable().optional(),
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
hersteller: z.string().max(100).nullable().optional(),
typ_schluessel: z.string().max(30).nullable().optional(),
besatzung_soll: z.string().max(10).nullable().optional(),
status: FahrzeugStatusEnum.optional(),
status_bemerkung: z.string().max(500).nullable().optional(),
standort: z.string().min(1).max(100).optional(),
bild_url: z.string().url().max(500).refine(
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).nullable().optional(),
paragraph57a_faellig_am: isoDate.nullable().optional(),
naechste_wartung_am: isoDate.nullable().optional(),
});
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(''),
}); });
const CreatePruefungSchema = z.object({
pruefung_art: PruefungArtEnum,
faellig_am: isoDate,
durchgefuehrt_am: isoDate.optional(),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden', 'ausstehend']).optional(),
pruefende_stelle: z.string().max(150).optional(),
kosten: z.number().min(0).optional(),
dokument_url: z.string().url().max(500).optional(),
bemerkung: z.string().max(1000).optional(),
});
const CreateWartungslogSchema = z.object({ const CreateWartungslogSchema = z.object({
datum: isoDate, datum: isoDate,
art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(), art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(),
@@ -76,17 +82,12 @@ const CreateWartungslogSchema = z.object({
// ── Helper ──────────────────────────────────────────────────────────────────── // ── Helper ────────────────────────────────────────────────────────────────────
function getUserId(req: Request): string { function getUserId(req: Request): string {
// req.user is guaranteed by the authenticate middleware
return req.user!.id; return req.user!.id;
} }
// ── Controller ──────────────────────────────────────────────────────────────── // ── Controller ────────────────────────────────────────────────────────────────
class VehicleController { class VehicleController {
/**
* GET /api/vehicles
* Fleet overview list with per-vehicle inspection badge data.
*/
async listVehicles(_req: Request, res: Response): Promise<void> { async listVehicles(_req: Request, res: Response): Promise<void> {
try { try {
const vehicles = await vehicleService.getAllVehicles(); const vehicles = await vehicleService.getAllVehicles();
@@ -97,10 +98,6 @@ class VehicleController {
} }
} }
/**
* GET /api/vehicles/stats
* Aggregated KPI counts for the dashboard strip.
*/
async getStats(_req: Request, res: Response): Promise<void> { async getStats(_req: Request, res: Response): Promise<void> {
try { try {
const stats = await vehicleService.getVehicleStats(); const stats = await vehicleService.getVehicleStats();
@@ -111,23 +108,14 @@ class VehicleController {
} }
} }
/**
* GET /api/vehicles/alerts?daysAhead=30
* Upcoming and overdue inspections — used by the InspectionAlerts dashboard panel.
* Returns alerts sorted by urgency (most overdue / soonest due first).
*/
async getAlerts(req: Request, res: Response): Promise<void> { async getAlerts(req: Request, res: Response): Promise<void> {
try { try {
const daysAhead = Math.min( const raw = parseInt((req.query.daysAhead as string) || '30', 10);
parseInt((req.query.daysAhead as string) || '30', 10), if (isNaN(raw) || raw < 0) {
365 // hard cap — never expose more than 1 year of lookahead
);
if (isNaN(daysAhead) || daysAhead < 0) {
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' }); res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
return; return;
} }
const daysAhead = Math.min(raw, 365);
const alerts = await vehicleService.getUpcomingInspections(daysAhead); const alerts = await vehicleService.getUpcomingInspections(daysAhead);
res.status(200).json({ success: true, data: alerts }); res.status(200).json({ success: true, data: alerts });
} catch (error) { } catch (error) {
@@ -136,20 +124,18 @@ class VehicleController {
} }
} }
/**
* GET /api/vehicles/:id
* Full vehicle detail with pruefstatus, inspection history, and wartungslog.
*/
async getVehicle(req: Request, res: Response): Promise<void> { async getVehicle(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params as Record<string, string>; const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const vehicle = await vehicleService.getVehicleById(id); const vehicle = await vehicleService.getVehicleById(id);
if (!vehicle) { if (!vehicle) {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return; return;
} }
res.status(200).json({ success: true, data: vehicle }); res.status(200).json({ success: true, data: vehicle });
} catch (error) { } catch (error) {
logger.error('getVehicle error', { error, id: req.params.id }); logger.error('getVehicle error', { error, id: req.params.id });
@@ -157,10 +143,6 @@ class VehicleController {
} }
} }
/**
* POST /api/vehicles
* Create a new vehicle. Requires vehicles:write permission.
*/
async createVehicle(req: Request, res: Response): Promise<void> { async createVehicle(req: Request, res: Response): Promise<void> {
try { try {
const parsed = CreateFahrzeugSchema.safeParse(req.body); const parsed = CreateFahrzeugSchema.safeParse(req.body);
@@ -172,7 +154,6 @@ class VehicleController {
}); });
return; return;
} }
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req)); const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: vehicle }); res.status(201).json({ success: true, data: vehicle });
} catch (error) { } catch (error) {
@@ -181,13 +162,13 @@ class VehicleController {
} }
} }
/**
* PATCH /api/vehicles/:id
* Update vehicle fields. Requires vehicles:write permission.
*/
async updateVehicle(req: Request, res: Response): Promise<void> { async updateVehicle(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params as Record<string, string>; const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const parsed = UpdateFahrzeugSchema.safeParse(req.body); const parsed = UpdateFahrzeugSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
@@ -197,7 +178,10 @@ class VehicleController {
}); });
return; return;
} }
if (Object.keys(parsed.data).length === 0) {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req)); const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req));
res.status(200).json({ success: true, data: vehicle }); res.status(200).json({ success: true, data: vehicle });
} catch (error: any) { } catch (error: any) {
@@ -210,20 +194,14 @@ class VehicleController {
} }
} }
/**
* PATCH /api/vehicles/:id/status
* Live status change — the Socket.IO hook point for Tier 3.
* Requires vehicles:write permission.
*
* The `io` instance is attached to req.app in server.ts (Tier 3):
* app.set('io', io);
* and retrieved here via req.app.get('io').
*/
async updateVehicleStatus(req: Request, res: Response): Promise<void> { async updateVehicleStatus(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params as Record<string, string>; const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const parsed = UpdateStatusSchema.safeParse(req.body); const parsed = UpdateStatusSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -232,19 +210,10 @@ class VehicleController {
}); });
return; return;
} }
// Tier 3: io will be available via req.app.get('io') once Socket.IO is wired up.
// Passing undefined here is safe — the service handles it gracefully.
const io = req.app.get('io') ?? undefined; const io = req.app.get('io') ?? undefined;
await vehicleService.updateVehicleStatus( await vehicleService.updateVehicleStatus(
id, id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io
parsed.data.status,
parsed.data.bemerkung,
getUserId(req),
io
); );
res.status(200).json({ success: true, message: 'Status aktualisiert' }); res.status(200).json({ success: true, message: 'Status aktualisiert' });
} catch (error: any) { } catch (error: any) {
if (error?.message === 'Vehicle not found') { if (error?.message === 'Vehicle not found') {
@@ -256,60 +225,14 @@ class VehicleController {
} }
} }
// ── Inspections ─────────────────────────────────────────────────────────────
/**
* POST /api/vehicles/:id/pruefungen
* Record an inspection (scheduled or completed). Requires vehicles:write.
*/
async addPruefung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const parsed = CreatePruefungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const pruefung = await vehicleService.addPruefung(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: pruefung });
} catch (error) {
logger.error('addPruefung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Prüfung konnte nicht eingetragen werden' });
}
}
/**
* GET /api/vehicles/:id/pruefungen
* Full inspection history for a vehicle.
*/
async getPruefungen(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const pruefungen = await vehicleService.getPruefungenForVehicle(id);
res.status(200).json({ success: true, data: pruefungen });
} catch (error) {
logger.error('getPruefungen error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Prüfungshistorie konnte nicht geladen werden' });
}
}
// ── Maintenance Log ──────────────────────────────────────────────────────────
/**
* POST /api/vehicles/:id/wartung
* Add a maintenance log entry. Requires vehicles:write.
*/
async addWartung(req: Request, res: Response): Promise<void> { async addWartung(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params as Record<string, string>; const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const parsed = CreateWartungslogSchema.safeParse(req.body); const parsed = CreateWartungslogSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -318,24 +241,27 @@ class VehicleController {
}); });
return; return;
} }
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req)); const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: entry }); res.status(201).json({ success: true, data: entry });
} catch (error) { } catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('addWartung error', { error, id: req.params.id }); logger.error('addWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' }); res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
} }
} }
/**
* DELETE /api/vehicles/:id
* Delete a vehicle. Requires dashboard_admin group.
*/
async deleteVehicle(req: Request, res: Response): Promise<void> { async deleteVehicle(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params as Record<string, string>; const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
await vehicleService.deleteVehicle(id, getUserId(req)); await vehicleService.deleteVehicle(id, getUserId(req));
res.status(200).json({ success: true, message: 'Fahrzeug gelöscht' }); res.status(204).send();
} 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' });
@@ -346,13 +272,13 @@ class VehicleController {
} }
} }
/**
* GET /api/vehicles/:id/wartung
* Maintenance log for a vehicle.
*/
async getWartung(req: Request, res: Response): Promise<void> { async getWartung(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params as Record<string, string>; const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const entries = await vehicleService.getWartungslogForVehicle(id); const entries = await vehicleService.getWartungslogForVehicle(id);
res.status(200).json({ success: true, data: entries }); res.status(200).json({ success: true, data: entries });
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,53 @@
-- Migration 008: Simplify inspection model
-- Remove fahrzeug_pruefungen table and related structures.
-- Only §57a (paragraph57a_faellig_am) and Wartung (naechste_wartung_am)
-- remain as the two tracked inspection deadlines, stored on fahrzeuge.
-- Drop the pruefungen table (cascades to indexes)
DROP TABLE IF EXISTS fahrzeug_pruefungen CASCADE;
-- Drop and recreate the fleet overview view (simplified — no CTE)
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.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;
-- Index support for alert queries
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_paragraph57a ON fahrzeuge(paragraph57a_faellig_am) WHERE paragraph57a_faellig_am IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_wartung ON fahrzeuge(naechste_wartung_am) WHERE naechste_wartung_am IS NOT NULL;

View File

@@ -0,0 +1,57 @@
-- Migration 009: Soft delete for vehicles
-- Adds deleted_at to fahrzeuge and refreshes the view to exclude soft-deleted rows.
-- Hard DELETE is replaced by UPDATE SET deleted_at = NOW() in the service layer.
ALTER TABLE fahrzeuge
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
COMMENT ON COLUMN fahrzeuge.deleted_at IS
'NULL = active vehicle. Set to timestamp when soft-deleted. Records are never physically removed.';
-- Partial index: only index active (non-deleted) vehicles for fast lookups
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_active
ON fahrzeuge(id)
WHERE deleted_at IS NULL;
-- Refresh the view to exclude soft-deleted vehicles
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.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,10 +4,6 @@
// ── Enums ───────────────────────────────────────────────────────────────────── // ── Enums ─────────────────────────────────────────────────────────────────────
/**
* Operational status of a vehicle.
* These values are the CHECK constraint values in the database.
*/
export enum FahrzeugStatus { export enum FahrzeugStatus {
Einsatzbereit = 'einsatzbereit', Einsatzbereit = 'einsatzbereit',
AusserDienstWartung = 'ausser_dienst_wartung', AusserDienstWartung = 'ausser_dienst_wartung',
@@ -15,7 +11,6 @@ export enum FahrzeugStatus {
InLehrgang = 'in_lehrgang', InLehrgang = 'in_lehrgang',
} }
/** Human-readable German labels for each status value */
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)',
@@ -23,53 +18,6 @@ export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
[FahrzeugStatus.InLehrgang]: 'In Lehrgang', [FahrzeugStatus.InLehrgang]: 'In Lehrgang',
}; };
/**
* Types of vehicle inspections (Prüfungsarten).
* These values are the CHECK constraint values in the database.
*/
export enum PruefungArt {
HU = 'HU', // Hauptuntersuchung (TÜV) — 24-month interval
AU = 'AU', // Abgasuntersuchung — 12-month interval
UVV = 'UVV', // Unfallverhütungsvorschrift BGV D29 — 12-month
Leiter = 'Leiter', // Leiternprüfung (DLK only) — 12-month
Kran = 'Kran', // Kranprüfung — 12-month
Seilwinde = 'Seilwinde', // Seilwindenprüfung — 12-month
Sonstiges = 'Sonstiges',
}
/** Human-readable German labels for each PruefungArt */
export const PruefungArtLabel: Record<PruefungArt, string> = {
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
[PruefungArt.AU]: 'Abgasuntersuchung',
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
[PruefungArt.Kran]: 'Kranprüfung',
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
};
/**
* Standard inspection intervals in months, keyed by PruefungArt.
* Used by vehicle.service.ts to auto-calculate naechste_faelligkeit.
*/
export const PruefungIntervalMonths: Partial<Record<PruefungArt, number>> = {
[PruefungArt.HU]: 24,
[PruefungArt.AU]: 12,
[PruefungArt.UVV]: 12,
[PruefungArt.Leiter]: 12,
[PruefungArt.Kran]: 12,
[PruefungArt.Seilwinde]: 12,
// Sonstiges: no standard interval — must be set manually
};
/** Inspection result values */
export type PruefungErgebnis =
| 'bestanden'
| 'bestanden_mit_maengeln'
| 'nicht_bestanden'
| 'ausstehend';
/** Maintenance log entry types */
export type WartungslogArt = export type WartungslogArt =
| 'Inspektion' | 'Inspektion'
| 'Reparatur' | 'Reparatur'
@@ -81,50 +29,29 @@ export type WartungslogArt =
// ── Core Entities ───────────────────────────────────────────────────────────── // ── Core Entities ─────────────────────────────────────────────────────────────
/** Raw database row from the `fahrzeuge` table */
export interface Fahrzeug { export interface Fahrzeug {
id: string; // UUID id: string;
bezeichnung: string; // e.g. "LF 20/16" bezeichnung: string;
kurzname: string | null; kurzname: string | null;
amtliches_kennzeichen: string | null; amtliches_kennzeichen: string | null;
fahrgestellnummer: string | null; fahrgestellnummer: string | null;
baujahr: number | null; baujahr: number | null;
hersteller: string | null; hersteller: string | null;
typ_schluessel: string | null; typ_schluessel: string | null;
besatzung_soll: string | null; // e.g. "1/8" besatzung_soll: string | null;
status: FahrzeugStatus; status: FahrzeugStatus;
status_bemerkung: string | null; status_bemerkung: string | null;
standort: string; standort: string;
bild_url: string | null; bild_url: string | null;
/** §57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV) */
paragraph57a_faellig_am: Date | null; paragraph57a_faellig_am: Date | null;
/** Next scheduled service / maintenance due date */
naechste_wartung_am: Date | null; naechste_wartung_am: Date | null;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
} }
/** Raw database row from `fahrzeug_pruefungen` */
export interface FahrzeugPruefung {
id: string; // UUID
fahrzeug_id: string; // UUID FK
pruefung_art: PruefungArt;
faellig_am: Date; // The hard legal deadline
durchgefuehrt_am: Date | null;
ergebnis: PruefungErgebnis | null;
naechste_faelligkeit: Date | null;
pruefende_stelle: string | null;
kosten: number | null;
dokument_url: string | null;
bemerkung: string | null;
erfasst_von: string | null; // UUID FK users
created_at: Date;
}
/** Raw database row from `fahrzeug_wartungslog` */
export interface FahrzeugWartungslog { export interface FahrzeugWartungslog {
id: string; // UUID id: string;
fahrzeug_id: string; // UUID FK fahrzeug_id: string;
datum: Date; datum: Date;
art: WartungslogArt | null; art: WartungslogArt | null;
beschreibung: string; beschreibung: string;
@@ -132,49 +59,12 @@ export interface FahrzeugWartungslog {
kraftstoff_liter: number | null; kraftstoff_liter: number | null;
kosten: number | null; kosten: number | null;
externe_werkstatt: string | null; externe_werkstatt: string | null;
erfasst_von: string | null; // UUID FK users erfasst_von: string | null;
created_at: Date; created_at: Date;
} }
// ── Inspection Status per Type ────────────────────────────────────────────────
/** Status of a single inspection type for a vehicle */
export interface PruefungStatus {
pruefung_id: string | null;
faellig_am: Date | null;
tage_bis_faelligkeit: number | null; // negative = overdue
ergebnis: PruefungErgebnis | null;
}
/**
* Vehicle with its per-type inspection status.
* Comes from the `fahrzeuge_mit_pruefstatus` view.
*/
export interface FahrzeugWithPruefstatus extends Fahrzeug {
pruefstatus: {
hu: PruefungStatus;
au: PruefungStatus;
uvv: PruefungStatus;
leiter: PruefungStatus;
};
/** Days until §57a inspection (negative = overdue) */
paragraph57a_tage_bis_faelligkeit: number | null;
/** Days until next service (negative = overdue) */
wartung_tage_bis_faelligkeit: number | null;
/** Minimum tage_bis_faelligkeit across all inspections (negative = any overdue) */
naechste_pruefung_tage: number | null;
/** Full inspection history, ordered by faellig_am DESC */
pruefungen: FahrzeugPruefung[];
/** Maintenance log entries, ordered by datum DESC */
wartungslog: FahrzeugWartungslog[];
}
// ── List Item (Grid / Card view) ────────────────────────────────────────────── // ── List Item (Grid / Card view) ──────────────────────────────────────────────
/**
* Lightweight type used in the vehicle fleet overview grid.
* Includes only the fields needed to render a card plus inspection badges.
*/
export interface FahrzeugListItem { export interface FahrzeugListItem {
id: string; id: string;
bezeichnung: string; bezeichnung: string;
@@ -186,47 +76,44 @@ export interface FahrzeugListItem {
status: FahrzeugStatus; status: FahrzeugStatus;
status_bemerkung: string | null; status_bemerkung: string | null;
bild_url: string | null; bild_url: string | null;
/** §57a due date (primary inspection badge) */
paragraph57a_faellig_am: Date | null; paragraph57a_faellig_am: Date | null;
paragraph57a_tage_bis_faelligkeit: number | null; paragraph57a_tage_bis_faelligkeit: number | null;
/** Next service due date */
naechste_wartung_am: Date | null; naechste_wartung_am: Date | null;
wartung_tage_bis_faelligkeit: number | null; wartung_tage_bis_faelligkeit: number | null;
// Legacy pruefungen kept for backwards compat
hu_faellig_am: Date | null;
hu_tage_bis_faelligkeit: number | null;
au_faellig_am: Date | null;
au_tage_bis_faelligkeit: number | null;
uvv_faellig_am: Date | null;
uvv_tage_bis_faelligkeit: number | null;
leiter_faellig_am: Date | null;
leiter_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null; naechste_pruefung_tage: number | null;
} }
// ── Detail View ───────────────────────────────────────────────────────────────
export interface FahrzeugDetail extends Fahrzeug {
paragraph57a_tage_bis_faelligkeit: number | null;
wartung_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null;
wartungslog: FahrzeugWartungslog[];
}
// ── Dashboard KPI ───────────────────────────────────────────────────────────── // ── Dashboard KPI ─────────────────────────────────────────────────────────────
/** Aggregated vehicle stats for the dashboard KPI strip */
export interface VehicleStats { export interface VehicleStats {
total: number; total: number;
einsatzbereit: number; einsatzbereit: number;
ausserDienst: number; // wartung + schaden combined ausserDienst: number;
inLehrgang: number; inLehrgang: number;
inspectionsDue: number; // vehicles with any inspection due within 30 days inspectionsDue: number;
inspectionsOverdue: number; // vehicles with any inspection already overdue inspectionsOverdue: number;
} }
// ── Inspection Alert ────────────────────────────────────────────────────────── // ── Inspection Alert ──────────────────────────────────────────────────────────
/** Single alert item for the dashboard InspectionAlerts component */ export type InspectionAlertType = '57a' | 'wartung';
export interface InspectionAlert { export interface InspectionAlert {
fahrzeugId: string; fahrzeugId: string;
bezeichnung: string; bezeichnung: string;
kurzname: string | null; kurzname: string | null;
pruefungId: string; type: InspectionAlertType;
pruefungArt: PruefungArt;
faelligAm: Date; faelligAm: Date;
tage: number; // negative = already overdue tage: number;
} }
// ── Create / Update DTOs ────────────────────────────────────────────────────── // ── Create / Update DTOs ──────────────────────────────────────────────────────
@@ -244,8 +131,8 @@ export interface CreateFahrzeugData {
status_bemerkung?: string; status_bemerkung?: string;
standort?: string; standort?: string;
bild_url?: string; bild_url?: string;
paragraph57a_faellig_am?: string; // ISO date 'YYYY-MM-DD' paragraph57a_faellig_am?: string;
naechste_wartung_am?: string; // ISO date 'YYYY-MM-DD' naechste_wartung_am?: string;
} }
export interface UpdateFahrzeugData { export interface UpdateFahrzeugData {
@@ -261,24 +148,12 @@ export interface UpdateFahrzeugData {
status_bemerkung?: string | null; status_bemerkung?: string | null;
standort?: string; standort?: string;
bild_url?: string | null; bild_url?: string | null;
paragraph57a_faellig_am?: string | null; // ISO date 'YYYY-MM-DD' paragraph57a_faellig_am?: string | null;
naechste_wartung_am?: string | null; // ISO date 'YYYY-MM-DD' naechste_wartung_am?: string | null;
}
export interface CreatePruefungData {
pruefung_art: PruefungArt;
faellig_am: string; // ISO date string 'YYYY-MM-DD'
durchgefuehrt_am?: string; // ISO date string, optional
ergebnis?: PruefungErgebnis;
pruefende_stelle?: string;
kosten?: number;
dokument_url?: string;
bemerkung?: string;
// naechste_faelligkeit is auto-calculated by the service — not accepted from client
} }
export interface CreateWartungslogData { export interface CreateWartungslogData {
datum: string; // ISO date string 'YYYY-MM-DD' datum: string;
art?: WartungslogArt; art?: WartungslogArt;
beschreibung: string; beschreibung: string;
km_stand?: number; km_stand?: number;

View File

@@ -3,118 +3,28 @@ import vehicleController from '../controllers/vehicle.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware'; import { requireGroups } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin']; const ADMIN_GROUPS = ['dashboard_admin'];
const STATUS_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister']; const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
const router = Router(); const router = Router();
// ── Read-only endpoints (any authenticated user) ────────────────────────────── // ── Read-only (any authenticated user) ───────────────────────────────────────
/** router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
* GET /api/vehicles router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
* Fleet overview list — inspection badges included.
*/
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
/**
* GET /api/vehicles/stats
* Dashboard KPI aggregates.
* NOTE: /stats and /alerts must be declared BEFORE /:id to avoid route conflicts.
*/
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
/**
* GET /api/vehicles/alerts?daysAhead=30
* Upcoming and overdue inspections for the dashboard alert panel.
*/
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController)); router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
/**
* GET /api/vehicles/:id
* Full vehicle detail with inspection history and maintenance log.
*/
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
/**
* GET /api/vehicles/:id/pruefungen
* Inspection history for a single vehicle.
*/
router.get('/:id/pruefungen', authenticate, vehicleController.getPruefungen.bind(vehicleController));
/**
* GET /api/vehicles/:id/wartung
* Maintenance log for a single vehicle.
*/
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
// ── Write endpoints (dashboard_admin group required) ──────────────────────── // ── Write — admin only ────────────────────────────────────────────────────────
/** router.post('/', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController));
* POST /api/vehicles router.patch('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController));
* Create a new vehicle. router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.deleteVehicle.bind(vehicleController));
*/
router.post(
'/',
authenticate,
requireGroups(ADMIN_GROUPS),
vehicleController.createVehicle.bind(vehicleController)
);
/** // ── Status + maintenance log — admin + fahrmeister ────────────────────────────
* PATCH /api/vehicles/:id
* Update vehicle fields.
*/
router.patch(
'/:id',
authenticate,
requireGroups(ADMIN_GROUPS),
vehicleController.updateVehicle.bind(vehicleController)
);
/** router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController));
* PATCH /api/vehicles/:id/status router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), vehicleController.addWartung.bind(vehicleController));
* Live status change — dashboard_admin or dashboard_fahrmeister required.
* The `io` instance is retrieved inside the controller via req.app.get('io').
*/
router.patch(
'/:id/status',
authenticate,
requireGroups(STATUS_GROUPS),
vehicleController.updateVehicleStatus.bind(vehicleController)
);
/**
* POST /api/vehicles/:id/pruefungen
* Record an inspection (scheduled or completed).
*/
router.post(
'/:id/pruefungen',
authenticate,
requireGroups(ADMIN_GROUPS),
vehicleController.addPruefung.bind(vehicleController)
);
/**
* POST /api/vehicles/:id/wartung
* Add a maintenance log entry.
*/
router.post(
'/:id/wartung',
authenticate,
requireGroups(ADMIN_GROUPS),
vehicleController.addWartung.bind(vehicleController)
);
/**
* DELETE /api/vehicles/:id
* Delete a vehicle — dashboard_admin only.
* NOTE: vehicleController.deleteVehicle needs to be implemented.
*/
router.delete(
'/:id',
authenticate,
requireGroups(ADMIN_GROUPS),
vehicleController.deleteVehicle.bind(vehicleController)
);
export default router; export default router;

View File

@@ -3,79 +3,29 @@ import logger from '../utils/logger';
import { import {
Fahrzeug, Fahrzeug,
FahrzeugListItem, FahrzeugListItem,
FahrzeugWithPruefstatus, FahrzeugDetail,
FahrzeugPruefung,
FahrzeugWartungslog, FahrzeugWartungslog,
CreateFahrzeugData, CreateFahrzeugData,
UpdateFahrzeugData, UpdateFahrzeugData,
CreatePruefungData,
CreateWartungslogData, CreateWartungslogData,
FahrzeugStatus, FahrzeugStatus,
PruefungArt,
PruefungIntervalMonths,
VehicleStats, VehicleStats,
InspectionAlert, InspectionAlert,
} from '../models/vehicle.model'; } from '../models/vehicle.model';
// ---------------------------------------------------------------------------
// Helper: add N months to a Date (handles month-end edge cases)
// ---------------------------------------------------------------------------
function addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
// ---------------------------------------------------------------------------
// Helper: map a flat view row to PruefungStatus sub-object
// ---------------------------------------------------------------------------
function mapPruefungStatus(row: any, prefix: string) {
return {
pruefung_id: row[`${prefix}_pruefung_id`] ?? null,
faellig_am: row[`${prefix}_faellig_am`] ?? null,
tage_bis_faelligkeit: row[`${prefix}_tage_bis_faelligkeit`] != null
? parseInt(row[`${prefix}_tage_bis_faelligkeit`], 10)
: null,
ergebnis: row[`${prefix}_ergebnis`] ?? null,
};
}
class VehicleService { class VehicleService {
// ========================================================================= // =========================================================================
// FLEET OVERVIEW // FLEET OVERVIEW
// ========================================================================= // =========================================================================
/**
* Returns all vehicles with their next-due inspection dates per type.
* Used by the fleet overview grid (FahrzeugListItem[]).
*/
async getAllVehicles(): Promise<FahrzeugListItem[]> { async getAllVehicles(): Promise<FahrzeugListItem[]> {
try { try {
const result = await pool.query(` const result = await pool.query(`
SELECT SELECT
id, id, bezeichnung, kurzname, amtliches_kennzeichen,
bezeichnung, baujahr, hersteller, besatzung_soll, status, status_bemerkung,
kurzname, bild_url, paragraph57a_faellig_am, paragraph57a_tage_bis_faelligkeit,
amtliches_kennzeichen, naechste_wartung_am, wartung_tage_bis_faelligkeit, naechste_pruefung_tage
baujahr,
hersteller,
besatzung_soll,
status,
status_bemerkung,
bild_url,
paragraph57a_faellig_am,
paragraph57a_tage_bis_faelligkeit,
naechste_wartung_am,
wartung_tage_bis_faelligkeit,
hu_faellig_am,
hu_tage_bis_faelligkeit,
au_faellig_am,
au_tage_bis_faelligkeit,
uvv_faellig_am,
uvv_tage_bis_faelligkeit,
leiter_faellig_am,
leiter_tage_bis_faelligkeit,
naechste_pruefung_tage
FROM fahrzeuge_mit_pruefstatus FROM fahrzeuge_mit_pruefstatus
ORDER BY bezeichnung ASC ORDER BY bezeichnung ASC
`); `);
@@ -84,17 +34,9 @@ class VehicleService {
...row, ...row,
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null, ? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null, ? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null naechste_pruefung_tage: row.naechste_pruefung_tage != null
? parseInt(row.hu_tage_bis_faelligkeit, 10) : null,
au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null
? parseInt(row.au_tage_bis_faelligkeit, 10) : null,
uvv_tage_bis_faelligkeit: row.uvv_tage_bis_faelligkeit != null
? parseInt(row.uvv_tage_bis_faelligkeit, 10) : null,
leiter_tage_bis_faelligkeit: row.leiter_tage_bis_faelligkeit != null
? parseInt(row.leiter_tage_bis_faelligkeit, 10) : null,
naechste_pruefung_tage: row.naechste_pruefung_tage != null
? parseInt(row.naechste_pruefung_tage, 10) : null, ? parseInt(row.naechste_pruefung_tage, 10) : null,
})) as FahrzeugListItem[]; })) as FahrzeugListItem[];
} catch (error) { } catch (error) {
@@ -107,13 +49,8 @@ class VehicleService {
// VEHICLE DETAIL // VEHICLE DETAIL
// ========================================================================= // =========================================================================
/** async getVehicleById(id: string): Promise<FahrzeugDetail | null> {
* Returns a single vehicle with full pruefstatus, inspection history,
* and maintenance log.
*/
async getVehicleById(id: string): Promise<FahrzeugWithPruefstatus | null> {
try { try {
// 1) Main record + inspection status from view
const vehicleResult = await pool.query( const vehicleResult = await pool.query(
`SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`, `SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`,
[id] [id]
@@ -123,15 +60,6 @@ class VehicleService {
const row = vehicleResult.rows[0]; const row = vehicleResult.rows[0];
// 2) Full inspection history
const pruefungenResult = await pool.query(
`SELECT * FROM fahrzeug_pruefungen
WHERE fahrzeug_id = $1
ORDER BY faellig_am DESC, created_at DESC`,
[id]
);
// 3) Maintenance log
const wartungslogResult = await pool.query( const wartungslogResult = await pool.query(
`SELECT * FROM fahrzeug_wartungslog `SELECT * FROM fahrzeug_wartungslog
WHERE fahrzeug_id = $1 WHERE fahrzeug_id = $1
@@ -139,7 +67,7 @@ class VehicleService {
[id] [id]
); );
const vehicle: FahrzeugWithPruefstatus = { const vehicle: FahrzeugDetail = {
id: row.id, id: row.id,
bezeichnung: row.bezeichnung, bezeichnung: row.bezeichnung,
kurzname: row.kurzname, kurzname: row.kurzname,
@@ -157,20 +85,16 @@ class VehicleService {
naechste_wartung_am: row.naechste_wartung_am ?? null, naechste_wartung_am: row.naechste_wartung_am ?? null,
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
pruefstatus: {
hu: mapPruefungStatus(row, 'hu'),
au: mapPruefungStatus(row, 'au'),
uvv: mapPruefungStatus(row, 'uvv'),
leiter: mapPruefungStatus(row, 'leiter'),
},
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null, ? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
? 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,
pruefungen: pruefungenResult.rows as FahrzeugPruefung[], wartungslog: wartungslogResult.rows.map(r => ({
wartungslog: wartungslogResult.rows as FahrzeugWartungslog[], ...r,
kosten: r.kosten != null ? Number(r.kosten) : null,
})) as FahrzeugWartungslog[],
}; };
return vehicle; return vehicle;
@@ -184,10 +108,7 @@ class VehicleService {
// CRUD // CRUD
// ========================================================================= // =========================================================================
async createVehicle( async createVehicle(data: CreateFahrzeugData, createdBy: string): Promise<Fahrzeug> {
data: CreateFahrzeugData,
createdBy: string
): Promise<Fahrzeug> {
try { try {
const result = await pool.query( const result = await pool.query(
`INSERT INTO fahrzeuge ( `INSERT INTO fahrzeuge (
@@ -224,11 +145,7 @@ class VehicleService {
} }
} }
async updateVehicle( async updateVehicle(id: string, data: UpdateFahrzeugData, updatedBy: string): Promise<Fahrzeug> {
id: string,
data: UpdateFahrzeugData,
updatedBy: string
): Promise<Fahrzeug> {
try { try {
const fields: string[] = []; const fields: string[] = [];
const values: unknown[] = []; const values: unknown[] = [];
@@ -258,9 +175,9 @@ class VehicleService {
throw new Error('No fields to update'); throw new Error('No fields to update');
} }
values.push(id); // for WHERE clause values.push(id);
const result = await pool.query( const result = await pool.query(
`UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`, `UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} AND deleted_at IS NULL RETURNING *`,
values values
); );
@@ -280,7 +197,10 @@ class VehicleService {
async deleteVehicle(id: string, deletedBy: string): Promise<void> { async deleteVehicle(id: string, deletedBy: string): Promise<void> {
try { try {
const result = await pool.query( const result = await pool.query(
`DELETE FROM fahrzeuge WHERE id = $1 RETURNING id`, `UPDATE fahrzeuge
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id`,
[id] [id]
); );
@@ -288,7 +208,7 @@ class VehicleService {
throw new Error('Vehicle not found'); throw new Error('Vehicle not found');
} }
logger.info('Vehicle deleted', { id, by: deletedBy }); logger.info('Vehicle soft-deleted', { id, by: deletedBy });
} catch (error) { } catch (error) {
logger.error('VehicleService.deleteVehicle failed', { error, id }); logger.error('VehicleService.deleteVehicle failed', { error, id });
throw error; throw error;
@@ -297,22 +217,8 @@ class VehicleService {
// ========================================================================= // =========================================================================
// STATUS MANAGEMENT // STATUS MANAGEMENT
// Socket.io-ready: accepts optional `io` parameter.
// In Tier 3, pass the real Socket.IO server instance here.
// The endpoint contract is: PATCH /api/vehicles/:id/status
// ========================================================================= // =========================================================================
/**
* Updates vehicle status and optionally broadcasts a Socket.IO event.
*
* Socket.IO integration (Tier 3):
* Pass the live `io` instance from server.ts. When provided, emits:
* event: 'vehicle:statusChanged'
* payload: { vehicleId, bezeichnung, oldStatus, newStatus, bemerkung, updatedBy, timestamp }
* All connected clients on the default namespace receive the update immediately.
*
* @param io - Optional Socket.IO server instance (injected from app layer in Tier 3)
*/
async updateVehicleStatus( async updateVehicleStatus(
id: string, id: string,
status: FahrzeugStatus, status: FahrzeugStatus,
@@ -320,38 +226,33 @@ class VehicleService {
updatedBy: string, updatedBy: string,
io?: any io?: any
): Promise<void> { ): Promise<void> {
const client = await pool.connect();
try { try {
// Fetch old status for Socket.IO payload and logging await client.query('BEGIN');
const oldResult = await pool.query(
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1`, const oldResult = await client.query(
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
[id] [id]
); );
if (oldResult.rows.length === 0) { if (oldResult.rows.length === 0) {
await client.query('ROLLBACK');
throw new Error('Vehicle not found'); throw new Error('Vehicle not found');
} }
const { bezeichnung, status: oldStatus } = oldResult.rows[0]; const { bezeichnung, status: oldStatus } = oldResult.rows[0];
await pool.query( await client.query(
`UPDATE fahrzeuge `UPDATE fahrzeuge SET status = $1, status_bemerkung = $2 WHERE id = $3`,
SET status = $1, status_bemerkung = $2
WHERE id = $3`,
[status, bemerkung || null, id] [status, bemerkung || null, id]
); );
logger.info('Vehicle status updated', { await client.query('COMMIT');
id,
from: oldStatus, logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy });
to: status,
by: updatedBy,
});
// ── Socket.IO broadcast (Tier 3 integration point) ──────────────────
// When `io` is provided (Tier 3), broadcast the status change to all
// connected dashboard clients so the live status board updates in real time.
if (io) { if (io) {
const payload = { io.emit('vehicle:statusChanged', {
vehicleId: id, vehicleId: id,
bezeichnung, bezeichnung,
oldStatus, oldStatus,
@@ -359,143 +260,14 @@ class VehicleService {
bemerkung: bemerkung || null, bemerkung: bemerkung || null,
updatedBy, updatedBy,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; });
io.emit('vehicle:statusChanged', payload);
logger.debug('Emitted vehicle:statusChanged via Socket.IO', { vehicleId: id });
} }
} catch (error) { } catch (error) {
await client.query('ROLLBACK').catch(() => {});
logger.error('VehicleService.updateVehicleStatus failed', { error, id }); logger.error('VehicleService.updateVehicleStatus failed', { error, id });
throw error; throw error;
} } finally {
} client.release();
// =========================================================================
// INSPECTIONS
// =========================================================================
/**
* Records a new inspection entry.
* Automatically calculates naechste_faelligkeit based on standard intervals
* when durchgefuehrt_am is provided and the art has a known interval.
*/
async addPruefung(
fahrzeugId: string,
data: CreatePruefungData,
createdBy: string
): Promise<FahrzeugPruefung> {
try {
// Auto-calculate naechste_faelligkeit
let naechsteFaelligkeit: string | null = null;
if (data.durchgefuehrt_am) {
const intervalMonths = PruefungIntervalMonths[data.pruefung_art];
if (intervalMonths !== undefined) {
const durchgefuehrt = new Date(data.durchgefuehrt_am);
naechsteFaelligkeit = addMonths(durchgefuehrt, intervalMonths)
.toISOString()
.split('T')[0];
}
}
const result = await pool.query(
`INSERT INTO fahrzeug_pruefungen (
fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am,
ergebnis, naechste_faelligkeit, pruefende_stelle,
kosten, dokument_url, bemerkung, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
[
fahrzeugId,
data.pruefung_art,
data.faellig_am,
data.durchgefuehrt_am ?? null,
data.ergebnis ?? 'ausstehend',
naechsteFaelligkeit,
data.pruefende_stelle ?? null,
data.kosten ?? null,
data.dokument_url ?? null,
data.bemerkung ?? null,
createdBy,
]
);
const pruefung = result.rows[0] as FahrzeugPruefung;
logger.info('Pruefung added', {
pruefungId: pruefung.id,
fahrzeugId,
art: data.pruefung_art,
by: createdBy,
});
return pruefung;
} catch (error) {
logger.error('VehicleService.addPruefung failed', { error, fahrzeugId });
throw new Error('Failed to add inspection record');
}
}
/**
* Returns the full inspection history for a specific vehicle,
* ordered newest-first.
*/
async getPruefungenForVehicle(fahrzeugId: string): Promise<FahrzeugPruefung[]> {
try {
const result = await pool.query(
`SELECT * FROM fahrzeug_pruefungen
WHERE fahrzeug_id = $1
ORDER BY faellig_am DESC, created_at DESC`,
[fahrzeugId]
);
return result.rows.map(r => ({
...r,
kosten: r.kosten != null ? Number(r.kosten) : null,
})) as FahrzeugPruefung[];
} catch (error) {
logger.error('VehicleService.getPruefungenForVehicle failed', { error, fahrzeugId });
throw new Error('Failed to fetch inspection history');
}
}
/**
* Returns all upcoming or overdue inspections within the given lookahead window.
* Used by the dashboard InspectionAlerts panel.
*
* @param daysAhead - How many days into the future to look (e.g. 30).
* Pass a very large number (e.g. 9999) to include all overdue too.
*/
async getUpcomingInspections(daysAhead: number): Promise<InspectionAlert[]> {
try {
// We include already-overdue inspections (tage < 0) AND upcoming within window.
// Only open (not yet completed) inspections are relevant.
const result = await pool.query(
`SELECT
p.id AS pruefung_id,
p.fahrzeug_id,
p.pruefung_art,
p.faellig_am,
(p.faellig_am::date - CURRENT_DATE) AS tage,
f.bezeichnung,
f.kurzname
FROM fahrzeug_pruefungen p
JOIN fahrzeuge f ON f.id = p.fahrzeug_id
WHERE
p.durchgefuehrt_am IS NULL
AND (p.faellig_am::date - CURRENT_DATE) <= $1
ORDER BY p.faellig_am ASC`,
[daysAhead]
);
return result.rows.map((row) => ({
fahrzeugId: row.fahrzeug_id,
bezeichnung: row.bezeichnung,
kurzname: row.kurzname,
pruefungId: row.pruefung_id,
pruefungArt: row.pruefung_art as PruefungArt,
faelligAm: row.faellig_am,
tage: parseInt(row.tage, 10),
})) as InspectionAlert[];
} catch (error) {
logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead });
throw new Error('Failed to fetch inspection alerts');
} }
} }
@@ -509,6 +281,14 @@ class VehicleService {
createdBy: string createdBy: string
): Promise<FahrzeugWartungslog> { ): Promise<FahrzeugWartungslog> {
try { try {
const check = await pool.query(
`SELECT 1 FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL`,
[fahrzeugId]
);
if (check.rows.length === 0) {
throw new Error('Vehicle not found');
}
const result = await pool.query( const result = await pool.query(
`INSERT INTO fahrzeug_wartungslog ( `INSERT INTO fahrzeug_wartungslog (
fahrzeug_id, datum, art, beschreibung, fahrzeug_id, datum, art, beschreibung,
@@ -529,15 +309,11 @@ class VehicleService {
); );
const entry = result.rows[0] as FahrzeugWartungslog; const entry = result.rows[0] as FahrzeugWartungslog;
logger.info('Wartungslog entry added', { logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy });
entryId: entry.id,
fahrzeugId,
by: createdBy,
});
return entry; return entry;
} catch (error) { } catch (error) {
logger.error('VehicleService.addWartungslog failed', { error, fahrzeugId }); logger.error('VehicleService.addWartungslog failed', { error, fahrzeugId });
throw new Error('Failed to add maintenance log entry'); throw error;
} }
} }
@@ -563,14 +339,9 @@ class VehicleService {
// DASHBOARD KPI // DASHBOARD KPI
// ========================================================================= // =========================================================================
/**
* Returns aggregate counts for the dashboard stats strip.
* inspectionsDue = vehicles with at least one inspection due within 30 days
* inspectionsOverdue = vehicles with at least one inspection already overdue
*/
async getVehicleStats(): Promise<VehicleStats> { async getVehicleStats(): Promise<VehicleStats> {
try { try {
const result = await pool.query(` const totalsResult = await pool.query(`
SELECT SELECT
COUNT(*) AS total, COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit, COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
@@ -579,22 +350,31 @@ class VehicleService {
) AS ausser_dienst, ) AS ausser_dienst,
COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang
FROM fahrzeuge FROM fahrzeuge
WHERE deleted_at IS NULL
`); `);
const alertResult = await pool.query(` const alertResult = await pool.query(`
SELECT SELECT
COUNT(DISTINCT fahrzeug_id) FILTER ( COUNT(*) FILTER (
WHERE (faellig_am::date - CURRENT_DATE) BETWEEN 0 AND 30 WHERE (
(paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE BETWEEN 0 AND 30)
OR
(naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE BETWEEN 0 AND 30)
)
) AS inspections_due, ) AS inspections_due,
COUNT(DISTINCT fahrzeug_id) FILTER ( COUNT(*) FILTER (
WHERE faellig_am::date < CURRENT_DATE WHERE (
(paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date < CURRENT_DATE)
OR
(naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date < CURRENT_DATE)
)
) AS inspections_overdue ) AS inspections_overdue
FROM fahrzeug_pruefungen FROM fahrzeuge
WHERE durchgefuehrt_am IS NULL WHERE deleted_at IS NULL
`); `);
const totals = result.rows[0]; const totals = totalsResult.rows[0];
const alerts = alertResult.rows[0]; const alerts = alertResult.rows[0];
return { return {
total: parseInt(totals.total, 10), total: parseInt(totals.total, 10),
@@ -609,6 +389,73 @@ class VehicleService {
throw new Error('Failed to fetch vehicle stats'); throw new Error('Failed to fetch vehicle stats');
} }
} }
async getUpcomingInspections(daysAhead: number): Promise<InspectionAlert[]> {
try {
const result = await pool.query(
`SELECT
id AS fahrzeug_id,
bezeichnung,
kurzname,
paragraph57a_faellig_am,
paragraph57a_faellig_am::date - CURRENT_DATE AS paragraph57a_tage,
naechste_wartung_am,
naechste_wartung_am::date - CURRENT_DATE AS wartung_tage
FROM fahrzeuge
WHERE
deleted_at IS NULL
AND (
(paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE <= $1)
OR
(naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE <= $1)
)
ORDER BY LEAST(
CASE WHEN paragraph57a_faellig_am IS NOT NULL
THEN paragraph57a_faellig_am::date - CURRENT_DATE END,
CASE WHEN naechste_wartung_am IS NOT NULL
THEN naechste_wartung_am::date - CURRENT_DATE END
) ASC NULLS LAST`,
[daysAhead]
);
const alerts: InspectionAlert[] = [];
for (const row of result.rows) {
if (row.paragraph57a_faellig_am !== null && row.paragraph57a_tage !== null) {
const tage = parseInt(row.paragraph57a_tage, 10);
if (tage <= daysAhead) {
alerts.push({
fahrzeugId: row.fahrzeug_id,
bezeichnung: row.bezeichnung,
kurzname: row.kurzname,
type: '57a',
faelligAm: row.paragraph57a_faellig_am,
tage,
});
}
}
if (row.naechste_wartung_am !== null && row.wartung_tage !== null) {
const tage = parseInt(row.wartung_tage, 10);
if (tage <= daysAhead) {
alerts.push({
fahrzeugId: row.fahrzeug_id,
bezeichnung: row.bezeichnung,
kurzname: row.kurzname,
type: 'wartung',
faelligAm: row.naechste_wartung_am,
tage,
});
}
}
}
alerts.sort((a, b) => a.tage - b.tage);
return alerts;
} catch (error) {
logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead });
throw new Error('Failed to fetch inspection alerts');
}
}
} }
export default new VehicleService(); export default new VehicleService();

View File

@@ -12,6 +12,7 @@ import Einsaetze from './pages/Einsaetze';
import EinsatzDetail from './pages/EinsatzDetail'; import EinsatzDetail from './pages/EinsatzDetail';
import Fahrzeuge from './pages/Fahrzeuge'; import Fahrzeuge from './pages/Fahrzeuge';
import FahrzeugDetail from './pages/FahrzeugDetail'; import FahrzeugDetail from './pages/FahrzeugDetail';
import FahrzeugForm from './pages/FahrzeugForm';
import Ausruestung from './pages/Ausruestung'; import Ausruestung from './pages/Ausruestung';
import Mitglieder from './pages/Mitglieder'; import Mitglieder from './pages/Mitglieder';
import MitgliedDetail from './pages/MitgliedDetail'; import MitgliedDetail from './pages/MitgliedDetail';
@@ -76,6 +77,22 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/fahrzeuge/neu"
element={
<ProtectedRoute>
<FahrzeugForm />
</ProtectedRoute>
}
/>
<Route
path="/fahrzeuge/:id/bearbeiten"
element={
<ProtectedRoute>
<FahrzeugForm />
</ProtectedRoute>
}
/>
<Route <Route
path="/fahrzeuge/:id" path="/fahrzeuge/:id"
element={ element={

View File

@@ -4,15 +4,12 @@ import {
AlertTitle, AlertTitle,
Box, Box,
CircularProgress, CircularProgress,
Collapse,
Link, Link,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { vehiclesApi } from '../../services/vehicles'; import { vehiclesApi } from '../../services/vehicles';
import { InspectionAlert, PruefungArtLabel, PruefungArt } from '../../types/vehicle.types'; import { InspectionAlert, InspectionAlertType } from '../../types/vehicle.types';
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', { return new Date(iso).toLocaleDateString('de-DE', {
@@ -22,6 +19,10 @@ function formatDate(iso: string): string {
}); });
} }
function alertTypeLabel(type: InspectionAlertType): string {
return type === '57a' ? '§57a Periodische Prüfung' : 'Nächste Wartung / Service';
}
type Urgency = 'overdue' | 'urgent' | 'warning'; type Urgency = 'overdue' | 'urgent' | 'warning';
function getUrgency(tage: number): Urgency { function getUrgency(tage: number): Urgency {
@@ -36,12 +37,8 @@ const URGENCY_CONFIG: Record<Urgency, { severity: 'error' | 'warning'; label: st
warning: { severity: 'warning', label: 'Fällig in Kürze (≤ 30 Tage)' }, warning: { severity: 'warning', label: 'Fällig in Kürze (≤ 30 Tage)' },
}; };
// ── Component ─────────────────────────────────────────────────────────────────
interface InspectionAlertsProps { interface InspectionAlertsProps {
/** How many days ahead to fetch — default 30 */
daysAhead?: number; daysAhead?: number;
/** Collapse into a single banner if no alerts */
hideWhenEmpty?: boolean; hideWhenEmpty?: boolean;
} }
@@ -55,7 +52,6 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const fetchAlerts = async () => { const fetchAlerts = async () => {
try { try {
setLoading(true); setLoading(true);
@@ -68,7 +64,6 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
if (mounted) setLoading(false); if (mounted) setLoading(false);
} }
}; };
fetchAlerts(); fetchAlerts();
return () => { mounted = false; }; return () => { mounted = false; };
}, [daysAhead]); }, [daysAhead]);
@@ -92,12 +87,11 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
if (hideWhenEmpty) return null; if (hideWhenEmpty) return null;
return ( return (
<Alert severity="success"> <Alert severity="success">
Alle Prüfungsfristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen. Alle Fristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen.
</Alert> </Alert>
); );
} }
// Group by urgency
const overdue = alerts.filter((a) => a.tage < 0); const overdue = alerts.filter((a) => a.tage < 0);
const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14); const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14);
const warning = alerts.filter((a) => a.tage > 14); const warning = alerts.filter((a) => a.tage > 14);
@@ -117,35 +111,37 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
<AlertTitle sx={{ fontWeight: 600 }}>{label}</AlertTitle> <AlertTitle sx={{ fontWeight: 600 }}>{label}</AlertTitle>
<Box component="ul" sx={{ m: 0, pl: 2 }}> <Box component="ul" sx={{ m: 0, pl: 2 }}>
{items.map((alert) => { {items.map((alert) => {
const artLabel = PruefungArtLabel[alert.pruefungArt as PruefungArt] ?? alert.pruefungArt; const typeLabel = alertTypeLabel(alert.type);
const dateStr = formatDate(alert.faelligAm); const dateStr = formatDate(alert.faelligAm);
const tageText = alert.tage < 0 const tageText = alert.tage < 0
? `seit ${Math.abs(alert.tage)} Tag${Math.abs(alert.tage) === 1 ? '' : 'en'} überfällig` ? `seit ${Math.abs(alert.tage)} Tag${Math.abs(alert.tage) === 1 ? '' : 'en'} überfällig`
: alert.tage === 0 : alert.tage === 0
? 'heute fällig' ? 'heute fällig'
: `fällig in ${alert.tage} Tag${alert.tage === 1 ? '' : 'en'}`; : `fällig in ${alert.tage} Tag${alert.tage === 1 ? '' : 'en'}`;
return ( return (
<Collapse key={alert.pruefungId} in timeout="auto"> <Box
<Box component="li" sx={{ mb: 0.5 }}> key={`${alert.fahrzeugId}-${alert.type}`}
<Link component="li"
component={RouterLink} sx={{ mb: 0.5 }}
to={`/fahrzeuge/${alert.fahrzeugId}`} >
color="inherit" <Link
underline="hover" component={RouterLink}
sx={{ fontWeight: 500 }} to={`/fahrzeuge/${alert.fahrzeugId}`}
> color="inherit"
{alert.bezeichnung} underline="hover"
{alert.kurzname ? ` (${alert.kurzname})` : ''} sx={{ fontWeight: 500 }}
</Link> >
{' — '} {alert.bezeichnung}
<strong>{artLabel}</strong> {alert.kurzname ? ` (${alert.kurzname})` : ''}
{' '} </Link>
<Typography component="span" variant="body2"> {' — '}
{tageText} ({dateStr}) <strong>{typeLabel}</strong>
</Typography> {' '}
</Box> <Typography component="span" variant="body2">
</Collapse> {tageText} ({dateStr})
</Typography>
</Box>
); );
})} })}
</Box> </Box>

View File

@@ -22,13 +22,6 @@ import {
Tab, Tab,
Tabs, Tabs,
TextField, TextField,
Timeline,
TimelineConnector,
TimelineContent,
TimelineDot,
TimelineItem,
TimelineOppositeContent,
TimelineSeparator,
Tooltip, Tooltip,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
@@ -39,6 +32,7 @@ import {
Build, Build,
CheckCircle, CheckCircle,
DirectionsCar, DirectionsCar,
Edit,
Error as ErrorIcon, Error as ErrorIcon,
LocalFireDepartment, LocalFireDepartment,
PauseCircle, PauseCircle,
@@ -51,16 +45,12 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
import { import {
FahrzeugDetail, FahrzeugDetail,
FahrzeugPruefung,
FahrzeugWartungslog, FahrzeugWartungslog,
FahrzeugStatus, FahrzeugStatus,
FahrzeugStatusLabel, FahrzeugStatusLabel,
PruefungArt,
PruefungArtLabel,
CreatePruefungPayload,
CreateWartungslogPayload, CreateWartungslogPayload,
UpdateStatusPayload,
WartungslogArt, WartungslogArt,
PruefungErgebnis,
} from '../types/vehicle.types'; } from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
@@ -125,11 +115,24 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
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 openDialog = () => {
setNewStatus(vehicle.status);
setBemerkung(vehicle.status_bemerkung ?? '');
setSaveError(null);
setStatusDialogOpen(true);
};
const closeDialog = () => {
setSaveError(null);
setStatusDialogOpen(false);
};
const handleSaveStatus = async () => { const handleSaveStatus = async () => {
try { try {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
await vehiclesApi.updateStatus(vehicle.id, { status: newStatus, bemerkung }); const payload: UpdateStatusPayload = { status: newStatus, bemerkung };
await vehiclesApi.updateStatus(vehicle.id, payload);
setStatusDialogOpen(false); setStatusDialogOpen(false);
onStatusUpdated(); onStatusUpdated();
} catch { } catch {
@@ -141,6 +144,12 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden; const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden;
// Inspection deadline badges
const inspItems: { label: string; faelligAm: string | null; tage: number | null }[] = [
{ label: '§57a Periodische Prüfung', faelligAm: vehicle.paragraph57a_faellig_am, tage: vehicle.paragraph57a_tage_bis_faelligkeit },
{ label: 'Nächste Wartung / Service', faelligAm: vehicle.naechste_wartung_am, tage: vehicle.wartung_tage_bis_faelligkeit },
];
return ( return (
<Box> <Box>
{isSchaden && ( {isSchaden && (
@@ -156,9 +165,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{STATUS_ICONS[vehicle.status]} {STATUS_ICONS[vehicle.status]}
<Box> <Box>
<Typography variant="subtitle1" fontWeight={600}> <Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
Aktueller Status
</Typography>
<Chip <Chip
label={FahrzeugStatusLabel[vehicle.status]} label={FahrzeugStatusLabel[vehicle.status]}
color={STATUS_CHIP_COLOR[vehicle.status]} color={STATUS_CHIP_COLOR[vehicle.status]}
@@ -171,18 +178,11 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
)} )}
</Box> </Box>
</Box> </Box>
<Button {canChangeStatus && (
variant="outlined" <Button variant="outlined" size="small" onClick={openDialog}>
size="small" Status ändern
onClick={() => { </Button>
setNewStatus(vehicle.status); )}
setBemerkung(vehicle.status_bemerkung ?? '');
setStatusDialogOpen(true);
}}
sx={{ display: canChangeStatus ? undefined : 'none' }}
>
Status ändern
</Button>
</Box> </Box>
</Paper> </Paper>
@@ -198,8 +198,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel }, { label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll }, { label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
{ label: 'Standort', value: vehicle.standort }, { label: 'Standort', value: vehicle.standort },
{ label: '§57a fällig am', value: fmtDate(vehicle.paragraph57a_faellig_am) !== '—' ? fmtDate(vehicle.paragraph57a_faellig_am) : null },
{ label: 'Nächste Wartung', value: fmtDate(vehicle.naechste_wartung_am) !== '—' ? fmtDate(vehicle.naechste_wartung_am) : null },
].map(({ label, value }) => ( ].map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}> <Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase"> <Typography variant="caption" color="text.secondary" textTransform="uppercase">
@@ -210,48 +208,40 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
))} ))}
</Grid> </Grid>
{/* Inspection status quick view */} {/* Inspection deadline quick view */}
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}> <Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
Prüffristen Übersicht Prüf- und Wartungsfristen
</Typography> </Typography>
<Grid container spacing={1.5}> <Grid container spacing={1.5}>
{Object.entries(vehicle.pruefstatus).map(([key, ps]) => { {inspItems.map(({ label, faelligAm, tage }) => {
const art = key.toUpperCase() as PruefungArt; const color = inspectionBadgeColor(tage);
const label = PruefungArtLabel[art] ?? key;
const color = inspectionBadgeColor(ps.tage_bis_faelligkeit);
return ( return (
<Grid item xs={12} sm={6} md={3} key={key}> <Grid item xs={12} sm={6} key={label}>
<Paper variant="outlined" sx={{ p: 1.5 }}> <Paper variant="outlined" sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase"> <Typography variant="caption" color="text.secondary" textTransform="uppercase">
{label} {label}
</Typography> </Typography>
{ps.faellig_am ? ( {faelligAm ? (
<> <>
<Chip <Chip
size="small" size="small"
color={color} color={color}
label={ label={
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0 tage !== null && tage < 0
? `ÜBERFÄLLIG (${fmtDate(ps.faellig_am)})` ? `ÜBERFÄLLIG (${fmtDate(faelligAm)})`
: `Fällig: ${fmtDate(ps.faellig_am)}` : `Fällig: ${fmtDate(faelligAm)}`
}
icon={
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
? <Warning fontSize="small" />
: undefined
} }
icon={tage !== null && tage < 0 ? <Warning fontSize="small" /> : undefined}
sx={{ mt: 0.5 }} sx={{ mt: 0.5 }}
/> />
{ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit >= 0 && ( {tage !== null && tage >= 0 && (
<Typography variant="caption" display="block" color="text.secondary"> <Typography variant="caption" display="block" color="text.secondary">
in {ps.tage_bis_faelligkeit} Tagen in {tage} Tagen
</Typography> </Typography>
)} )}
</> </>
) : ( ) : (
<Typography variant="body2" color="text.disabled"> <Typography variant="body2" color="text.disabled">Kein Datum erfasst</Typography>
Keine Daten
</Typography>
)} )}
</Paper> </Paper>
</Grid> </Grid>
@@ -260,12 +250,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
</Grid> </Grid>
{/* Status change dialog */} {/* Status change dialog */}
<Dialog <Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
open={statusDialogOpen}
onClose={() => setStatusDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Fahrzeugstatus ändern</DialogTitle> <DialogTitle>Fahrzeugstatus ändern</DialogTitle>
<DialogContent> <DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>} {saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
@@ -278,9 +263,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
onChange={(e) => setNewStatus(e.target.value as FahrzeugStatus)} onChange={(e) => setNewStatus(e.target.value as FahrzeugStatus)}
> >
{Object.values(FahrzeugStatus).map((s) => ( {Object.values(FahrzeugStatus).map((s) => (
<MenuItem key={s} value={s}> <MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
{FahrzeugStatusLabel[s]}
</MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
@@ -295,7 +278,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setStatusDialogOpen(false)}>Abbrechen</Button> <Button onClick={closeDialog}>Abbrechen</Button>
<Button <Button
variant="contained" variant="contained"
onClick={handleSaveStatus} onClick={handleSaveStatus}
@@ -310,247 +293,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
); );
}; };
// ── Prüfungen Tab ─────────────────────────────────────────────────────────────
interface PruefungenTabProps {
fahrzeugId: string;
pruefungen: FahrzeugPruefung[];
onAdded: () => void;
canWrite: boolean;
}
const ERGEBNIS_LABELS: Record<PruefungErgebnis, string> = {
bestanden: 'Bestanden',
bestanden_mit_maengeln: 'Bestanden mit Mängeln',
nicht_bestanden: 'Nicht bestanden',
ausstehend: 'Ausstehend',
};
const ERGEBNIS_COLORS: Record<PruefungErgebnis, 'success' | 'warning' | 'error' | 'default'> = {
bestanden: 'success',
bestanden_mit_maengeln: 'warning',
nicht_bestanden: 'error',
ausstehend: 'default',
};
const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, onAdded, canWrite }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const emptyForm: CreatePruefungPayload = {
pruefung_art: PruefungArt.HU,
faellig_am: '',
durchgefuehrt_am: '',
ergebnis: 'ausstehend',
pruefende_stelle: '',
kosten: undefined,
bemerkung: '',
};
const [form, setForm] = useState<CreatePruefungPayload>(emptyForm);
const handleSubmit = async () => {
if (!form.faellig_am) {
setSaveError('Fälligkeitsdatum ist erforderlich.');
return;
}
try {
setSaving(true);
setSaveError(null);
const payload: CreatePruefungPayload = {
...form,
durchgefuehrt_am: form.durchgefuehrt_am || undefined,
kosten: form.kosten !== undefined && form.kosten !== null ? Number(form.kosten) : undefined,
};
await vehiclesApi.addPruefung(fahrzeugId, payload);
setDialogOpen(false);
setForm(emptyForm);
onAdded();
} catch {
setSaveError('Prüfung konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
return (
<Box>
{pruefungen.length === 0 ? (
<Typography color="text.secondary">Noch keine Prüfungen erfasst.</Typography>
) : (
<Stack divider={<Divider />} spacing={0}>
{pruefungen.map((p) => {
const ergebnis = (p.ergebnis ?? 'ausstehend') as PruefungErgebnis;
const isFaellig = !p.durchgefuehrt_am && new Date(p.faellig_am) < new Date();
return (
<Box key={p.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Box sx={{ minWidth: 140 }}>
<Typography variant="caption" color="text.secondary">
{PruefungArtLabel[p.pruefung_art] ?? p.pruefung_art}
</Typography>
<Typography variant="body2" fontWeight={600}>
Fällig: {fmtDate(p.faellig_am)}
</Typography>
{isFaellig && !p.durchgefuehrt_am && (
<Chip label="ÜBERFÄLLIG" color="error" size="small" sx={{ mt: 0.5 }} />
)}
</Box>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 0.5 }}>
<Chip
label={ERGEBNIS_LABELS[ergebnis]}
color={ERGEBNIS_COLORS[ergebnis]}
size="small"
/>
{p.durchgefuehrt_am && (
<Chip
label={`Durchgeführt: ${fmtDate(p.durchgefuehrt_am)}`}
variant="outlined"
size="small"
/>
)}
{p.naechste_faelligkeit && (
<Chip
label={`Nächste: ${fmtDate(p.naechste_faelligkeit)}`}
variant="outlined"
size="small"
/>
)}
</Box>
{p.pruefende_stelle && (
<Typography variant="body2" color="text.secondary">
{p.pruefende_stelle}
{p.kosten != null && ` · ${p.kosten.toFixed(2)}`}
</Typography>
)}
{p.bemerkung && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{p.bemerkung}
</Typography>
)}
</Box>
</Box>
);
})}
</Stack>
)}
{/* FAB */}
{canWrite && (
<Fab
color="primary"
size="small"
aria-label="Prüfung hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
>
<Add />
</Fab>
)}
{/* Add inspection dialog */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Prüfung erfassen</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Prüfungsart</InputLabel>
<Select
label="Prüfungsart"
value={form.pruefung_art}
onChange={(e) => setForm((f) => ({ ...f, pruefung_art: e.target.value as PruefungArt }))}
>
{Object.values(PruefungArt).map((art) => (
<MenuItem key={art} value={art}>{PruefungArtLabel[art]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Ergebnis</InputLabel>
<Select
label="Ergebnis"
value={form.ergebnis ?? 'ausstehend'}
onChange={(e) => setForm((f) => ({ ...f, ergebnis: e.target.value as PruefungErgebnis }))}
>
{(Object.keys(ERGEBNIS_LABELS) as PruefungErgebnis[]).map((e) => (
<MenuItem key={e} value={e}>{ERGEBNIS_LABELS[e]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fällig am *"
type="date"
fullWidth
value={form.faellig_am}
onChange={(e) => setForm((f) => ({ ...f, faellig_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Durchgeführt am"
type="date"
fullWidth
value={form.durchgefuehrt_am ?? ''}
onChange={(e) => setForm((f) => ({ ...f, durchgefuehrt_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Prüfende Stelle"
fullWidth
value={form.pruefende_stelle ?? ''}
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
placeholder="z.B. TÜV Süd Stuttgart"
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Kosten (€)"
type="number"
fullWidth
value={form.kosten ?? ''}
onChange={(e) => setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))}
inputProps={{ min: 0, step: 0.01 }}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Bemerkung"
fullWidth
multiline
rows={2}
value={form.bemerkung ?? ''}
onChange={(e) => setForm((f) => ({ ...f, bemerkung: e.target.value }))}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}
>
Speichern
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// ── Wartung Tab ─────────────────────────────────────────────────────────────── // ── Wartung Tab ───────────────────────────────────────────────────────────────
interface WartungTabProps { interface WartungTabProps {
@@ -561,11 +303,11 @@ interface WartungTabProps {
} }
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = { const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
Kraftstoff: <LocalFireDepartment color="action" />, Kraftstoff: <LocalFireDepartment color="action" />,
Reparatur: <Build color="warning" />, Reparatur: <Build color="warning" />,
Inspektion: <Assignment color="primary" />, Inspektion: <Assignment color="primary" />,
Hauptuntersuchung:<CheckCircle color="success" />, Hauptuntersuchung: <CheckCircle color="success" />,
default: <Build color="action" />, default: <Build color="action" />,
}; };
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => { const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => {
@@ -612,8 +354,6 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
{wartungslog.length === 0 ? ( {wartungslog.length === 0 ? (
<Typography color="text.secondary">Noch keine Wartungseinträge erfasst.</Typography> <Typography color="text.secondary">Noch keine Wartungseinträge erfasst.</Typography>
) : ( ) : (
// MUI Timeline is available via @mui/lab — using Paper list as fallback
// since @mui/lab is not in current package.json
<Stack divider={<Divider />} spacing={0}> <Stack divider={<Divider />} spacing={0}>
{wartungslog.map((entry) => { {wartungslog.map((entry) => {
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default; const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
@@ -623,9 +363,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
<Box sx={{ flexGrow: 1 }}> <Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography> <Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
{entry.art && ( {entry.art && <Chip label={entry.art} size="small" variant="outlined" />}
<Chip label={entry.art} size="small" variant="outlined" />
)}
</Box> </Box>
<Typography variant="body2">{entry.beschreibung}</Typography> <Typography variant="body2">{entry.beschreibung}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
@@ -795,11 +533,7 @@ function FahrzeugDetail() {
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Alert severity="error">{error ?? 'Fahrzeug nicht gefunden.'}</Alert> <Alert severity="error">{error ?? 'Fahrzeug nicht gefunden.'}</Alert>
<Button <Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
startIcon={<ArrowBack />}
onClick={() => navigate('/fahrzeuge')}
sx={{ mt: 2 }}
>
Zurück zur Übersicht Zurück zur Übersicht
</Button> </Button>
</Container> </Container>
@@ -807,10 +541,13 @@ function FahrzeugDetail() {
); );
} }
const hasOverdue =
(vehicle.paragraph57a_tage_bis_faelligkeit !== null && vehicle.paragraph57a_tage_bis_faelligkeit < 0) ||
(vehicle.wartung_tage_bis_faelligkeit !== null && vehicle.wartung_tage_bis_faelligkeit < 0);
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
{/* Breadcrumb / back */}
<Button <Button
startIcon={<ArrowBack />} startIcon={<ArrowBack />}
onClick={() => navigate('/fahrzeuge')} onClick={() => navigate('/fahrzeuge')}
@@ -820,7 +557,6 @@ function FahrzeugDetail() {
Fahrzeugübersicht Fahrzeugübersicht
</Button> </Button>
{/* Page title */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} /> <DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
<Box> <Box>
@@ -839,16 +575,26 @@ function FahrzeugDetail() {
</Typography> </Typography>
)} )}
</Box> </Box>
<Box sx={{ ml: 'auto' }}> <Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip <Chip
icon={STATUS_ICONS[vehicle.status]} icon={STATUS_ICONS[vehicle.status]}
label={FahrzeugStatusLabel[vehicle.status]} label={FahrzeugStatusLabel[vehicle.status]}
color={STATUS_CHIP_COLOR[vehicle.status]} color={STATUS_CHIP_COLOR[vehicle.status]}
/> />
{isAdmin && (
<Tooltip title="Fahrzeug bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)}
aria-label="Fahrzeug bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
</Box> </Box>
</Box> </Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs <Tabs
value={activeTab} value={activeTab}
@@ -858,42 +604,35 @@ function FahrzeugDetail() {
<Tab label="Übersicht" /> <Tab label="Übersicht" />
<Tab <Tab
label={ label={
vehicle.naechste_pruefung_tage !== null && vehicle.naechste_pruefung_tage < 0 hasOverdue
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> ? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Prüfungen <Warning color="error" fontSize="small" /> Wartung <Warning color="error" fontSize="small" />
</Box> </Box>
: 'Prüfungen' : 'Wartung'
} }
/> />
<Tab label="Wartung" />
<Tab label="Einsätze" /> <Tab label="Einsätze" />
</Tabs> </Tabs>
</Box> </Box>
{/* Tab content */}
<TabPanel value={activeTab} index={0}> <TabPanel value={activeTab} index={0}>
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} canChangeStatus={canChangeStatus} /> <UebersichtTab
</TabPanel> vehicle={vehicle}
onStatusUpdated={fetchVehicle}
<TabPanel value={activeTab} index={1}> canChangeStatus={canChangeStatus}
<PruefungenTab
fahrzeugId={vehicle.id}
pruefungen={vehicle.pruefungen}
onAdded={fetchVehicle}
canWrite={isAdmin}
/> />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={2}> <TabPanel value={activeTab} index={1}>
<WartungTab <WartungTab
fahrzeugId={vehicle.id} fahrzeugId={vehicle.id}
wartungslog={vehicle.wartungslog} wartungslog={vehicle.wartungslog}
onAdded={fetchVehicle} onAdded={fetchVehicle}
canWrite={isAdmin} canWrite={canChangeStatus}
/> />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={3}> <TabPanel value={activeTab} index={2}>
<Box sx={{ textAlign: 'center', py: 8 }}> <Box sx={{ textAlign: 'center', py: 8 }}>
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} /> <LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary"> <Typography variant="h6" color="text.secondary">

View File

@@ -0,0 +1,400 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
Container,
FormControl,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
TextField,
Typography,
} from '@mui/material';
import { ArrowBack, Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import {
FahrzeugStatus,
FahrzeugStatusLabel,
CreateFahrzeugPayload,
UpdateFahrzeugPayload,
} from '../types/vehicle.types';
// ── Form state shape ──────────────────────────────────────────────────────────
interface FormState {
bezeichnung: string;
kurzname: string;
amtliches_kennzeichen: string;
fahrgestellnummer: string;
baujahr: string; // kept as string for input, parsed on submit
hersteller: string;
typ_schluessel: string;
besatzung_soll: string;
status: FahrzeugStatus;
status_bemerkung: string;
standort: string;
bild_url: string;
paragraph57a_faellig_am: string; // ISO date 'YYYY-MM-DD' or ''
naechste_wartung_am: string; // ISO date 'YYYY-MM-DD' or ''
}
const EMPTY_FORM: FormState = {
bezeichnung: '',
kurzname: '',
amtliches_kennzeichen: '',
fahrgestellnummer: '',
baujahr: '',
hersteller: '',
typ_schluessel: '',
besatzung_soll: '',
status: FahrzeugStatus.Einsatzbereit,
status_bemerkung: '',
standort: 'Feuerwehrhaus',
bild_url: '',
paragraph57a_faellig_am: '',
naechste_wartung_am: '',
};
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' */
function toDateInput(iso: string | null | undefined): string {
if (!iso) return '';
return iso.slice(0, 10);
}
// ── Component ─────────────────────────────────────────────────────────────────
function FahrzeugForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isEditMode = Boolean(id);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditMode);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const fetchVehicle = useCallback(async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const vehicle = await vehiclesApi.getById(id);
setForm({
bezeichnung: vehicle.bezeichnung,
kurzname: vehicle.kurzname ?? '',
amtliches_kennzeichen: vehicle.amtliches_kennzeichen ?? '',
fahrgestellnummer: vehicle.fahrgestellnummer ?? '',
baujahr: vehicle.baujahr?.toString() ?? '',
hersteller: vehicle.hersteller ?? '',
typ_schluessel: vehicle.typ_schluessel ?? '',
besatzung_soll: vehicle.besatzung_soll ?? '',
status: vehicle.status,
status_bemerkung: vehicle.status_bemerkung ?? '',
standort: vehicle.standort,
bild_url: vehicle.bild_url ?? '',
paragraph57a_faellig_am: toDateInput(vehicle.paragraph57a_faellig_am),
naechste_wartung_am: toDateInput(vehicle.naechste_wartung_am),
});
} catch {
setError('Fahrzeug konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
if (isEditMode) fetchVehicle();
}, [isEditMode, fetchVehicle]);
const validate = (): boolean => {
const errors: Partial<Record<keyof FormState, string>> = {};
if (!form.bezeichnung.trim()) {
errors.bezeichnung = 'Bezeichnung ist erforderlich.';
}
if (form.baujahr && (isNaN(Number(form.baujahr)) || Number(form.baujahr) < 1950 || Number(form.baujahr) > 2100)) {
errors.baujahr = 'Baujahr muss zwischen 1950 und 2100 liegen.';
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
try {
setSaving(true);
setSaveError(null);
if (isEditMode && id) {
const payload: UpdateFahrzeugPayload = {
bezeichnung: form.bezeichnung.trim() || undefined,
kurzname: form.kurzname.trim() || undefined,
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined,
fahrgestellnummer: form.fahrgestellnummer.trim() || undefined,
baujahr: form.baujahr ? Number(form.baujahr) : undefined,
hersteller: form.hersteller.trim() || undefined,
typ_schluessel: form.typ_schluessel.trim() || undefined,
besatzung_soll: form.besatzung_soll.trim() || undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
naechste_wartung_am: form.naechste_wartung_am || undefined,
};
await vehiclesApi.update(id, payload);
navigate(`/fahrzeuge/${id}`);
} else {
const payload: CreateFahrzeugPayload = {
bezeichnung: form.bezeichnung.trim(),
kurzname: form.kurzname.trim() || undefined,
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined,
fahrgestellnummer: form.fahrgestellnummer.trim() || undefined,
baujahr: form.baujahr ? Number(form.baujahr) : undefined,
hersteller: form.hersteller.trim() || undefined,
typ_schluessel: form.typ_schluessel.trim() || undefined,
besatzung_soll: form.besatzung_soll.trim() || undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
naechste_wartung_am: form.naechste_wartung_am || undefined,
};
const newVehicle = await vehiclesApi.create(payload);
navigate(`/fahrzeuge/${newVehicle.id}`);
}
} catch {
setSaveError(
isEditMode
? 'Fahrzeug konnte nicht gespeichert werden.'
: 'Fahrzeug konnte nicht erstellt werden.'
);
} finally {
setSaving(false);
}
};
const f = (field: keyof FormState) => ({
value: form[field] as string,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((prev) => ({ ...prev, [field]: e.target.value })),
error: Boolean(fieldErrors[field]),
helperText: fieldErrors[field],
});
if (loading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (error) {
return (
<DashboardLayout>
<Container maxWidth="md">
<Alert severity="error">{error}</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
Zurück
</Button>
</Container>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Container maxWidth="md">
<Button
startIcon={<ArrowBack />}
onClick={() => (isEditMode && id ? navigate(`/fahrzeuge/${id}`) : navigate('/fahrzeuge'))}
sx={{ mb: 2 }}
size="small"
>
{isEditMode ? 'Zurück zur Detailansicht' : 'Fahrzeugübersicht'}
</Button>
<Typography variant="h4" gutterBottom>
{isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
</Typography>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Stammdaten</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={8}>
<TextField
label="Bezeichnung *"
fullWidth
{...f('bezeichnung')}
placeholder="z.B. HLF 20/16"
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Kurzname"
fullWidth
{...f('kurzname')}
placeholder="z.B. HLF 1"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Amtl. Kennzeichen"
fullWidth
{...f('amtliches_kennzeichen')}
placeholder="z.B. WN-FW 1"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fahrgestellnummer (VIN)"
fullWidth
{...f('fahrgestellnummer')}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Baujahr"
type="number"
fullWidth
{...f('baujahr')}
inputProps={{ min: 1950, max: 2100 }}
/>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Hersteller"
fullWidth
{...f('hersteller')}
placeholder="z.B. MAN TGM / Rosenbauer"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Typ-Schlüssel (DIN 14502)"
fullWidth
{...f('typ_schluessel')}
placeholder="z.B. LF 10"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
label="Besatzung (Soll)"
fullWidth
{...f('besatzung_soll')}
placeholder="z.B. 1/8"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
label="Standort"
fullWidth
{...f('standort')}
/>
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value as FahrzeugStatus }))}
>
{Object.values(FahrzeugStatus).map((s) => (
<MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Status-Bemerkung"
fullWidth
{...f('status_bemerkung')}
placeholder="z.B. Fahrzeug in Werkstatt bis 01.03."
/>
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüf- und Wartungsfristen</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
label="§57a fällig am"
type="date"
fullWidth
value={form.paragraph57a_faellig_am}
onChange={(e) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
helperText="Periodische Begutachtung (§57a StVO)"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Nächste Wartung am"
type="date"
fullWidth
value={form.naechste_wartung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
helperText="Nächster geplanter Servicetermin"
/>
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Bild</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label="Bild-URL"
fullWidth
{...f('bild_url')}
placeholder="https://..."
helperText="Direktlink zu einem Fahrzeugfoto (https://)"
/>
</Grid>
</Grid>
</Paper>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 3 }}>
<Button
variant="outlined"
onClick={() => (isEditMode && id ? navigate(`/fahrzeuge/${id}`) : navigate('/fahrzeuge'))}
>
Abbrechen
</Button>
<Button
variant="contained"
startIcon={saving ? <CircularProgress size={16} /> : <Save />}
onClick={handleSubmit}
disabled={saving}
>
{isEditMode ? 'Änderungen speichern' : 'Fahrzeug erstellen'}
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
export default FahrzeugForm;

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { import {
Alert,
Box, Box,
Card, Card,
CardActionArea, CardActionArea,
@@ -15,7 +16,6 @@ import {
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
Alert,
} from '@mui/material'; } from '@mui/material';
import { import {
Add, Add,
@@ -35,8 +35,6 @@ import {
FahrzeugListItem, FahrzeugListItem,
FahrzeugStatus, FahrzeugStatus,
FahrzeugStatusLabel, FahrzeugStatusLabel,
PruefungArt,
PruefungArtLabel,
} from '../types/vehicle.types'; } from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
@@ -64,13 +62,23 @@ function inspBadgeColor(tage: number | null): InspBadgeColor {
} }
function inspBadgeLabel(art: string, tage: number | null, faelligAm: string | null): string { function inspBadgeLabel(art: string, tage: number | null, faelligAm: string | null): string {
const artShort = art; // 'HU', 'AU', etc.
if (faelligAm === null) return ''; if (faelligAm === null) return '';
const date = new Date(faelligAm).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }); const date = new Date(faelligAm).toLocaleDateString('de-DE', {
if (tage === null) return `${artShort}: ${date}`; day: '2-digit', month: '2-digit', year: '2-digit',
if (tage < 0) return `${artShort}: ÜBERFÄLLIG (${date})`; });
if (tage === 0) return `${artShort}: heute (${date})`; if (tage === null) return `${art}: ${date}`;
return `${artShort}: ${date}`; if (tage < 0) return `${art}: ÜBERFÄLLIG (${date})`;
if (tage === 0) return `${art}: heute (${date})`;
return `${art}: ${date}`;
}
function inspTooltipTitle(fullLabel: string, tage: number | null, faelligAm: string | null): string {
if (!faelligAm) return fullLabel;
const date = new Date(faelligAm).toLocaleDateString('de-DE');
if (tage !== null && tage < 0) {
return `${fullLabel}: Seit ${Math.abs(tage)} Tagen überfällig!`;
}
return `${fullLabel}: Fällig am ${date}`;
} }
// ── Vehicle Card ────────────────────────────────────────────────────────────── // ── Vehicle Card ──────────────────────────────────────────────────────────────
@@ -81,15 +89,23 @@ interface VehicleCardProps {
} }
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => { const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
const status = vehicle.status as FahrzeugStatus; const status = vehicle.status as FahrzeugStatus;
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit]; const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden; const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
// Collect inspection badges (only for types where a faellig_am exists) const inspBadges = [
const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [ {
{ art: '§57a', tage: vehicle.paragraph57a_tage_bis_faelligkeit, faelligAm: vehicle.paragraph57a_faellig_am }, art: '§57a',
{ art: 'Wartung', tage: vehicle.wartung_tage_bis_faelligkeit, faelligAm: vehicle.naechste_wartung_am }, fullLabel: '§57a Periodische Prüfung',
tage: vehicle.paragraph57a_tage_bis_faelligkeit,
faelligAm: vehicle.paragraph57a_faellig_am,
},
{
art: 'Wartung',
fullLabel: 'Nächste Wartung / Service',
tage: vehicle.wartung_tage_bis_faelligkeit,
faelligAm: vehicle.naechste_wartung_am,
},
].filter((b) => b.faelligAm !== null); ].filter((b) => b.faelligAm !== null);
return ( return (
@@ -116,7 +132,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
onClick={() => onClick(vehicle.id)} onClick={() => onClick(vehicle.id)}
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
> >
{/* Vehicle image / placeholder */}
{vehicle.bild_url ? ( {vehicle.bild_url ? (
<CardMedia <CardMedia
component="img" component="img"
@@ -140,7 +155,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
)} )}
<CardContent sx={{ flexGrow: 1, pb: '8px !important' }}> <CardContent sx={{ flexGrow: 1, pb: '8px !important' }}>
{/* Title row */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}>
<Box> <Box>
<Typography variant="h6" component="div" lineHeight={1.2}> <Typography variant="h6" component="div" lineHeight={1.2}>
@@ -159,7 +173,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
</Box> </Box>
</Box> </Box>
{/* Status badge */}
<Box sx={{ mb: 1 }}> <Box sx={{ mb: 1 }}>
<Chip <Chip
icon={statusCfg.icon} icon={statusCfg.icon}
@@ -170,7 +183,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
/> />
</Box> </Box>
{/* Crew config */}
{vehicle.besatzung_soll && ( {vehicle.besatzung_soll && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Besatzung: {vehicle.besatzung_soll} Besatzung: {vehicle.besatzung_soll}
@@ -178,7 +190,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
</Typography> </Typography>
)} )}
{/* Inspection badges */}
{inspBadges.length > 0 && ( {inspBadges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{inspBadges.map((b) => { {inspBadges.map((b) => {
@@ -188,11 +199,7 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
return ( return (
<Tooltip <Tooltip
key={b.art} key={b.art}
title={`${PruefungArtLabel[b.art as PruefungArt] ?? b.art}: ${ title={inspTooltipTitle(b.fullLabel, b.tage, b.faelligAm)}
b.tage !== null && b.tage < 0
? `Seit ${Math.abs(b.tage)} Tagen überfällig!`
: `Fällig am ${new Date(b.faelligAm!).toLocaleDateString('de-DE')}`
}`}
> >
<Chip <Chip
size="small" size="small"
@@ -249,16 +256,18 @@ function Fahrzeuge() {
); );
}); });
// Summary counts
const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length; const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length;
// An overdue inspection exists if §57a OR Wartung is past due
const hasOverdue = vehicles.some( const hasOverdue = vehicles.some(
(v) => v.naechste_pruefung_tage !== null && v.naechste_pruefung_tage < 0 (v) =>
(v.paragraph57a_tage_bis_faelligkeit !== null && v.paragraph57a_tage_bis_faelligkeit < 0) ||
(v.wartung_tage_bis_faelligkeit !== null && v.wartung_tage_bis_faelligkeit < 0)
); );
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="xl"> <Container maxWidth="xl">
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box> <Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}> <Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
@@ -268,12 +277,7 @@ function Fahrzeuge() {
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt {vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt
{' · '} {' · '}
<Typography <Typography component="span" variant="body2" color="success.main" fontWeight={600}>
component="span"
variant="body2"
color="success.main"
fontWeight={600}
>
{einsatzbereit} einsatzbereit {einsatzbereit} einsatzbereit
</Typography> </Typography>
</Typography> </Typography>
@@ -281,15 +285,12 @@ function Fahrzeuge() {
</Box> </Box>
</Box> </Box>
{/* Overdue inspection global warning */}
{hasOverdue && ( {hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}> <Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungsfrist. <strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
Betroffene Fahrzeuge dürfen bis zur Durchführung der Prüfung ggf. nicht eingesetzt werden.
</Alert> </Alert>
)} )}
{/* Search bar */}
<TextField <TextField
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)" placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
value={search} value={search}
@@ -306,21 +307,18 @@ function Fahrzeuge() {
}} }}
/> />
{/* Loading state */}
{loading && ( {loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
)} )}
{/* Error state */}
{!loading && error && ( {!loading && error && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }}>
{error} {error}
</Alert> </Alert>
)} )}
{/* Empty state */}
{!loading && !error && filtered.length === 0 && ( {!loading && !error && filtered.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}> <Box sx={{ textAlign: 'center', py: 8 }}>
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} /> <DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
@@ -332,7 +330,6 @@ function Fahrzeuge() {
</Box> </Box>
)} )}
{/* Vehicle grid */}
{!loading && !error && filtered.length > 0 && ( {!loading && !error && filtered.length > 0 && (
<Grid container spacing={3}> <Grid container spacing={3}>
{filtered.map((vehicle) => ( {filtered.map((vehicle) => (
@@ -346,7 +343,6 @@ function Fahrzeuge() {
</Grid> </Grid>
)} )}
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
{isAdmin && ( {isAdmin && (
<Fab <Fab
color="primary" color="primary"

View File

@@ -2,47 +2,31 @@ import { api } from './api';
import type { import type {
FahrzeugListItem, FahrzeugListItem,
FahrzeugDetail, FahrzeugDetail,
FahrzeugPruefung,
FahrzeugWartungslog, FahrzeugWartungslog,
VehicleStats, VehicleStats,
InspectionAlert, InspectionAlert,
CreateFahrzeugPayload, CreateFahrzeugPayload,
UpdateFahrzeugPayload, UpdateFahrzeugPayload,
UpdateStatusPayload, UpdateStatusPayload,
CreatePruefungPayload,
CreateWartungslogPayload, CreateWartungslogPayload,
} from '../types/vehicle.types'; } from '../types/vehicle.types';
// --------------------------------------------------------------------------- async function unwrap<T>(
// Internal: unwrap the standard { success, data } envelope promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>
// --------------------------------------------------------------------------- ): Promise<T> {
async function unwrap<T>(promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>): Promise<T> {
const response = await promise; const response = await promise;
return response.data.data; return response.data.data;
} }
// ---------------------------------------------------------------------------
// Vehicle API Service
// ---------------------------------------------------------------------------
export const vehiclesApi = { export const vehiclesApi = {
// ── Fleet overview ──────────────────────────────────────────────────────────
/** Fetch all vehicles with their next inspection badge data */
async getAll(): Promise<FahrzeugListItem[]> { async getAll(): Promise<FahrzeugListItem[]> {
return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles')); return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles'));
}, },
/** Dashboard KPI stats */
async getStats(): Promise<VehicleStats> { async getStats(): Promise<VehicleStats> {
return unwrap(api.get<{ success: boolean; data: VehicleStats }>('/api/vehicles/stats')); return unwrap(api.get<{ success: boolean; data: VehicleStats }>('/api/vehicles/stats'));
}, },
/**
* Upcoming and overdue inspection alerts.
* @param daysAhead How many days to look ahead (default 30, max 365).
*/
async getAlerts(daysAhead = 30): Promise<InspectionAlert[]> { async getAlerts(daysAhead = 30): Promise<InspectionAlert[]> {
return unwrap( return unwrap(
api.get<{ success: boolean; data: InspectionAlert[] }>( api.get<{ success: boolean; data: InspectionAlert[] }>(
@@ -51,15 +35,10 @@ export const vehiclesApi = {
); );
}, },
// ── Vehicle detail ──────────────────────────────────────────────────────────
/** Full vehicle detail including inspection history and maintenance log */
async getById(id: string): Promise<FahrzeugDetail> { async getById(id: string): Promise<FahrzeugDetail> {
return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`)); return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`));
}, },
// ── CRUD ────────────────────────────────────────────────────────────────────
async create(payload: CreateFahrzeugPayload): Promise<FahrzeugDetail> { async create(payload: CreateFahrzeugPayload): Promise<FahrzeugDetail> {
const response = await api.post<{ success: boolean; data: FahrzeugDetail }>( const response = await api.post<{ success: boolean; data: FahrzeugDetail }>(
'/api/vehicles', '/api/vehicles',
@@ -80,29 +59,10 @@ export const vehiclesApi = {
await api.delete(`/api/vehicles/${id}`); await api.delete(`/api/vehicles/${id}`);
}, },
/** Live status change — Socket.IO event is emitted server-side in Tier 3 */
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> { async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> {
await api.patch(`/api/vehicles/${id}/status`, payload); await api.patch(`/api/vehicles/${id}/status`, payload);
}, },
// ── Inspections ─────────────────────────────────────────────────────────────
async getPruefungen(id: string): Promise<FahrzeugPruefung[]> {
return unwrap(
api.get<{ success: boolean; data: FahrzeugPruefung[] }>(`/api/vehicles/${id}/pruefungen`)
);
},
async addPruefung(id: string, payload: CreatePruefungPayload): Promise<FahrzeugPruefung> {
const response = await api.post<{ success: boolean; data: FahrzeugPruefung }>(
`/api/vehicles/${id}/pruefungen`,
payload
);
return response.data.data;
},
// ── Maintenance log ─────────────────────────────────────────────────────────
async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> { async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> {
return unwrap( return unwrap(
api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`) api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`)

View File

@@ -1,6 +1,5 @@
// ============================================================================= // =============================================================================
// Vehicle Fleet Management — Frontend Type Definitions // Vehicle Fleet Management — Frontend Type Definitions
// Mirror of backend/src/models/vehicle.model.ts (transport layer shapes)
// ============================================================================= // =============================================================================
export enum FahrzeugStatus { export enum FahrzeugStatus {
@@ -17,32 +16,6 @@ export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
[FahrzeugStatus.InLehrgang]: 'In Lehrgang', [FahrzeugStatus.InLehrgang]: 'In Lehrgang',
}; };
export enum PruefungArt {
HU = 'HU',
AU = 'AU',
UVV = 'UVV',
Leiter = 'Leiter',
Kran = 'Kran',
Seilwinde = 'Seilwinde',
Sonstiges = 'Sonstiges',
}
export const PruefungArtLabel: Record<PruefungArt, string> = {
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
[PruefungArt.AU]: 'Abgasuntersuchung',
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
[PruefungArt.Kran]: 'Kranprüfung',
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
};
export type PruefungErgebnis =
| 'bestanden'
| 'bestanden_mit_maengeln'
| 'nicht_bestanden'
| 'ausstehend';
export type WartungslogArt = export type WartungslogArt =
| 'Inspektion' | 'Inspektion'
| 'Reparatur' | 'Reparatur'
@@ -65,14 +38,6 @@ export interface FahrzeugListItem {
status: FahrzeugStatus; status: FahrzeugStatus;
status_bemerkung: string | null; status_bemerkung: string | null;
bild_url: string | null; bild_url: string | null;
hu_faellig_am: string | null; // ISO date string from API
hu_tage_bis_faelligkeit: number | null;
au_faellig_am: string | null;
au_tage_bis_faelligkeit: number | null;
uvv_faellig_am: string | null;
uvv_tage_bis_faelligkeit: number | null;
leiter_faellig_am: string | null;
leiter_tage_bis_faelligkeit: number | 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;
@@ -80,29 +45,6 @@ export interface FahrzeugListItem {
naechste_pruefung_tage: number | null; naechste_pruefung_tage: number | null;
} }
export interface PruefungStatus {
pruefung_id: string | null;
faellig_am: string | null;
tage_bis_faelligkeit: number | null;
ergebnis: PruefungErgebnis | null;
}
export interface FahrzeugPruefung {
id: string;
fahrzeug_id: string;
pruefung_art: PruefungArt;
faellig_am: string;
durchgefuehrt_am: string | null;
ergebnis: PruefungErgebnis | null;
naechste_faelligkeit: string | null;
pruefende_stelle: string | null;
kosten: number | null;
dokument_url: string | null;
bemerkung: string | null;
erfasst_von: string | null;
created_at: string;
}
export interface FahrzeugWartungslog { export interface FahrzeugWartungslog {
id: string; id: string;
fahrzeug_id: string; fahrzeug_id: string;
@@ -137,14 +79,7 @@ export interface FahrzeugDetail {
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;
pruefstatus: { naechste_pruefung_tage: number | null;
hu: PruefungStatus;
au: PruefungStatus;
uvv: PruefungStatus;
leiter: PruefungStatus;
};
naechste_pruefung_tage: number | null;
pruefungen: FahrzeugPruefung[];
wartungslog: FahrzeugWartungslog[]; wartungslog: FahrzeugWartungslog[];
} }
@@ -157,12 +92,13 @@ export interface VehicleStats {
inspectionsOverdue: number; inspectionsOverdue: number;
} }
export type InspectionAlertType = '57a' | 'wartung';
export interface InspectionAlert { export interface InspectionAlert {
fahrzeugId: string; fahrzeugId: string;
bezeichnung: string; bezeichnung: string;
kurzname: string | null; kurzname: string | null;
pruefungId: string; type: InspectionAlertType;
pruefungArt: PruefungArt;
faelligAm: string; faelligAm: string;
tage: number; tage: number;
} }
@@ -186,24 +122,15 @@ export interface CreateFahrzeugPayload {
naechste_wartung_am?: string; naechste_wartung_am?: string;
} }
export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>; export type UpdateFahrzeugPayload = {
[K in keyof CreateFahrzeugPayload]?: CreateFahrzeugPayload[K] | null;
};
export interface UpdateStatusPayload { export interface UpdateStatusPayload {
status: FahrzeugStatus; status: FahrzeugStatus;
bemerkung?: string; bemerkung?: string;
} }
export interface CreatePruefungPayload {
pruefung_art: PruefungArt;
faellig_am: string;
durchgefuehrt_am?: string;
ergebnis?: PruefungErgebnis;
pruefende_stelle?: string;
kosten?: number;
dokument_url?: string;
bemerkung?: string;
}
export interface CreateWartungslogPayload { export interface CreateWartungslogPayload {
datum: string; datum: string;
art?: WartungslogArt; art?: WartungslogArt;