rework vehicle handling
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import vehicleService from '../services/vehicle.service';
|
||||
import { FahrzeugStatus, PruefungArt } from '../models/vehicle.model';
|
||||
import { FahrzeugStatus } from '../models/vehicle.model';
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
const FahrzeugStatusEnum = z.enum([
|
||||
@@ -13,19 +19,9 @@ const FahrzeugStatusEnum = z.enum([
|
||||
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(
|
||||
/^\d{4}-\d{2}-\d{2}$/,
|
||||
'Expected ISO date format YYYY-MM-DD'
|
||||
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
||||
'Erwartet ISO-Datum im Format YYYY-MM-DD'
|
||||
);
|
||||
|
||||
const CreateFahrzeugSchema = z.object({
|
||||
@@ -39,30 +35,40 @@ const CreateFahrzeugSchema = z.object({
|
||||
besatzung_soll: z.string().max(10).optional(),
|
||||
status: FahrzeugStatusEnum.optional(),
|
||||
status_bemerkung: z.string().max(500).optional(),
|
||||
standort: z.string().max(100).optional(),
|
||||
bild_url: z.string().url().max(500).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'
|
||||
).optional(),
|
||||
paragraph57a_faellig_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({
|
||||
status: FahrzeugStatusEnum,
|
||||
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({
|
||||
datum: isoDate,
|
||||
art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(),
|
||||
@@ -76,17 +82,12 @@ const CreateWartungslogSchema = z.object({
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getUserId(req: Request): string {
|
||||
// req.user is guaranteed by the authenticate middleware
|
||||
return req.user!.id;
|
||||
}
|
||||
|
||||
// ── Controller ────────────────────────────────────────────────────────────────
|
||||
|
||||
class VehicleController {
|
||||
/**
|
||||
* GET /api/vehicles
|
||||
* Fleet overview list with per-vehicle inspection badge data.
|
||||
*/
|
||||
async listVehicles(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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> {
|
||||
try {
|
||||
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> {
|
||||
try {
|
||||
const daysAhead = Math.min(
|
||||
parseInt((req.query.daysAhead as string) || '30', 10),
|
||||
365 // hard cap — never expose more than 1 year of lookahead
|
||||
);
|
||||
|
||||
if (isNaN(daysAhead) || daysAhead < 0) {
|
||||
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
|
||||
if (isNaN(raw) || raw < 0) {
|
||||
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
|
||||
return;
|
||||
}
|
||||
|
||||
const daysAhead = Math.min(raw, 365);
|
||||
const alerts = await vehicleService.getUpcomingInspections(daysAhead);
|
||||
res.status(200).json({ success: true, data: alerts });
|
||||
} 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> {
|
||||
try {
|
||||
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);
|
||||
|
||||
if (!vehicle) {
|
||||
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: vehicle });
|
||||
} catch (error) {
|
||||
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> {
|
||||
try {
|
||||
const parsed = CreateFahrzeugSchema.safeParse(req.body);
|
||||
@@ -172,7 +154,6 @@ class VehicleController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
|
||||
res.status(201).json({ success: true, data: vehicle });
|
||||
} 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> {
|
||||
try {
|
||||
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);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
@@ -197,7 +178,10 @@ class VehicleController {
|
||||
});
|
||||
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));
|
||||
res.status(200).json({ success: true, data: vehicle });
|
||||
} 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> {
|
||||
try {
|
||||
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);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -232,19 +210,10 @@ class VehicleController {
|
||||
});
|
||||
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;
|
||||
|
||||
await vehicleService.updateVehicleStatus(
|
||||
id,
|
||||
parsed.data.status,
|
||||
parsed.data.bemerkung,
|
||||
getUserId(req),
|
||||
io
|
||||
id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io
|
||||
);
|
||||
|
||||
res.status(200).json({ success: true, message: 'Status aktualisiert' });
|
||||
} catch (error: any) {
|
||||
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> {
|
||||
try {
|
||||
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);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -318,24 +241,27 @@ class VehicleController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
|
||||
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 });
|
||||
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> {
|
||||
try {
|
||||
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));
|
||||
res.status(200).json({ success: true, message: 'Fahrzeug gelöscht' });
|
||||
res.status(204).send();
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Vehicle not found') {
|
||||
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> {
|
||||
try {
|
||||
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);
|
||||
res.status(200).json({ success: true, data: entries });
|
||||
} catch (error) {
|
||||
|
||||
53
backend/src/database/migrations/008_simplify_inspections.sql
Normal file
53
backend/src/database/migrations/008_simplify_inspections.sql
Normal 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;
|
||||
57
backend/src/database/migrations/009_vehicle_soft_delete.sql
Normal file
57
backend/src/database/migrations/009_vehicle_soft_delete.sql
Normal 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;
|
||||
@@ -4,10 +4,6 @@
|
||||
|
||||
// ── Enums ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Operational status of a vehicle.
|
||||
* These values are the CHECK constraint values in the database.
|
||||
*/
|
||||
export enum FahrzeugStatus {
|
||||
Einsatzbereit = 'einsatzbereit',
|
||||
AusserDienstWartung = 'ausser_dienst_wartung',
|
||||
@@ -15,7 +11,6 @@ export enum FahrzeugStatus {
|
||||
InLehrgang = 'in_lehrgang',
|
||||
}
|
||||
|
||||
/** Human-readable German labels for each status value */
|
||||
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
||||
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
|
||||
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
|
||||
@@ -23,53 +18,6 @@ export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
||||
[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 =
|
||||
| 'Inspektion'
|
||||
| 'Reparatur'
|
||||
@@ -81,50 +29,29 @@ export type WartungslogArt =
|
||||
|
||||
// ── Core Entities ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Raw database row from the `fahrzeuge` table */
|
||||
export interface Fahrzeug {
|
||||
id: string; // UUID
|
||||
bezeichnung: string; // e.g. "LF 20/16"
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
kurzname: string | null;
|
||||
amtliches_kennzeichen: string | null;
|
||||
fahrgestellnummer: string | null;
|
||||
baujahr: number | null;
|
||||
hersteller: string | null;
|
||||
typ_schluessel: string | null;
|
||||
besatzung_soll: string | null; // e.g. "1/8"
|
||||
besatzung_soll: string | null;
|
||||
status: FahrzeugStatus;
|
||||
status_bemerkung: string | null;
|
||||
standort: string;
|
||||
bild_url: string | null;
|
||||
/** §57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV) */
|
||||
paragraph57a_faellig_am: Date | null;
|
||||
/** Next scheduled service / maintenance due date */
|
||||
naechste_wartung_am: Date | null;
|
||||
created_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 {
|
||||
id: string; // UUID
|
||||
fahrzeug_id: string; // UUID FK
|
||||
id: string;
|
||||
fahrzeug_id: string;
|
||||
datum: Date;
|
||||
art: WartungslogArt | null;
|
||||
beschreibung: string;
|
||||
@@ -132,49 +59,12 @@ export interface FahrzeugWartungslog {
|
||||
kraftstoff_liter: number | null;
|
||||
kosten: number | null;
|
||||
externe_werkstatt: string | null;
|
||||
erfasst_von: string | null; // UUID FK users
|
||||
erfasst_von: string | null;
|
||||
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) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lightweight type used in the vehicle fleet overview grid.
|
||||
* Includes only the fields needed to render a card plus inspection badges.
|
||||
*/
|
||||
export interface FahrzeugListItem {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
@@ -186,47 +76,44 @@ export interface FahrzeugListItem {
|
||||
status: FahrzeugStatus;
|
||||
status_bemerkung: string | null;
|
||||
bild_url: string | null;
|
||||
/** §57a due date (primary inspection badge) */
|
||||
paragraph57a_faellig_am: Date | null;
|
||||
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||
/** Next service due date */
|
||||
naechste_wartung_am: Date | 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;
|
||||
}
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Aggregated vehicle stats for the dashboard KPI strip */
|
||||
export interface VehicleStats {
|
||||
total: number;
|
||||
einsatzbereit: number;
|
||||
ausserDienst: number; // wartung + schaden combined
|
||||
ausserDienst: number;
|
||||
inLehrgang: number;
|
||||
inspectionsDue: number; // vehicles with any inspection due within 30 days
|
||||
inspectionsOverdue: number; // vehicles with any inspection already overdue
|
||||
inspectionsDue: number;
|
||||
inspectionsOverdue: number;
|
||||
}
|
||||
|
||||
// ── Inspection Alert ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Single alert item for the dashboard InspectionAlerts component */
|
||||
export type InspectionAlertType = '57a' | 'wartung';
|
||||
|
||||
export interface InspectionAlert {
|
||||
fahrzeugId: string;
|
||||
bezeichnung: string;
|
||||
kurzname: string | null;
|
||||
pruefungId: string;
|
||||
pruefungArt: PruefungArt;
|
||||
type: InspectionAlertType;
|
||||
faelligAm: Date;
|
||||
tage: number; // negative = already overdue
|
||||
tage: number;
|
||||
}
|
||||
|
||||
// ── Create / Update DTOs ──────────────────────────────────────────────────────
|
||||
@@ -244,8 +131,8 @@ export interface CreateFahrzeugData {
|
||||
status_bemerkung?: string;
|
||||
standort?: string;
|
||||
bild_url?: string;
|
||||
paragraph57a_faellig_am?: string; // ISO date 'YYYY-MM-DD'
|
||||
naechste_wartung_am?: string; // ISO date 'YYYY-MM-DD'
|
||||
paragraph57a_faellig_am?: string;
|
||||
naechste_wartung_am?: string;
|
||||
}
|
||||
|
||||
export interface UpdateFahrzeugData {
|
||||
@@ -261,24 +148,12 @@ export interface UpdateFahrzeugData {
|
||||
status_bemerkung?: string | null;
|
||||
standort?: string;
|
||||
bild_url?: string | null;
|
||||
paragraph57a_faellig_am?: string | null; // ISO date 'YYYY-MM-DD'
|
||||
naechste_wartung_am?: string | null; // ISO date 'YYYY-MM-DD'
|
||||
}
|
||||
|
||||
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
|
||||
paragraph57a_faellig_am?: string | null;
|
||||
naechste_wartung_am?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateWartungslogData {
|
||||
datum: string; // ISO date string 'YYYY-MM-DD'
|
||||
datum: string;
|
||||
art?: WartungslogArt;
|
||||
beschreibung: string;
|
||||
km_stand?: number;
|
||||
|
||||
@@ -4,117 +4,27 @@ import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requireGroups } from '../middleware/rbac.middleware';
|
||||
|
||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
||||
const STATUS_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Read-only endpoints (any authenticated user) ──────────────────────────────
|
||||
// ── Read-only (any authenticated user) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/vehicles
|
||||
* 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));
|
||||
|
||||
/**
|
||||
* 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));
|
||||
|
||||
// ── Write endpoints (dashboard_admin group required) ────────────────────────
|
||||
// ── Write — admin only ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/vehicles
|
||||
* Create a new vehicle.
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
requireGroups(ADMIN_GROUPS),
|
||||
vehicleController.createVehicle.bind(vehicleController)
|
||||
);
|
||||
router.post('/', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController));
|
||||
router.patch('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController));
|
||||
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.deleteVehicle.bind(vehicleController));
|
||||
|
||||
/**
|
||||
* PATCH /api/vehicles/:id
|
||||
* Update vehicle fields.
|
||||
*/
|
||||
router.patch(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requireGroups(ADMIN_GROUPS),
|
||||
vehicleController.updateVehicle.bind(vehicleController)
|
||||
);
|
||||
// ── Status + maintenance log — admin + fahrmeister ────────────────────────────
|
||||
|
||||
/**
|
||||
* PATCH /api/vehicles/:id/status
|
||||
* 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)
|
||||
);
|
||||
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), vehicleController.addWartung.bind(vehicleController));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,79 +3,29 @@ import logger from '../utils/logger';
|
||||
import {
|
||||
Fahrzeug,
|
||||
FahrzeugListItem,
|
||||
FahrzeugWithPruefstatus,
|
||||
FahrzeugPruefung,
|
||||
FahrzeugDetail,
|
||||
FahrzeugWartungslog,
|
||||
CreateFahrzeugData,
|
||||
UpdateFahrzeugData,
|
||||
CreatePruefungData,
|
||||
CreateWartungslogData,
|
||||
FahrzeugStatus,
|
||||
PruefungArt,
|
||||
PruefungIntervalMonths,
|
||||
VehicleStats,
|
||||
InspectionAlert,
|
||||
} 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 {
|
||||
// =========================================================================
|
||||
// FLEET OVERVIEW
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Returns all vehicles with their next-due inspection dates per type.
|
||||
* Used by the fleet overview grid (FahrzeugListItem[]).
|
||||
*/
|
||||
async getAllVehicles(): Promise<FahrzeugListItem[]> {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
bezeichnung,
|
||||
kurzname,
|
||||
amtliches_kennzeichen,
|
||||
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
|
||||
id, bezeichnung, kurzname, amtliches_kennzeichen,
|
||||
baujahr, hersteller, besatzung_soll, status, status_bemerkung,
|
||||
bild_url, paragraph57a_faellig_am, paragraph57a_tage_bis_faelligkeit,
|
||||
naechste_wartung_am, wartung_tage_bis_faelligkeit, naechste_pruefung_tage
|
||||
FROM fahrzeuge_mit_pruefstatus
|
||||
ORDER BY bezeichnung ASC
|
||||
`);
|
||||
@@ -86,14 +36,6 @@ class VehicleService {
|
||||
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
||||
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
||||
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
||||
hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != 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,
|
||||
})) as FahrzeugListItem[];
|
||||
@@ -107,13 +49,8 @@ class VehicleService {
|
||||
// VEHICLE DETAIL
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Returns a single vehicle with full pruefstatus, inspection history,
|
||||
* and maintenance log.
|
||||
*/
|
||||
async getVehicleById(id: string): Promise<FahrzeugWithPruefstatus | null> {
|
||||
async getVehicleById(id: string): Promise<FahrzeugDetail | null> {
|
||||
try {
|
||||
// 1) Main record + inspection status from view
|
||||
const vehicleResult = await pool.query(
|
||||
`SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`,
|
||||
[id]
|
||||
@@ -123,15 +60,6 @@ class VehicleService {
|
||||
|
||||
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(
|
||||
`SELECT * FROM fahrzeug_wartungslog
|
||||
WHERE fahrzeug_id = $1
|
||||
@@ -139,7 +67,7 @@ class VehicleService {
|
||||
[id]
|
||||
);
|
||||
|
||||
const vehicle: FahrzeugWithPruefstatus = {
|
||||
const vehicle: FahrzeugDetail = {
|
||||
id: row.id,
|
||||
bezeichnung: row.bezeichnung,
|
||||
kurzname: row.kurzname,
|
||||
@@ -157,20 +85,16 @@ class VehicleService {
|
||||
naechste_wartung_am: row.naechste_wartung_am ?? null,
|
||||
created_at: row.created_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
|
||||
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
||||
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
||||
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
||||
naechste_pruefung_tage: row.naechste_pruefung_tage != null
|
||||
? parseInt(row.naechste_pruefung_tage, 10) : null,
|
||||
pruefungen: pruefungenResult.rows as FahrzeugPruefung[],
|
||||
wartungslog: wartungslogResult.rows as FahrzeugWartungslog[],
|
||||
wartungslog: wartungslogResult.rows.map(r => ({
|
||||
...r,
|
||||
kosten: r.kosten != null ? Number(r.kosten) : null,
|
||||
})) as FahrzeugWartungslog[],
|
||||
};
|
||||
|
||||
return vehicle;
|
||||
@@ -184,10 +108,7 @@ class VehicleService {
|
||||
// CRUD
|
||||
// =========================================================================
|
||||
|
||||
async createVehicle(
|
||||
data: CreateFahrzeugData,
|
||||
createdBy: string
|
||||
): Promise<Fahrzeug> {
|
||||
async createVehicle(data: CreateFahrzeugData, createdBy: string): Promise<Fahrzeug> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO fahrzeuge (
|
||||
@@ -224,11 +145,7 @@ class VehicleService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateVehicle(
|
||||
id: string,
|
||||
data: UpdateFahrzeugData,
|
||||
updatedBy: string
|
||||
): Promise<Fahrzeug> {
|
||||
async updateVehicle(id: string, data: UpdateFahrzeugData, updatedBy: string): Promise<Fahrzeug> {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
@@ -258,9 +175,9 @@ class VehicleService {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(id); // for WHERE clause
|
||||
values.push(id);
|
||||
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
|
||||
);
|
||||
|
||||
@@ -280,7 +197,10 @@ class VehicleService {
|
||||
async deleteVehicle(id: string, deletedBy: string): Promise<void> {
|
||||
try {
|
||||
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]
|
||||
);
|
||||
|
||||
@@ -288,7 +208,7 @@ class VehicleService {
|
||||
throw new Error('Vehicle not found');
|
||||
}
|
||||
|
||||
logger.info('Vehicle deleted', { id, by: deletedBy });
|
||||
logger.info('Vehicle soft-deleted', { id, by: deletedBy });
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.deleteVehicle failed', { error, id });
|
||||
throw error;
|
||||
@@ -297,22 +217,8 @@ class VehicleService {
|
||||
|
||||
// =========================================================================
|
||||
// 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(
|
||||
id: string,
|
||||
status: FahrzeugStatus,
|
||||
@@ -320,38 +226,33 @@ class VehicleService {
|
||||
updatedBy: string,
|
||||
io?: any
|
||||
): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Fetch old status for Socket.IO payload and logging
|
||||
const oldResult = await pool.query(
|
||||
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1`,
|
||||
await client.query('BEGIN');
|
||||
|
||||
const oldResult = await client.query(
|
||||
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (oldResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
throw new Error('Vehicle not found');
|
||||
}
|
||||
|
||||
const { bezeichnung, status: oldStatus } = oldResult.rows[0];
|
||||
|
||||
await pool.query(
|
||||
`UPDATE fahrzeuge
|
||||
SET status = $1, status_bemerkung = $2
|
||||
WHERE id = $3`,
|
||||
await client.query(
|
||||
`UPDATE fahrzeuge SET status = $1, status_bemerkung = $2 WHERE id = $3`,
|
||||
[status, bemerkung || null, id]
|
||||
);
|
||||
|
||||
logger.info('Vehicle status updated', {
|
||||
id,
|
||||
from: oldStatus,
|
||||
to: status,
|
||||
by: updatedBy,
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info('Vehicle status updated', { id, from: oldStatus, 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) {
|
||||
const payload = {
|
||||
io.emit('vehicle:statusChanged', {
|
||||
vehicleId: id,
|
||||
bezeichnung,
|
||||
oldStatus,
|
||||
@@ -359,143 +260,14 @@ class VehicleService {
|
||||
bemerkung: bemerkung || null,
|
||||
updatedBy,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
io.emit('vehicle:statusChanged', payload);
|
||||
logger.debug('Emitted vehicle:statusChanged via Socket.IO', { vehicleId: id });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
logger.error('VehicleService.updateVehicleStatus failed', { error, id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +281,14 @@ class VehicleService {
|
||||
createdBy: string
|
||||
): Promise<FahrzeugWartungslog> {
|
||||
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(
|
||||
`INSERT INTO fahrzeug_wartungslog (
|
||||
fahrzeug_id, datum, art, beschreibung,
|
||||
@@ -529,15 +309,11 @@ class VehicleService {
|
||||
);
|
||||
|
||||
const entry = result.rows[0] as FahrzeugWartungslog;
|
||||
logger.info('Wartungslog entry added', {
|
||||
entryId: entry.id,
|
||||
fahrzeugId,
|
||||
by: createdBy,
|
||||
});
|
||||
logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy });
|
||||
return entry;
|
||||
} catch (error) {
|
||||
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
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
const totalsResult = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
|
||||
@@ -579,21 +350,30 @@ class VehicleService {
|
||||
) AS ausser_dienst,
|
||||
COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang
|
||||
FROM fahrzeuge
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
const alertResult = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT fahrzeug_id) FILTER (
|
||||
WHERE (faellig_am::date - CURRENT_DATE) BETWEEN 0 AND 30
|
||||
COUNT(*) FILTER (
|
||||
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,
|
||||
COUNT(DISTINCT fahrzeug_id) FILTER (
|
||||
WHERE faellig_am::date < CURRENT_DATE
|
||||
COUNT(*) FILTER (
|
||||
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
|
||||
FROM fahrzeug_pruefungen
|
||||
WHERE durchgefuehrt_am IS NULL
|
||||
FROM fahrzeuge
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
const totals = result.rows[0];
|
||||
const totals = totalsResult.rows[0];
|
||||
const alerts = alertResult.rows[0];
|
||||
|
||||
return {
|
||||
@@ -609,6 +389,73 @@ class VehicleService {
|
||||
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();
|
||||
|
||||
@@ -12,6 +12,7 @@ import Einsaetze from './pages/Einsaetze';
|
||||
import EinsatzDetail from './pages/EinsatzDetail';
|
||||
import Fahrzeuge from './pages/Fahrzeuge';
|
||||
import FahrzeugDetail from './pages/FahrzeugDetail';
|
||||
import FahrzeugForm from './pages/FahrzeugForm';
|
||||
import Ausruestung from './pages/Ausruestung';
|
||||
import Mitglieder from './pages/Mitglieder';
|
||||
import MitgliedDetail from './pages/MitgliedDetail';
|
||||
@@ -76,6 +77,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fahrzeuge/neu"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FahrzeugForm />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fahrzeuge/:id/bearbeiten"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FahrzeugForm />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fahrzeuge/:id"
|
||||
element={
|
||||
|
||||
@@ -4,15 +4,12 @@ import {
|
||||
AlertTitle,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Link,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { vehiclesApi } from '../../services/vehicles';
|
||||
import { InspectionAlert, PruefungArtLabel, PruefungArt } from '../../types/vehicle.types';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
import { InspectionAlert, InspectionAlertType } from '../../types/vehicle.types';
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
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';
|
||||
|
||||
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)' },
|
||||
};
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InspectionAlertsProps {
|
||||
/** How many days ahead to fetch — default 30 */
|
||||
daysAhead?: number;
|
||||
/** Collapse into a single banner if no alerts */
|
||||
hideWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
@@ -55,7 +52,6 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -68,7 +64,6 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlerts();
|
||||
return () => { mounted = false; };
|
||||
}, [daysAhead]);
|
||||
@@ -92,12 +87,11 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
||||
if (hideWhenEmpty) return null;
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by urgency
|
||||
const overdue = alerts.filter((a) => a.tage < 0);
|
||||
const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14);
|
||||
const warning = alerts.filter((a) => a.tage > 14);
|
||||
@@ -117,7 +111,7 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
||||
<AlertTitle sx={{ fontWeight: 600 }}>{label}</AlertTitle>
|
||||
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
||||
{items.map((alert) => {
|
||||
const artLabel = PruefungArtLabel[alert.pruefungArt as PruefungArt] ?? alert.pruefungArt;
|
||||
const typeLabel = alertTypeLabel(alert.type);
|
||||
const dateStr = formatDate(alert.faelligAm);
|
||||
const tageText = alert.tage < 0
|
||||
? `seit ${Math.abs(alert.tage)} Tag${Math.abs(alert.tage) === 1 ? '' : 'en'} überfällig`
|
||||
@@ -126,8 +120,11 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
||||
: `fällig in ${alert.tage} Tag${alert.tage === 1 ? '' : 'en'}`;
|
||||
|
||||
return (
|
||||
<Collapse key={alert.pruefungId} in timeout="auto">
|
||||
<Box component="li" sx={{ mb: 0.5 }}>
|
||||
<Box
|
||||
key={`${alert.fahrzeugId}-${alert.type}`}
|
||||
component="li"
|
||||
sx={{ mb: 0.5 }}
|
||||
>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/fahrzeuge/${alert.fahrzeugId}`}
|
||||
@@ -139,13 +136,12 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
||||
{alert.kurzname ? ` (${alert.kurzname})` : ''}
|
||||
</Link>
|
||||
{' — '}
|
||||
<strong>{artLabel}</strong>
|
||||
<strong>{typeLabel}</strong>
|
||||
{' '}
|
||||
<Typography component="span" variant="body2">
|
||||
{tageText} ({dateStr})
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
@@ -22,13 +22,6 @@ import {
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Timeline,
|
||||
TimelineConnector,
|
||||
TimelineContent,
|
||||
TimelineDot,
|
||||
TimelineItem,
|
||||
TimelineOppositeContent,
|
||||
TimelineSeparator,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
@@ -39,6 +32,7 @@ import {
|
||||
Build,
|
||||
CheckCircle,
|
||||
DirectionsCar,
|
||||
Edit,
|
||||
Error as ErrorIcon,
|
||||
LocalFireDepartment,
|
||||
PauseCircle,
|
||||
@@ -51,16 +45,12 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import {
|
||||
FahrzeugDetail,
|
||||
FahrzeugPruefung,
|
||||
FahrzeugWartungslog,
|
||||
FahrzeugStatus,
|
||||
FahrzeugStatusLabel,
|
||||
PruefungArt,
|
||||
PruefungArtLabel,
|
||||
CreatePruefungPayload,
|
||||
CreateWartungslogPayload,
|
||||
UpdateStatusPayload,
|
||||
WartungslogArt,
|
||||
PruefungErgebnis,
|
||||
} from '../types/vehicle.types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
@@ -125,11 +115,24 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
const [saving, setSaving] = useState(false);
|
||||
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 () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
await vehiclesApi.updateStatus(vehicle.id, { status: newStatus, bemerkung });
|
||||
const payload: UpdateStatusPayload = { status: newStatus, bemerkung };
|
||||
await vehiclesApi.updateStatus(vehicle.id, payload);
|
||||
setStatusDialogOpen(false);
|
||||
onStatusUpdated();
|
||||
} catch {
|
||||
@@ -141,6 +144,12 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
{isSchaden && (
|
||||
@@ -156,9 +165,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{STATUS_ICONS[vehicle.status]}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Aktueller Status
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
|
||||
<Chip
|
||||
label={FahrzeugStatusLabel[vehicle.status]}
|
||||
color={STATUS_CHIP_COLOR[vehicle.status]}
|
||||
@@ -171,18 +178,11 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setNewStatus(vehicle.status);
|
||||
setBemerkung(vehicle.status_bemerkung ?? '');
|
||||
setStatusDialogOpen(true);
|
||||
}}
|
||||
sx={{ display: canChangeStatus ? undefined : 'none' }}
|
||||
>
|
||||
{canChangeStatus && (
|
||||
<Button variant="outlined" size="small" onClick={openDialog}>
|
||||
Status ändern
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
@@ -198,8 +198,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
|
||||
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
|
||||
{ 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 }) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={label}>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
||||
@@ -210,48 +208,40 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Inspection status quick view */}
|
||||
{/* Inspection deadline quick view */}
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
|
||||
Prüffristen Übersicht
|
||||
Prüf- und Wartungsfristen
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
{Object.entries(vehicle.pruefstatus).map(([key, ps]) => {
|
||||
const art = key.toUpperCase() as PruefungArt;
|
||||
const label = PruefungArtLabel[art] ?? key;
|
||||
const color = inspectionBadgeColor(ps.tage_bis_faelligkeit);
|
||||
{inspItems.map(({ label, faelligAm, tage }) => {
|
||||
const color = inspectionBadgeColor(tage);
|
||||
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 }}>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
||||
{label}
|
||||
</Typography>
|
||||
{ps.faellig_am ? (
|
||||
{faelligAm ? (
|
||||
<>
|
||||
<Chip
|
||||
size="small"
|
||||
color={color}
|
||||
label={
|
||||
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
|
||||
? `ÜBERFÄLLIG (${fmtDate(ps.faellig_am)})`
|
||||
: `Fällig: ${fmtDate(ps.faellig_am)}`
|
||||
}
|
||||
icon={
|
||||
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
|
||||
? <Warning fontSize="small" />
|
||||
: undefined
|
||||
tage !== null && tage < 0
|
||||
? `ÜBERFÄLLIG (${fmtDate(faelligAm)})`
|
||||
: `Fällig: ${fmtDate(faelligAm)}`
|
||||
}
|
||||
icon={tage !== null && tage < 0 ? <Warning fontSize="small" /> : undefined}
|
||||
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">
|
||||
in {ps.tage_bis_faelligkeit} Tagen
|
||||
in {tage} Tagen
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
Keine Daten
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.disabled">Kein Datum erfasst</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
@@ -260,12 +250,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
</Grid>
|
||||
|
||||
{/* Status change dialog */}
|
||||
<Dialog
|
||||
open={statusDialogOpen}
|
||||
onClose={() => setStatusDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Fahrzeugstatus ändern</DialogTitle>
|
||||
<DialogContent>
|
||||
{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)}
|
||||
>
|
||||
{Object.values(FahrzeugStatus).map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{FahrzeugStatusLabel[s]}
|
||||
</MenuItem>
|
||||
<MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -295,7 +278,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setStatusDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={closeDialog}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface WartungTabProps {
|
||||
@@ -564,7 +306,7 @@ const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
|
||||
Kraftstoff: <LocalFireDepartment color="action" />,
|
||||
Reparatur: <Build color="warning" />,
|
||||
Inspektion: <Assignment color="primary" />,
|
||||
Hauptuntersuchung:<CheckCircle color="success" />,
|
||||
Hauptuntersuchung: <CheckCircle color="success" />,
|
||||
default: <Build color="action" />,
|
||||
};
|
||||
|
||||
@@ -612,8 +354,6 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
||||
{wartungslog.length === 0 ? (
|
||||
<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}>
|
||||
{wartungslog.map((entry) => {
|
||||
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={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
||||
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
|
||||
{entry.art && (
|
||||
<Chip label={entry.art} size="small" variant="outlined" />
|
||||
)}
|
||||
{entry.art && <Chip label={entry.art} size="small" variant="outlined" />}
|
||||
</Box>
|
||||
<Typography variant="body2">{entry.beschreibung}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||
@@ -795,11 +533,7 @@ function FahrzeugDetail() {
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Alert severity="error">{error ?? 'Fahrzeug nicht gefunden.'}</Alert>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/fahrzeuge')}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
|
||||
Zurück zur Übersicht
|
||||
</Button>
|
||||
</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 (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
{/* Breadcrumb / back */}
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/fahrzeuge')}
|
||||
@@ -820,7 +557,6 @@ function FahrzeugDetail() {
|
||||
Fahrzeugübersicht
|
||||
</Button>
|
||||
|
||||
{/* Page title */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
|
||||
<Box>
|
||||
@@ -839,16 +575,26 @@ function FahrzeugDetail() {
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
icon={STATUS_ICONS[vehicle.status]}
|
||||
label={FahrzeugStatusLabel[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>
|
||||
|
||||
{/* Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -858,42 +604,35 @@ function FahrzeugDetail() {
|
||||
<Tab label="Übersicht" />
|
||||
<Tab
|
||||
label={
|
||||
vehicle.naechste_pruefung_tage !== null && vehicle.naechste_pruefung_tage < 0
|
||||
hasOverdue
|
||||
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
Prüfungen <Warning color="error" fontSize="small" />
|
||||
Wartung <Warning color="error" fontSize="small" />
|
||||
</Box>
|
||||
: 'Prüfungen'
|
||||
: 'Wartung'
|
||||
}
|
||||
/>
|
||||
<Tab label="Wartung" />
|
||||
<Tab label="Einsätze" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Tab content */}
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} canChangeStatus={canChangeStatus} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<PruefungenTab
|
||||
fahrzeugId={vehicle.id}
|
||||
pruefungen={vehicle.pruefungen}
|
||||
onAdded={fetchVehicle}
|
||||
canWrite={isAdmin}
|
||||
<UebersichtTab
|
||||
vehicle={vehicle}
|
||||
onStatusUpdated={fetchVehicle}
|
||||
canChangeStatus={canChangeStatus}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<WartungTab
|
||||
fahrzeugId={vehicle.id}
|
||||
wartungslog={vehicle.wartungslog}
|
||||
onAdded={fetchVehicle}
|
||||
canWrite={isAdmin}
|
||||
canWrite={canChangeStatus}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
|
||||
400
frontend/src/pages/FahrzeugForm.tsx
Normal file
400
frontend/src/pages/FahrzeugForm.tsx
Normal 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;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardActionArea,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
@@ -35,8 +35,6 @@ import {
|
||||
FahrzeugListItem,
|
||||
FahrzeugStatus,
|
||||
FahrzeugStatusLabel,
|
||||
PruefungArt,
|
||||
PruefungArtLabel,
|
||||
} from '../types/vehicle.types';
|
||||
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 {
|
||||
const artShort = art; // 'HU', 'AU', etc.
|
||||
if (faelligAm === null) return '';
|
||||
const date = new Date(faelligAm).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||
if (tage === null) return `${artShort}: ${date}`;
|
||||
if (tage < 0) return `${artShort}: ÜBERFÄLLIG (${date})`;
|
||||
if (tage === 0) return `${artShort}: heute (${date})`;
|
||||
return `${artShort}: ${date}`;
|
||||
const date = new Date(faelligAm).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
});
|
||||
if (tage === null) return `${art}: ${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 ──────────────────────────────────────────────────────────────
|
||||
@@ -83,13 +91,21 @@ interface VehicleCardProps {
|
||||
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
const status = vehicle.status as FahrzeugStatus;
|
||||
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
|
||||
|
||||
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
|
||||
|
||||
// Collect inspection badges (only for types where a faellig_am exists)
|
||||
const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [
|
||||
{ art: '§57a', tage: vehicle.paragraph57a_tage_bis_faelligkeit, faelligAm: vehicle.paragraph57a_faellig_am },
|
||||
{ art: 'Wartung', tage: vehicle.wartung_tage_bis_faelligkeit, faelligAm: vehicle.naechste_wartung_am },
|
||||
const inspBadges = [
|
||||
{
|
||||
art: '§57a',
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -116,7 +132,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
onClick={() => onClick(vehicle.id)}
|
||||
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
{/* Vehicle image / placeholder */}
|
||||
{vehicle.bild_url ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
@@ -140,7 +155,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
)}
|
||||
|
||||
<CardContent sx={{ flexGrow: 1, pb: '8px !important' }}>
|
||||
{/* Title row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" component="div" lineHeight={1.2}>
|
||||
@@ -159,7 +173,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Status badge */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Chip
|
||||
icon={statusCfg.icon}
|
||||
@@ -170,7 +183,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Crew config */}
|
||||
{vehicle.besatzung_soll && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Besatzung: {vehicle.besatzung_soll}
|
||||
@@ -178,7 +190,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Inspection badges */}
|
||||
{inspBadges.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{inspBadges.map((b) => {
|
||||
@@ -188,11 +199,7 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||
return (
|
||||
<Tooltip
|
||||
key={b.art}
|
||||
title={`${PruefungArtLabel[b.art as PruefungArt] ?? b.art}: ${
|
||||
b.tage !== null && b.tage < 0
|
||||
? `Seit ${Math.abs(b.tage)} Tagen überfällig!`
|
||||
: `Fällig am ${new Date(b.faelligAm!).toLocaleDateString('de-DE')}`
|
||||
}`}
|
||||
title={inspTooltipTitle(b.fullLabel, b.tage, b.faelligAm)}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
@@ -249,16 +256,18 @@ function Fahrzeuge() {
|
||||
);
|
||||
});
|
||||
|
||||
// Summary counts
|
||||
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(
|
||||
(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 (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="xl">
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||
@@ -268,12 +277,7 @@ function Fahrzeuge() {
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt
|
||||
{' · '}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="success.main"
|
||||
fontWeight={600}
|
||||
>
|
||||
<Typography component="span" variant="body2" color="success.main" fontWeight={600}>
|
||||
{einsatzbereit} einsatzbereit
|
||||
</Typography>
|
||||
</Typography>
|
||||
@@ -281,15 +285,12 @@ function Fahrzeuge() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Overdue inspection global warning */}
|
||||
{hasOverdue && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
||||
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungsfrist.
|
||||
Betroffene Fahrzeuge dürfen bis zur Durchführung der Prüfung ggf. nicht eingesetzt werden.
|
||||
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<TextField
|
||||
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
|
||||
value={search}
|
||||
@@ -306,21 +307,18 @@ function Fahrzeuge() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!loading && error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||
@@ -332,7 +330,6 @@ function Fahrzeuge() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Vehicle grid */}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<Grid container spacing={3}>
|
||||
{filtered.map((vehicle) => (
|
||||
@@ -346,7 +343,6 @@ function Fahrzeuge() {
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
|
||||
{isAdmin && (
|
||||
<Fab
|
||||
color="primary"
|
||||
|
||||
@@ -2,47 +2,31 @@ import { api } from './api';
|
||||
import type {
|
||||
FahrzeugListItem,
|
||||
FahrzeugDetail,
|
||||
FahrzeugPruefung,
|
||||
FahrzeugWartungslog,
|
||||
VehicleStats,
|
||||
InspectionAlert,
|
||||
CreateFahrzeugPayload,
|
||||
UpdateFahrzeugPayload,
|
||||
UpdateStatusPayload,
|
||||
CreatePruefungPayload,
|
||||
CreateWartungslogPayload,
|
||||
} from '../types/vehicle.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: unwrap the standard { success, data } envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
async function unwrap<T>(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;
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vehicle API Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const vehiclesApi = {
|
||||
|
||||
// ── Fleet overview ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch all vehicles with their next inspection badge data */
|
||||
async getAll(): Promise<FahrzeugListItem[]> {
|
||||
return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles'));
|
||||
},
|
||||
|
||||
/** Dashboard KPI stats */
|
||||
async getStats(): Promise<VehicleStats> {
|
||||
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[]> {
|
||||
return unwrap(
|
||||
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> {
|
||||
return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`));
|
||||
},
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async create(payload: CreateFahrzeugPayload): Promise<FahrzeugDetail> {
|
||||
const response = await api.post<{ success: boolean; data: FahrzeugDetail }>(
|
||||
'/api/vehicles',
|
||||
@@ -80,29 +59,10 @@ export const vehiclesApi = {
|
||||
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> {
|
||||
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[]> {
|
||||
return unwrap(
|
||||
api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// =============================================================================
|
||||
// Vehicle Fleet Management — Frontend Type Definitions
|
||||
// Mirror of backend/src/models/vehicle.model.ts (transport layer shapes)
|
||||
// =============================================================================
|
||||
|
||||
export enum FahrzeugStatus {
|
||||
@@ -17,32 +16,6 @@ export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
||||
[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 =
|
||||
| 'Inspektion'
|
||||
| 'Reparatur'
|
||||
@@ -65,14 +38,6 @@ export interface FahrzeugListItem {
|
||||
status: FahrzeugStatus;
|
||||
status_bemerkung: 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_tage_bis_faelligkeit: number | null;
|
||||
naechste_wartung_am: string | null;
|
||||
@@ -80,29 +45,6 @@ export interface FahrzeugListItem {
|
||||
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 {
|
||||
id: string;
|
||||
fahrzeug_id: string;
|
||||
@@ -137,14 +79,7 @@ export interface FahrzeugDetail {
|
||||
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||
naechste_wartung_am: string | null;
|
||||
wartung_tage_bis_faelligkeit: number | null;
|
||||
pruefstatus: {
|
||||
hu: PruefungStatus;
|
||||
au: PruefungStatus;
|
||||
uvv: PruefungStatus;
|
||||
leiter: PruefungStatus;
|
||||
};
|
||||
naechste_pruefung_tage: number | null;
|
||||
pruefungen: FahrzeugPruefung[];
|
||||
wartungslog: FahrzeugWartungslog[];
|
||||
}
|
||||
|
||||
@@ -157,12 +92,13 @@ export interface VehicleStats {
|
||||
inspectionsOverdue: number;
|
||||
}
|
||||
|
||||
export type InspectionAlertType = '57a' | 'wartung';
|
||||
|
||||
export interface InspectionAlert {
|
||||
fahrzeugId: string;
|
||||
bezeichnung: string;
|
||||
kurzname: string | null;
|
||||
pruefungId: string;
|
||||
pruefungArt: PruefungArt;
|
||||
type: InspectionAlertType;
|
||||
faelligAm: string;
|
||||
tage: number;
|
||||
}
|
||||
@@ -186,24 +122,15 @@ export interface CreateFahrzeugPayload {
|
||||
naechste_wartung_am?: string;
|
||||
}
|
||||
|
||||
export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>;
|
||||
export type UpdateFahrzeugPayload = {
|
||||
[K in keyof CreateFahrzeugPayload]?: CreateFahrzeugPayload[K] | null;
|
||||
};
|
||||
|
||||
export interface UpdateStatusPayload {
|
||||
status: FahrzeugStatus;
|
||||
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 {
|
||||
datum: string;
|
||||
art?: WartungslogArt;
|
||||
|
||||
Reference in New Issue
Block a user