rework vehicle handling
This commit is contained in:
@@ -1,9 +1,15 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import vehicleService from '../services/vehicle.service';
|
import vehicleService from '../services/vehicle.service';
|
||||||
import { FahrzeugStatus, PruefungArt } from '../models/vehicle.model';
|
import { FahrzeugStatus } from '../models/vehicle.model';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
// ── UUID validation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isValidUUID(s: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Zod Validation Schemas ────────────────────────────────────────────────────
|
// ── Zod Validation Schemas ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const FahrzeugStatusEnum = z.enum([
|
const FahrzeugStatusEnum = z.enum([
|
||||||
@@ -13,19 +19,9 @@ const FahrzeugStatusEnum = z.enum([
|
|||||||
FahrzeugStatus.InLehrgang,
|
FahrzeugStatus.InLehrgang,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const PruefungArtEnum = z.enum([
|
|
||||||
PruefungArt.HU,
|
|
||||||
PruefungArt.AU,
|
|
||||||
PruefungArt.UVV,
|
|
||||||
PruefungArt.Leiter,
|
|
||||||
PruefungArt.Kran,
|
|
||||||
PruefungArt.Seilwinde,
|
|
||||||
PruefungArt.Sonstiges,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isoDate = z.string().regex(
|
const isoDate = z.string().regex(
|
||||||
/^\d{4}-\d{2}-\d{2}$/,
|
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
||||||
'Expected ISO date format YYYY-MM-DD'
|
'Erwartet ISO-Datum im Format YYYY-MM-DD'
|
||||||
);
|
);
|
||||||
|
|
||||||
const CreateFahrzeugSchema = z.object({
|
const CreateFahrzeugSchema = z.object({
|
||||||
@@ -39,30 +35,40 @@ const CreateFahrzeugSchema = z.object({
|
|||||||
besatzung_soll: z.string().max(10).optional(),
|
besatzung_soll: z.string().max(10).optional(),
|
||||||
status: FahrzeugStatusEnum.optional(),
|
status: FahrzeugStatusEnum.optional(),
|
||||||
status_bemerkung: z.string().max(500).optional(),
|
status_bemerkung: z.string().max(500).optional(),
|
||||||
standort: z.string().max(100).optional(),
|
standort: z.string().min(1).max(100).optional(),
|
||||||
bild_url: z.string().url().max(500).optional(),
|
bild_url: z.string().url().max(500).refine(
|
||||||
|
(url) => /^https?:\/\//i.test(url),
|
||||||
|
'Nur http/https URLs erlaubt'
|
||||||
|
).optional(),
|
||||||
paragraph57a_faellig_am: isoDate.optional(),
|
paragraph57a_faellig_am: isoDate.optional(),
|
||||||
naechste_wartung_am: isoDate.optional(),
|
naechste_wartung_am: isoDate.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial();
|
const UpdateFahrzeugSchema = z.object({
|
||||||
|
bezeichnung: z.string().min(1).max(100).optional(),
|
||||||
|
kurzname: z.string().max(20).nullable().optional(),
|
||||||
|
amtliches_kennzeichen: z.string().max(20).nullable().optional(),
|
||||||
|
fahrgestellnummer: z.string().max(50).nullable().optional(),
|
||||||
|
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
|
||||||
|
hersteller: z.string().max(100).nullable().optional(),
|
||||||
|
typ_schluessel: z.string().max(30).nullable().optional(),
|
||||||
|
besatzung_soll: z.string().max(10).nullable().optional(),
|
||||||
|
status: FahrzeugStatusEnum.optional(),
|
||||||
|
status_bemerkung: z.string().max(500).nullable().optional(),
|
||||||
|
standort: z.string().min(1).max(100).optional(),
|
||||||
|
bild_url: z.string().url().max(500).refine(
|
||||||
|
(url) => /^https?:\/\//i.test(url),
|
||||||
|
'Nur http/https URLs erlaubt'
|
||||||
|
).nullable().optional(),
|
||||||
|
paragraph57a_faellig_am: isoDate.nullable().optional(),
|
||||||
|
naechste_wartung_am: isoDate.nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const UpdateStatusSchema = z.object({
|
const UpdateStatusSchema = z.object({
|
||||||
status: FahrzeugStatusEnum,
|
status: FahrzeugStatusEnum,
|
||||||
bemerkung: z.string().max(500).optional().default(''),
|
bemerkung: z.string().max(500).optional().default(''),
|
||||||
});
|
});
|
||||||
|
|
||||||
const CreatePruefungSchema = z.object({
|
|
||||||
pruefung_art: PruefungArtEnum,
|
|
||||||
faellig_am: isoDate,
|
|
||||||
durchgefuehrt_am: isoDate.optional(),
|
|
||||||
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden', 'ausstehend']).optional(),
|
|
||||||
pruefende_stelle: z.string().max(150).optional(),
|
|
||||||
kosten: z.number().min(0).optional(),
|
|
||||||
dokument_url: z.string().url().max(500).optional(),
|
|
||||||
bemerkung: z.string().max(1000).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const CreateWartungslogSchema = z.object({
|
const CreateWartungslogSchema = z.object({
|
||||||
datum: isoDate,
|
datum: isoDate,
|
||||||
art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(),
|
art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(),
|
||||||
@@ -76,17 +82,12 @@ const CreateWartungslogSchema = z.object({
|
|||||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getUserId(req: Request): string {
|
function getUserId(req: Request): string {
|
||||||
// req.user is guaranteed by the authenticate middleware
|
|
||||||
return req.user!.id;
|
return req.user!.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Controller ────────────────────────────────────────────────────────────────
|
// ── Controller ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class VehicleController {
|
class VehicleController {
|
||||||
/**
|
|
||||||
* GET /api/vehicles
|
|
||||||
* Fleet overview list with per-vehicle inspection badge data.
|
|
||||||
*/
|
|
||||||
async listVehicles(_req: Request, res: Response): Promise<void> {
|
async listVehicles(_req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const vehicles = await vehicleService.getAllVehicles();
|
const vehicles = await vehicleService.getAllVehicles();
|
||||||
@@ -97,10 +98,6 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/vehicles/stats
|
|
||||||
* Aggregated KPI counts for the dashboard strip.
|
|
||||||
*/
|
|
||||||
async getStats(_req: Request, res: Response): Promise<void> {
|
async getStats(_req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stats = await vehicleService.getVehicleStats();
|
const stats = await vehicleService.getVehicleStats();
|
||||||
@@ -111,23 +108,14 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/vehicles/alerts?daysAhead=30
|
|
||||||
* Upcoming and overdue inspections — used by the InspectionAlerts dashboard panel.
|
|
||||||
* Returns alerts sorted by urgency (most overdue / soonest due first).
|
|
||||||
*/
|
|
||||||
async getAlerts(req: Request, res: Response): Promise<void> {
|
async getAlerts(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const daysAhead = Math.min(
|
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
|
||||||
parseInt((req.query.daysAhead as string) || '30', 10),
|
if (isNaN(raw) || raw < 0) {
|
||||||
365 // hard cap — never expose more than 1 year of lookahead
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isNaN(daysAhead) || daysAhead < 0) {
|
|
||||||
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
|
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const daysAhead = Math.min(raw, 365);
|
||||||
const alerts = await vehicleService.getUpcomingInspections(daysAhead);
|
const alerts = await vehicleService.getUpcomingInspections(daysAhead);
|
||||||
res.status(200).json({ success: true, data: alerts });
|
res.status(200).json({ success: true, data: alerts });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -136,20 +124,18 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/vehicles/:id
|
|
||||||
* Full vehicle detail with pruefstatus, inspection history, and wartungslog.
|
|
||||||
*/
|
|
||||||
async getVehicle(req: Request, res: Response): Promise<void> {
|
async getVehicle(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params as Record<string, string>;
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const vehicle = await vehicleService.getVehicleById(id);
|
const vehicle = await vehicleService.getVehicleById(id);
|
||||||
|
|
||||||
if (!vehicle) {
|
if (!vehicle) {
|
||||||
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: vehicle });
|
res.status(200).json({ success: true, data: vehicle });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('getVehicle error', { error, id: req.params.id });
|
logger.error('getVehicle error', { error, id: req.params.id });
|
||||||
@@ -157,10 +143,6 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/vehicles
|
|
||||||
* Create a new vehicle. Requires vehicles:write permission.
|
|
||||||
*/
|
|
||||||
async createVehicle(req: Request, res: Response): Promise<void> {
|
async createVehicle(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const parsed = CreateFahrzeugSchema.safeParse(req.body);
|
const parsed = CreateFahrzeugSchema.safeParse(req.body);
|
||||||
@@ -172,7 +154,6 @@ class VehicleController {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
|
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
|
||||||
res.status(201).json({ success: true, data: vehicle });
|
res.status(201).json({ success: true, data: vehicle });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -181,13 +162,13 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /api/vehicles/:id
|
|
||||||
* Update vehicle fields. Requires vehicles:write permission.
|
|
||||||
*/
|
|
||||||
async updateVehicle(req: Request, res: Response): Promise<void> {
|
async updateVehicle(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params as Record<string, string>;
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parsed = UpdateFahrzeugSchema.safeParse(req.body);
|
const parsed = UpdateFahrzeugSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -197,7 +178,10 @@ class VehicleController {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (Object.keys(parsed.data).length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req));
|
const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req));
|
||||||
res.status(200).json({ success: true, data: vehicle });
|
res.status(200).json({ success: true, data: vehicle });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -210,20 +194,14 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /api/vehicles/:id/status
|
|
||||||
* Live status change — the Socket.IO hook point for Tier 3.
|
|
||||||
* Requires vehicles:write permission.
|
|
||||||
*
|
|
||||||
* The `io` instance is attached to req.app in server.ts (Tier 3):
|
|
||||||
* app.set('io', io);
|
|
||||||
* and retrieved here via req.app.get('io').
|
|
||||||
*/
|
|
||||||
async updateVehicleStatus(req: Request, res: Response): Promise<void> {
|
async updateVehicleStatus(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params as Record<string, string>;
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parsed = UpdateStatusSchema.safeParse(req.body);
|
const parsed = UpdateStatusSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -232,19 +210,10 @@ class VehicleController {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 3: io will be available via req.app.get('io') once Socket.IO is wired up.
|
|
||||||
// Passing undefined here is safe — the service handles it gracefully.
|
|
||||||
const io = req.app.get('io') ?? undefined;
|
const io = req.app.get('io') ?? undefined;
|
||||||
|
|
||||||
await vehicleService.updateVehicleStatus(
|
await vehicleService.updateVehicleStatus(
|
||||||
id,
|
id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io
|
||||||
parsed.data.status,
|
|
||||||
parsed.data.bemerkung,
|
|
||||||
getUserId(req),
|
|
||||||
io
|
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).json({ success: true, message: 'Status aktualisiert' });
|
res.status(200).json({ success: true, message: 'Status aktualisiert' });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.message === 'Vehicle not found') {
|
if (error?.message === 'Vehicle not found') {
|
||||||
@@ -256,60 +225,14 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Inspections ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/vehicles/:id/pruefungen
|
|
||||||
* Record an inspection (scheduled or completed). Requires vehicles:write.
|
|
||||||
*/
|
|
||||||
async addPruefung(req: Request, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { id } = req.params as Record<string, string>;
|
|
||||||
const parsed = CreatePruefungSchema.safeParse(req.body);
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Validierungsfehler',
|
|
||||||
errors: parsed.error.flatten().fieldErrors,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pruefung = await vehicleService.addPruefung(id, parsed.data, getUserId(req));
|
|
||||||
res.status(201).json({ success: true, data: pruefung });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('addPruefung error', { error, id: req.params.id });
|
|
||||||
res.status(500).json({ success: false, message: 'Prüfung konnte nicht eingetragen werden' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/vehicles/:id/pruefungen
|
|
||||||
* Full inspection history for a vehicle.
|
|
||||||
*/
|
|
||||||
async getPruefungen(req: Request, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { id } = req.params as Record<string, string>;
|
|
||||||
const pruefungen = await vehicleService.getPruefungenForVehicle(id);
|
|
||||||
res.status(200).json({ success: true, data: pruefungen });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('getPruefungen error', { error, id: req.params.id });
|
|
||||||
res.status(500).json({ success: false, message: 'Prüfungshistorie konnte nicht geladen werden' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Maintenance Log ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/vehicles/:id/wartung
|
|
||||||
* Add a maintenance log entry. Requires vehicles:write.
|
|
||||||
*/
|
|
||||||
async addWartung(req: Request, res: Response): Promise<void> {
|
async addWartung(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params as Record<string, string>;
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parsed = CreateWartungslogSchema.safeParse(req.body);
|
const parsed = CreateWartungslogSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -318,24 +241,27 @@ class VehicleController {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
|
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
|
||||||
res.status(201).json({ success: true, data: entry });
|
res.status(201).json({ success: true, data: entry });
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Vehicle not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error('addWartung error', { error, id: req.params.id });
|
logger.error('addWartung error', { error, id: req.params.id });
|
||||||
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
|
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/vehicles/:id
|
|
||||||
* Delete a vehicle. Requires dashboard_admin group.
|
|
||||||
*/
|
|
||||||
async deleteVehicle(req: Request, res: Response): Promise<void> {
|
async deleteVehicle(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params as Record<string, string>;
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await vehicleService.deleteVehicle(id, getUserId(req));
|
await vehicleService.deleteVehicle(id, getUserId(req));
|
||||||
res.status(200).json({ success: true, message: 'Fahrzeug gelöscht' });
|
res.status(204).send();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.message === 'Vehicle not found') {
|
if (error?.message === 'Vehicle not found') {
|
||||||
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||||
@@ -346,13 +272,13 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/vehicles/:id/wartung
|
|
||||||
* Maintenance log for a vehicle.
|
|
||||||
*/
|
|
||||||
async getWartung(req: Request, res: Response): Promise<void> {
|
async getWartung(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params as Record<string, string>;
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const entries = await vehicleService.getWartungslogForVehicle(id);
|
const entries = await vehicleService.getWartungslogForVehicle(id);
|
||||||
res.status(200).json({ success: true, data: entries });
|
res.status(200).json({ success: true, data: entries });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
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 ─────────────────────────────────────────────────────────────────────
|
// ── Enums ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Operational status of a vehicle.
|
|
||||||
* These values are the CHECK constraint values in the database.
|
|
||||||
*/
|
|
||||||
export enum FahrzeugStatus {
|
export enum FahrzeugStatus {
|
||||||
Einsatzbereit = 'einsatzbereit',
|
Einsatzbereit = 'einsatzbereit',
|
||||||
AusserDienstWartung = 'ausser_dienst_wartung',
|
AusserDienstWartung = 'ausser_dienst_wartung',
|
||||||
@@ -15,7 +11,6 @@ export enum FahrzeugStatus {
|
|||||||
InLehrgang = 'in_lehrgang',
|
InLehrgang = 'in_lehrgang',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human-readable German labels for each status value */
|
|
||||||
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
||||||
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
|
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
|
||||||
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
|
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
|
||||||
@@ -23,53 +18,6 @@ export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
|||||||
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
|
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Types of vehicle inspections (Prüfungsarten).
|
|
||||||
* These values are the CHECK constraint values in the database.
|
|
||||||
*/
|
|
||||||
export enum PruefungArt {
|
|
||||||
HU = 'HU', // Hauptuntersuchung (TÜV) — 24-month interval
|
|
||||||
AU = 'AU', // Abgasuntersuchung — 12-month interval
|
|
||||||
UVV = 'UVV', // Unfallverhütungsvorschrift BGV D29 — 12-month
|
|
||||||
Leiter = 'Leiter', // Leiternprüfung (DLK only) — 12-month
|
|
||||||
Kran = 'Kran', // Kranprüfung — 12-month
|
|
||||||
Seilwinde = 'Seilwinde', // Seilwindenprüfung — 12-month
|
|
||||||
Sonstiges = 'Sonstiges',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Human-readable German labels for each PruefungArt */
|
|
||||||
export const PruefungArtLabel: Record<PruefungArt, string> = {
|
|
||||||
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
|
|
||||||
[PruefungArt.AU]: 'Abgasuntersuchung',
|
|
||||||
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
|
|
||||||
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
|
|
||||||
[PruefungArt.Kran]: 'Kranprüfung',
|
|
||||||
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
|
|
||||||
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard inspection intervals in months, keyed by PruefungArt.
|
|
||||||
* Used by vehicle.service.ts to auto-calculate naechste_faelligkeit.
|
|
||||||
*/
|
|
||||||
export const PruefungIntervalMonths: Partial<Record<PruefungArt, number>> = {
|
|
||||||
[PruefungArt.HU]: 24,
|
|
||||||
[PruefungArt.AU]: 12,
|
|
||||||
[PruefungArt.UVV]: 12,
|
|
||||||
[PruefungArt.Leiter]: 12,
|
|
||||||
[PruefungArt.Kran]: 12,
|
|
||||||
[PruefungArt.Seilwinde]: 12,
|
|
||||||
// Sonstiges: no standard interval — must be set manually
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Inspection result values */
|
|
||||||
export type PruefungErgebnis =
|
|
||||||
| 'bestanden'
|
|
||||||
| 'bestanden_mit_maengeln'
|
|
||||||
| 'nicht_bestanden'
|
|
||||||
| 'ausstehend';
|
|
||||||
|
|
||||||
/** Maintenance log entry types */
|
|
||||||
export type WartungslogArt =
|
export type WartungslogArt =
|
||||||
| 'Inspektion'
|
| 'Inspektion'
|
||||||
| 'Reparatur'
|
| 'Reparatur'
|
||||||
@@ -81,50 +29,29 @@ export type WartungslogArt =
|
|||||||
|
|
||||||
// ── Core Entities ─────────────────────────────────────────────────────────────
|
// ── Core Entities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Raw database row from the `fahrzeuge` table */
|
|
||||||
export interface Fahrzeug {
|
export interface Fahrzeug {
|
||||||
id: string; // UUID
|
id: string;
|
||||||
bezeichnung: string; // e.g. "LF 20/16"
|
bezeichnung: string;
|
||||||
kurzname: string | null;
|
kurzname: string | null;
|
||||||
amtliches_kennzeichen: string | null;
|
amtliches_kennzeichen: string | null;
|
||||||
fahrgestellnummer: string | null;
|
fahrgestellnummer: string | null;
|
||||||
baujahr: number | null;
|
baujahr: number | null;
|
||||||
hersteller: string | null;
|
hersteller: string | null;
|
||||||
typ_schluessel: string | null;
|
typ_schluessel: string | null;
|
||||||
besatzung_soll: string | null; // e.g. "1/8"
|
besatzung_soll: string | null;
|
||||||
status: FahrzeugStatus;
|
status: FahrzeugStatus;
|
||||||
status_bemerkung: string | null;
|
status_bemerkung: string | null;
|
||||||
standort: string;
|
standort: string;
|
||||||
bild_url: string | null;
|
bild_url: string | null;
|
||||||
/** §57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV) */
|
|
||||||
paragraph57a_faellig_am: Date | null;
|
paragraph57a_faellig_am: Date | null;
|
||||||
/** Next scheduled service / maintenance due date */
|
|
||||||
naechste_wartung_am: Date | null;
|
naechste_wartung_am: Date | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raw database row from `fahrzeug_pruefungen` */
|
|
||||||
export interface FahrzeugPruefung {
|
|
||||||
id: string; // UUID
|
|
||||||
fahrzeug_id: string; // UUID FK
|
|
||||||
pruefung_art: PruefungArt;
|
|
||||||
faellig_am: Date; // The hard legal deadline
|
|
||||||
durchgefuehrt_am: Date | null;
|
|
||||||
ergebnis: PruefungErgebnis | null;
|
|
||||||
naechste_faelligkeit: Date | null;
|
|
||||||
pruefende_stelle: string | null;
|
|
||||||
kosten: number | null;
|
|
||||||
dokument_url: string | null;
|
|
||||||
bemerkung: string | null;
|
|
||||||
erfasst_von: string | null; // UUID FK users
|
|
||||||
created_at: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Raw database row from `fahrzeug_wartungslog` */
|
|
||||||
export interface FahrzeugWartungslog {
|
export interface FahrzeugWartungslog {
|
||||||
id: string; // UUID
|
id: string;
|
||||||
fahrzeug_id: string; // UUID FK
|
fahrzeug_id: string;
|
||||||
datum: Date;
|
datum: Date;
|
||||||
art: WartungslogArt | null;
|
art: WartungslogArt | null;
|
||||||
beschreibung: string;
|
beschreibung: string;
|
||||||
@@ -132,49 +59,12 @@ export interface FahrzeugWartungslog {
|
|||||||
kraftstoff_liter: number | null;
|
kraftstoff_liter: number | null;
|
||||||
kosten: number | null;
|
kosten: number | null;
|
||||||
externe_werkstatt: string | null;
|
externe_werkstatt: string | null;
|
||||||
erfasst_von: string | null; // UUID FK users
|
erfasst_von: string | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Inspection Status per Type ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Status of a single inspection type for a vehicle */
|
|
||||||
export interface PruefungStatus {
|
|
||||||
pruefung_id: string | null;
|
|
||||||
faellig_am: Date | null;
|
|
||||||
tage_bis_faelligkeit: number | null; // negative = overdue
|
|
||||||
ergebnis: PruefungErgebnis | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vehicle with its per-type inspection status.
|
|
||||||
* Comes from the `fahrzeuge_mit_pruefstatus` view.
|
|
||||||
*/
|
|
||||||
export interface FahrzeugWithPruefstatus extends Fahrzeug {
|
|
||||||
pruefstatus: {
|
|
||||||
hu: PruefungStatus;
|
|
||||||
au: PruefungStatus;
|
|
||||||
uvv: PruefungStatus;
|
|
||||||
leiter: PruefungStatus;
|
|
||||||
};
|
|
||||||
/** Days until §57a inspection (negative = overdue) */
|
|
||||||
paragraph57a_tage_bis_faelligkeit: number | null;
|
|
||||||
/** Days until next service (negative = overdue) */
|
|
||||||
wartung_tage_bis_faelligkeit: number | null;
|
|
||||||
/** Minimum tage_bis_faelligkeit across all inspections (negative = any overdue) */
|
|
||||||
naechste_pruefung_tage: number | null;
|
|
||||||
/** Full inspection history, ordered by faellig_am DESC */
|
|
||||||
pruefungen: FahrzeugPruefung[];
|
|
||||||
/** Maintenance log entries, ordered by datum DESC */
|
|
||||||
wartungslog: FahrzeugWartungslog[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── List Item (Grid / Card view) ──────────────────────────────────────────────
|
// ── List Item (Grid / Card view) ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight type used in the vehicle fleet overview grid.
|
|
||||||
* Includes only the fields needed to render a card plus inspection badges.
|
|
||||||
*/
|
|
||||||
export interface FahrzeugListItem {
|
export interface FahrzeugListItem {
|
||||||
id: string;
|
id: string;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
@@ -186,47 +76,44 @@ export interface FahrzeugListItem {
|
|||||||
status: FahrzeugStatus;
|
status: FahrzeugStatus;
|
||||||
status_bemerkung: string | null;
|
status_bemerkung: string | null;
|
||||||
bild_url: string | null;
|
bild_url: string | null;
|
||||||
/** §57a due date (primary inspection badge) */
|
|
||||||
paragraph57a_faellig_am: Date | null;
|
paragraph57a_faellig_am: Date | null;
|
||||||
paragraph57a_tage_bis_faelligkeit: number | null;
|
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||||
/** Next service due date */
|
|
||||||
naechste_wartung_am: Date | null;
|
naechste_wartung_am: Date | null;
|
||||||
wartung_tage_bis_faelligkeit: number | null;
|
wartung_tage_bis_faelligkeit: number | null;
|
||||||
// Legacy pruefungen kept for backwards compat
|
|
||||||
hu_faellig_am: Date | null;
|
|
||||||
hu_tage_bis_faelligkeit: number | null;
|
|
||||||
au_faellig_am: Date | null;
|
|
||||||
au_tage_bis_faelligkeit: number | null;
|
|
||||||
uvv_faellig_am: Date | null;
|
|
||||||
uvv_tage_bis_faelligkeit: number | null;
|
|
||||||
leiter_faellig_am: Date | null;
|
|
||||||
leiter_tage_bis_faelligkeit: number | null;
|
|
||||||
naechste_pruefung_tage: number | null;
|
naechste_pruefung_tage: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Detail View ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FahrzeugDetail extends Fahrzeug {
|
||||||
|
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||||
|
wartung_tage_bis_faelligkeit: number | null;
|
||||||
|
naechste_pruefung_tage: number | null;
|
||||||
|
wartungslog: FahrzeugWartungslog[];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dashboard KPI ─────────────────────────────────────────────────────────────
|
// ── Dashboard KPI ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Aggregated vehicle stats for the dashboard KPI strip */
|
|
||||||
export interface VehicleStats {
|
export interface VehicleStats {
|
||||||
total: number;
|
total: number;
|
||||||
einsatzbereit: number;
|
einsatzbereit: number;
|
||||||
ausserDienst: number; // wartung + schaden combined
|
ausserDienst: number;
|
||||||
inLehrgang: number;
|
inLehrgang: number;
|
||||||
inspectionsDue: number; // vehicles with any inspection due within 30 days
|
inspectionsDue: number;
|
||||||
inspectionsOverdue: number; // vehicles with any inspection already overdue
|
inspectionsOverdue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Inspection Alert ──────────────────────────────────────────────────────────
|
// ── Inspection Alert ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Single alert item for the dashboard InspectionAlerts component */
|
export type InspectionAlertType = '57a' | 'wartung';
|
||||||
|
|
||||||
export interface InspectionAlert {
|
export interface InspectionAlert {
|
||||||
fahrzeugId: string;
|
fahrzeugId: string;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
kurzname: string | null;
|
kurzname: string | null;
|
||||||
pruefungId: string;
|
type: InspectionAlertType;
|
||||||
pruefungArt: PruefungArt;
|
|
||||||
faelligAm: Date;
|
faelligAm: Date;
|
||||||
tage: number; // negative = already overdue
|
tage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create / Update DTOs ──────────────────────────────────────────────────────
|
// ── Create / Update DTOs ──────────────────────────────────────────────────────
|
||||||
@@ -244,8 +131,8 @@ export interface CreateFahrzeugData {
|
|||||||
status_bemerkung?: string;
|
status_bemerkung?: string;
|
||||||
standort?: string;
|
standort?: string;
|
||||||
bild_url?: string;
|
bild_url?: string;
|
||||||
paragraph57a_faellig_am?: string; // ISO date 'YYYY-MM-DD'
|
paragraph57a_faellig_am?: string;
|
||||||
naechste_wartung_am?: string; // ISO date 'YYYY-MM-DD'
|
naechste_wartung_am?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateFahrzeugData {
|
export interface UpdateFahrzeugData {
|
||||||
@@ -261,24 +148,12 @@ export interface UpdateFahrzeugData {
|
|||||||
status_bemerkung?: string | null;
|
status_bemerkung?: string | null;
|
||||||
standort?: string;
|
standort?: string;
|
||||||
bild_url?: string | null;
|
bild_url?: string | null;
|
||||||
paragraph57a_faellig_am?: string | null; // ISO date 'YYYY-MM-DD'
|
paragraph57a_faellig_am?: string | null;
|
||||||
naechste_wartung_am?: string | null; // ISO date 'YYYY-MM-DD'
|
naechste_wartung_am?: string | null;
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreatePruefungData {
|
|
||||||
pruefung_art: PruefungArt;
|
|
||||||
faellig_am: string; // ISO date string 'YYYY-MM-DD'
|
|
||||||
durchgefuehrt_am?: string; // ISO date string, optional
|
|
||||||
ergebnis?: PruefungErgebnis;
|
|
||||||
pruefende_stelle?: string;
|
|
||||||
kosten?: number;
|
|
||||||
dokument_url?: string;
|
|
||||||
bemerkung?: string;
|
|
||||||
// naechste_faelligkeit is auto-calculated by the service — not accepted from client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateWartungslogData {
|
export interface CreateWartungslogData {
|
||||||
datum: string; // ISO date string 'YYYY-MM-DD'
|
datum: string;
|
||||||
art?: WartungslogArt;
|
art?: WartungslogArt;
|
||||||
beschreibung: string;
|
beschreibung: string;
|
||||||
km_stand?: number;
|
km_stand?: number;
|
||||||
|
|||||||
@@ -3,118 +3,28 @@ import vehicleController from '../controllers/vehicle.controller';
|
|||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
import { requireGroups } from '../middleware/rbac.middleware';
|
import { requireGroups } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
const ADMIN_GROUPS = ['dashboard_admin'];
|
||||||
const STATUS_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// ── Read-only endpoints (any authenticated user) ──────────────────────────────
|
// ── Read-only (any authenticated user) ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
||||||
* GET /api/vehicles
|
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
||||||
* Fleet overview list — inspection badges included.
|
|
||||||
*/
|
|
||||||
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/vehicles/stats
|
|
||||||
* Dashboard KPI aggregates.
|
|
||||||
* NOTE: /stats and /alerts must be declared BEFORE /:id to avoid route conflicts.
|
|
||||||
*/
|
|
||||||
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/vehicles/alerts?daysAhead=30
|
|
||||||
* Upcoming and overdue inspections for the dashboard alert panel.
|
|
||||||
*/
|
|
||||||
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
|
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
|
||||||
|
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
||||||
/**
|
|
||||||
* GET /api/vehicles/:id
|
|
||||||
* Full vehicle detail with inspection history and maintenance log.
|
|
||||||
*/
|
|
||||||
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/vehicles/:id/pruefungen
|
|
||||||
* Inspection history for a single vehicle.
|
|
||||||
*/
|
|
||||||
router.get('/:id/pruefungen', authenticate, vehicleController.getPruefungen.bind(vehicleController));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/vehicles/:id/wartung
|
|
||||||
* Maintenance log for a single vehicle.
|
|
||||||
*/
|
|
||||||
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
||||||
|
|
||||||
// ── Write endpoints (dashboard_admin group required) ────────────────────────
|
// ── Write — admin only ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
router.post('/', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController));
|
||||||
* POST /api/vehicles
|
router.patch('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController));
|
||||||
* Create a new vehicle.
|
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.deleteVehicle.bind(vehicleController));
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
'/',
|
|
||||||
authenticate,
|
|
||||||
requireGroups(ADMIN_GROUPS),
|
|
||||||
vehicleController.createVehicle.bind(vehicleController)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
// ── Status + maintenance log — admin + fahrmeister ────────────────────────────
|
||||||
* PATCH /api/vehicles/:id
|
|
||||||
* Update vehicle fields.
|
|
||||||
*/
|
|
||||||
router.patch(
|
|
||||||
'/:id',
|
|
||||||
authenticate,
|
|
||||||
requireGroups(ADMIN_GROUPS),
|
|
||||||
vehicleController.updateVehicle.bind(vehicleController)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||||
* PATCH /api/vehicles/:id/status
|
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), vehicleController.addWartung.bind(vehicleController));
|
||||||
* Live status change — dashboard_admin or dashboard_fahrmeister required.
|
|
||||||
* The `io` instance is retrieved inside the controller via req.app.get('io').
|
|
||||||
*/
|
|
||||||
router.patch(
|
|
||||||
'/:id/status',
|
|
||||||
authenticate,
|
|
||||||
requireGroups(STATUS_GROUPS),
|
|
||||||
vehicleController.updateVehicleStatus.bind(vehicleController)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/vehicles/:id/pruefungen
|
|
||||||
* Record an inspection (scheduled or completed).
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
'/:id/pruefungen',
|
|
||||||
authenticate,
|
|
||||||
requireGroups(ADMIN_GROUPS),
|
|
||||||
vehicleController.addPruefung.bind(vehicleController)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/vehicles/:id/wartung
|
|
||||||
* Add a maintenance log entry.
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
'/:id/wartung',
|
|
||||||
authenticate,
|
|
||||||
requireGroups(ADMIN_GROUPS),
|
|
||||||
vehicleController.addWartung.bind(vehicleController)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/vehicles/:id
|
|
||||||
* Delete a vehicle — dashboard_admin only.
|
|
||||||
* NOTE: vehicleController.deleteVehicle needs to be implemented.
|
|
||||||
*/
|
|
||||||
router.delete(
|
|
||||||
'/:id',
|
|
||||||
authenticate,
|
|
||||||
requireGroups(ADMIN_GROUPS),
|
|
||||||
vehicleController.deleteVehicle.bind(vehicleController)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -3,79 +3,29 @@ import logger from '../utils/logger';
|
|||||||
import {
|
import {
|
||||||
Fahrzeug,
|
Fahrzeug,
|
||||||
FahrzeugListItem,
|
FahrzeugListItem,
|
||||||
FahrzeugWithPruefstatus,
|
FahrzeugDetail,
|
||||||
FahrzeugPruefung,
|
|
||||||
FahrzeugWartungslog,
|
FahrzeugWartungslog,
|
||||||
CreateFahrzeugData,
|
CreateFahrzeugData,
|
||||||
UpdateFahrzeugData,
|
UpdateFahrzeugData,
|
||||||
CreatePruefungData,
|
|
||||||
CreateWartungslogData,
|
CreateWartungslogData,
|
||||||
FahrzeugStatus,
|
FahrzeugStatus,
|
||||||
PruefungArt,
|
|
||||||
PruefungIntervalMonths,
|
|
||||||
VehicleStats,
|
VehicleStats,
|
||||||
InspectionAlert,
|
InspectionAlert,
|
||||||
} from '../models/vehicle.model';
|
} from '../models/vehicle.model';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: add N months to a Date (handles month-end edge cases)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function addMonths(date: Date, months: number): Date {
|
|
||||||
const result = new Date(date);
|
|
||||||
result.setMonth(result.getMonth() + months);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: map a flat view row to PruefungStatus sub-object
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function mapPruefungStatus(row: any, prefix: string) {
|
|
||||||
return {
|
|
||||||
pruefung_id: row[`${prefix}_pruefung_id`] ?? null,
|
|
||||||
faellig_am: row[`${prefix}_faellig_am`] ?? null,
|
|
||||||
tage_bis_faelligkeit: row[`${prefix}_tage_bis_faelligkeit`] != null
|
|
||||||
? parseInt(row[`${prefix}_tage_bis_faelligkeit`], 10)
|
|
||||||
: null,
|
|
||||||
ergebnis: row[`${prefix}_ergebnis`] ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class VehicleService {
|
class VehicleService {
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// FLEET OVERVIEW
|
// FLEET OVERVIEW
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all vehicles with their next-due inspection dates per type.
|
|
||||||
* Used by the fleet overview grid (FahrzeugListItem[]).
|
|
||||||
*/
|
|
||||||
async getAllVehicles(): Promise<FahrzeugListItem[]> {
|
async getAllVehicles(): Promise<FahrzeugListItem[]> {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id, bezeichnung, kurzname, amtliches_kennzeichen,
|
||||||
bezeichnung,
|
baujahr, hersteller, besatzung_soll, status, status_bemerkung,
|
||||||
kurzname,
|
bild_url, paragraph57a_faellig_am, paragraph57a_tage_bis_faelligkeit,
|
||||||
amtliches_kennzeichen,
|
naechste_wartung_am, wartung_tage_bis_faelligkeit, naechste_pruefung_tage
|
||||||
baujahr,
|
|
||||||
hersteller,
|
|
||||||
besatzung_soll,
|
|
||||||
status,
|
|
||||||
status_bemerkung,
|
|
||||||
bild_url,
|
|
||||||
paragraph57a_faellig_am,
|
|
||||||
paragraph57a_tage_bis_faelligkeit,
|
|
||||||
naechste_wartung_am,
|
|
||||||
wartung_tage_bis_faelligkeit,
|
|
||||||
hu_faellig_am,
|
|
||||||
hu_tage_bis_faelligkeit,
|
|
||||||
au_faellig_am,
|
|
||||||
au_tage_bis_faelligkeit,
|
|
||||||
uvv_faellig_am,
|
|
||||||
uvv_tage_bis_faelligkeit,
|
|
||||||
leiter_faellig_am,
|
|
||||||
leiter_tage_bis_faelligkeit,
|
|
||||||
naechste_pruefung_tage
|
|
||||||
FROM fahrzeuge_mit_pruefstatus
|
FROM fahrzeuge_mit_pruefstatus
|
||||||
ORDER BY bezeichnung ASC
|
ORDER BY bezeichnung ASC
|
||||||
`);
|
`);
|
||||||
@@ -84,17 +34,9 @@ class VehicleService {
|
|||||||
...row,
|
...row,
|
||||||
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
|
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
|
||||||
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
||||||
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
||||||
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
||||||
hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null
|
naechste_pruefung_tage: row.naechste_pruefung_tage != null
|
||||||
? parseInt(row.hu_tage_bis_faelligkeit, 10) : null,
|
|
||||||
au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null
|
|
||||||
? parseInt(row.au_tage_bis_faelligkeit, 10) : null,
|
|
||||||
uvv_tage_bis_faelligkeit: row.uvv_tage_bis_faelligkeit != null
|
|
||||||
? parseInt(row.uvv_tage_bis_faelligkeit, 10) : null,
|
|
||||||
leiter_tage_bis_faelligkeit: row.leiter_tage_bis_faelligkeit != null
|
|
||||||
? parseInt(row.leiter_tage_bis_faelligkeit, 10) : null,
|
|
||||||
naechste_pruefung_tage: row.naechste_pruefung_tage != null
|
|
||||||
? parseInt(row.naechste_pruefung_tage, 10) : null,
|
? parseInt(row.naechste_pruefung_tage, 10) : null,
|
||||||
})) as FahrzeugListItem[];
|
})) as FahrzeugListItem[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -107,13 +49,8 @@ class VehicleService {
|
|||||||
// VEHICLE DETAIL
|
// VEHICLE DETAIL
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
async getVehicleById(id: string): Promise<FahrzeugDetail | null> {
|
||||||
* Returns a single vehicle with full pruefstatus, inspection history,
|
|
||||||
* and maintenance log.
|
|
||||||
*/
|
|
||||||
async getVehicleById(id: string): Promise<FahrzeugWithPruefstatus | null> {
|
|
||||||
try {
|
try {
|
||||||
// 1) Main record + inspection status from view
|
|
||||||
const vehicleResult = await pool.query(
|
const vehicleResult = await pool.query(
|
||||||
`SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`,
|
`SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
@@ -123,15 +60,6 @@ class VehicleService {
|
|||||||
|
|
||||||
const row = vehicleResult.rows[0];
|
const row = vehicleResult.rows[0];
|
||||||
|
|
||||||
// 2) Full inspection history
|
|
||||||
const pruefungenResult = await pool.query(
|
|
||||||
`SELECT * FROM fahrzeug_pruefungen
|
|
||||||
WHERE fahrzeug_id = $1
|
|
||||||
ORDER BY faellig_am DESC, created_at DESC`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3) Maintenance log
|
|
||||||
const wartungslogResult = await pool.query(
|
const wartungslogResult = await pool.query(
|
||||||
`SELECT * FROM fahrzeug_wartungslog
|
`SELECT * FROM fahrzeug_wartungslog
|
||||||
WHERE fahrzeug_id = $1
|
WHERE fahrzeug_id = $1
|
||||||
@@ -139,7 +67,7 @@ class VehicleService {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const vehicle: FahrzeugWithPruefstatus = {
|
const vehicle: FahrzeugDetail = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
bezeichnung: row.bezeichnung,
|
bezeichnung: row.bezeichnung,
|
||||||
kurzname: row.kurzname,
|
kurzname: row.kurzname,
|
||||||
@@ -157,20 +85,16 @@ class VehicleService {
|
|||||||
naechste_wartung_am: row.naechste_wartung_am ?? null,
|
naechste_wartung_am: row.naechste_wartung_am ?? null,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_at,
|
updated_at: row.updated_at,
|
||||||
pruefstatus: {
|
|
||||||
hu: mapPruefungStatus(row, 'hu'),
|
|
||||||
au: mapPruefungStatus(row, 'au'),
|
|
||||||
uvv: mapPruefungStatus(row, 'uvv'),
|
|
||||||
leiter: mapPruefungStatus(row, 'leiter'),
|
|
||||||
},
|
|
||||||
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
|
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
|
||||||
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
||||||
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
||||||
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
||||||
naechste_pruefung_tage: row.naechste_pruefung_tage != null
|
naechste_pruefung_tage: row.naechste_pruefung_tage != null
|
||||||
? parseInt(row.naechste_pruefung_tage, 10) : null,
|
? parseInt(row.naechste_pruefung_tage, 10) : null,
|
||||||
pruefungen: pruefungenResult.rows as FahrzeugPruefung[],
|
wartungslog: wartungslogResult.rows.map(r => ({
|
||||||
wartungslog: wartungslogResult.rows as FahrzeugWartungslog[],
|
...r,
|
||||||
|
kosten: r.kosten != null ? Number(r.kosten) : null,
|
||||||
|
})) as FahrzeugWartungslog[],
|
||||||
};
|
};
|
||||||
|
|
||||||
return vehicle;
|
return vehicle;
|
||||||
@@ -184,10 +108,7 @@ class VehicleService {
|
|||||||
// CRUD
|
// CRUD
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
async createVehicle(
|
async createVehicle(data: CreateFahrzeugData, createdBy: string): Promise<Fahrzeug> {
|
||||||
data: CreateFahrzeugData,
|
|
||||||
createdBy: string
|
|
||||||
): Promise<Fahrzeug> {
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO fahrzeuge (
|
`INSERT INTO fahrzeuge (
|
||||||
@@ -224,11 +145,7 @@ class VehicleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateVehicle(
|
async updateVehicle(id: string, data: UpdateFahrzeugData, updatedBy: string): Promise<Fahrzeug> {
|
||||||
id: string,
|
|
||||||
data: UpdateFahrzeugData,
|
|
||||||
updatedBy: string
|
|
||||||
): Promise<Fahrzeug> {
|
|
||||||
try {
|
try {
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
@@ -258,9 +175,9 @@ class VehicleService {
|
|||||||
throw new Error('No fields to update');
|
throw new Error('No fields to update');
|
||||||
}
|
}
|
||||||
|
|
||||||
values.push(id); // for WHERE clause
|
values.push(id);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`,
|
`UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} AND deleted_at IS NULL RETURNING *`,
|
||||||
values
|
values
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -280,7 +197,10 @@ class VehicleService {
|
|||||||
async deleteVehicle(id: string, deletedBy: string): Promise<void> {
|
async deleteVehicle(id: string, deletedBy: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`DELETE FROM fahrzeuge WHERE id = $1 RETURNING id`,
|
`UPDATE fahrzeuge
|
||||||
|
SET deleted_at = NOW()
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
RETURNING id`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -288,7 +208,7 @@ class VehicleService {
|
|||||||
throw new Error('Vehicle not found');
|
throw new Error('Vehicle not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Vehicle deleted', { id, by: deletedBy });
|
logger.info('Vehicle soft-deleted', { id, by: deletedBy });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('VehicleService.deleteVehicle failed', { error, id });
|
logger.error('VehicleService.deleteVehicle failed', { error, id });
|
||||||
throw error;
|
throw error;
|
||||||
@@ -297,22 +217,8 @@ class VehicleService {
|
|||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// STATUS MANAGEMENT
|
// STATUS MANAGEMENT
|
||||||
// Socket.io-ready: accepts optional `io` parameter.
|
|
||||||
// In Tier 3, pass the real Socket.IO server instance here.
|
|
||||||
// The endpoint contract is: PATCH /api/vehicles/:id/status
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates vehicle status and optionally broadcasts a Socket.IO event.
|
|
||||||
*
|
|
||||||
* Socket.IO integration (Tier 3):
|
|
||||||
* Pass the live `io` instance from server.ts. When provided, emits:
|
|
||||||
* event: 'vehicle:statusChanged'
|
|
||||||
* payload: { vehicleId, bezeichnung, oldStatus, newStatus, bemerkung, updatedBy, timestamp }
|
|
||||||
* All connected clients on the default namespace receive the update immediately.
|
|
||||||
*
|
|
||||||
* @param io - Optional Socket.IO server instance (injected from app layer in Tier 3)
|
|
||||||
*/
|
|
||||||
async updateVehicleStatus(
|
async updateVehicleStatus(
|
||||||
id: string,
|
id: string,
|
||||||
status: FahrzeugStatus,
|
status: FahrzeugStatus,
|
||||||
@@ -320,38 +226,33 @@ class VehicleService {
|
|||||||
updatedBy: string,
|
updatedBy: string,
|
||||||
io?: any
|
io?: any
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
// Fetch old status for Socket.IO payload and logging
|
await client.query('BEGIN');
|
||||||
const oldResult = await pool.query(
|
|
||||||
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1`,
|
const oldResult = await client.query(
|
||||||
|
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (oldResult.rows.length === 0) {
|
if (oldResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
throw new Error('Vehicle not found');
|
throw new Error('Vehicle not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bezeichnung, status: oldStatus } = oldResult.rows[0];
|
const { bezeichnung, status: oldStatus } = oldResult.rows[0];
|
||||||
|
|
||||||
await pool.query(
|
await client.query(
|
||||||
`UPDATE fahrzeuge
|
`UPDATE fahrzeuge SET status = $1, status_bemerkung = $2 WHERE id = $3`,
|
||||||
SET status = $1, status_bemerkung = $2
|
|
||||||
WHERE id = $3`,
|
|
||||||
[status, bemerkung || null, id]
|
[status, bemerkung || null, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info('Vehicle status updated', {
|
await client.query('COMMIT');
|
||||||
id,
|
|
||||||
from: oldStatus,
|
logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy });
|
||||||
to: status,
|
|
||||||
by: updatedBy,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Socket.IO broadcast (Tier 3 integration point) ──────────────────
|
|
||||||
// When `io` is provided (Tier 3), broadcast the status change to all
|
|
||||||
// connected dashboard clients so the live status board updates in real time.
|
|
||||||
if (io) {
|
if (io) {
|
||||||
const payload = {
|
io.emit('vehicle:statusChanged', {
|
||||||
vehicleId: id,
|
vehicleId: id,
|
||||||
bezeichnung,
|
bezeichnung,
|
||||||
oldStatus,
|
oldStatus,
|
||||||
@@ -359,143 +260,14 @@ class VehicleService {
|
|||||||
bemerkung: bemerkung || null,
|
bemerkung: bemerkung || null,
|
||||||
updatedBy,
|
updatedBy,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
});
|
||||||
io.emit('vehicle:statusChanged', payload);
|
|
||||||
logger.debug('Emitted vehicle:statusChanged via Socket.IO', { vehicleId: id });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
logger.error('VehicleService.updateVehicleStatus failed', { error, id });
|
logger.error('VehicleService.updateVehicleStatus failed', { error, id });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
} finally {
|
||||||
}
|
client.release();
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// INSPECTIONS
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a new inspection entry.
|
|
||||||
* Automatically calculates naechste_faelligkeit based on standard intervals
|
|
||||||
* when durchgefuehrt_am is provided and the art has a known interval.
|
|
||||||
*/
|
|
||||||
async addPruefung(
|
|
||||||
fahrzeugId: string,
|
|
||||||
data: CreatePruefungData,
|
|
||||||
createdBy: string
|
|
||||||
): Promise<FahrzeugPruefung> {
|
|
||||||
try {
|
|
||||||
// Auto-calculate naechste_faelligkeit
|
|
||||||
let naechsteFaelligkeit: string | null = null;
|
|
||||||
|
|
||||||
if (data.durchgefuehrt_am) {
|
|
||||||
const intervalMonths = PruefungIntervalMonths[data.pruefung_art];
|
|
||||||
if (intervalMonths !== undefined) {
|
|
||||||
const durchgefuehrt = new Date(data.durchgefuehrt_am);
|
|
||||||
naechsteFaelligkeit = addMonths(durchgefuehrt, intervalMonths)
|
|
||||||
.toISOString()
|
|
||||||
.split('T')[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO fahrzeug_pruefungen (
|
|
||||||
fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am,
|
|
||||||
ergebnis, naechste_faelligkeit, pruefende_stelle,
|
|
||||||
kosten, dokument_url, bemerkung, erfasst_von
|
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
||||||
RETURNING *`,
|
|
||||||
[
|
|
||||||
fahrzeugId,
|
|
||||||
data.pruefung_art,
|
|
||||||
data.faellig_am,
|
|
||||||
data.durchgefuehrt_am ?? null,
|
|
||||||
data.ergebnis ?? 'ausstehend',
|
|
||||||
naechsteFaelligkeit,
|
|
||||||
data.pruefende_stelle ?? null,
|
|
||||||
data.kosten ?? null,
|
|
||||||
data.dokument_url ?? null,
|
|
||||||
data.bemerkung ?? null,
|
|
||||||
createdBy,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pruefung = result.rows[0] as FahrzeugPruefung;
|
|
||||||
logger.info('Pruefung added', {
|
|
||||||
pruefungId: pruefung.id,
|
|
||||||
fahrzeugId,
|
|
||||||
art: data.pruefung_art,
|
|
||||||
by: createdBy,
|
|
||||||
});
|
|
||||||
return pruefung;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('VehicleService.addPruefung failed', { error, fahrzeugId });
|
|
||||||
throw new Error('Failed to add inspection record');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the full inspection history for a specific vehicle,
|
|
||||||
* ordered newest-first.
|
|
||||||
*/
|
|
||||||
async getPruefungenForVehicle(fahrzeugId: string): Promise<FahrzeugPruefung[]> {
|
|
||||||
try {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT * FROM fahrzeug_pruefungen
|
|
||||||
WHERE fahrzeug_id = $1
|
|
||||||
ORDER BY faellig_am DESC, created_at DESC`,
|
|
||||||
[fahrzeugId]
|
|
||||||
);
|
|
||||||
return result.rows.map(r => ({
|
|
||||||
...r,
|
|
||||||
kosten: r.kosten != null ? Number(r.kosten) : null,
|
|
||||||
})) as FahrzeugPruefung[];
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('VehicleService.getPruefungenForVehicle failed', { error, fahrzeugId });
|
|
||||||
throw new Error('Failed to fetch inspection history');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all upcoming or overdue inspections within the given lookahead window.
|
|
||||||
* Used by the dashboard InspectionAlerts panel.
|
|
||||||
*
|
|
||||||
* @param daysAhead - How many days into the future to look (e.g. 30).
|
|
||||||
* Pass a very large number (e.g. 9999) to include all overdue too.
|
|
||||||
*/
|
|
||||||
async getUpcomingInspections(daysAhead: number): Promise<InspectionAlert[]> {
|
|
||||||
try {
|
|
||||||
// We include already-overdue inspections (tage < 0) AND upcoming within window.
|
|
||||||
// Only open (not yet completed) inspections are relevant.
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
p.id AS pruefung_id,
|
|
||||||
p.fahrzeug_id,
|
|
||||||
p.pruefung_art,
|
|
||||||
p.faellig_am,
|
|
||||||
(p.faellig_am::date - CURRENT_DATE) AS tage,
|
|
||||||
f.bezeichnung,
|
|
||||||
f.kurzname
|
|
||||||
FROM fahrzeug_pruefungen p
|
|
||||||
JOIN fahrzeuge f ON f.id = p.fahrzeug_id
|
|
||||||
WHERE
|
|
||||||
p.durchgefuehrt_am IS NULL
|
|
||||||
AND (p.faellig_am::date - CURRENT_DATE) <= $1
|
|
||||||
ORDER BY p.faellig_am ASC`,
|
|
||||||
[daysAhead]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows.map((row) => ({
|
|
||||||
fahrzeugId: row.fahrzeug_id,
|
|
||||||
bezeichnung: row.bezeichnung,
|
|
||||||
kurzname: row.kurzname,
|
|
||||||
pruefungId: row.pruefung_id,
|
|
||||||
pruefungArt: row.pruefung_art as PruefungArt,
|
|
||||||
faelligAm: row.faellig_am,
|
|
||||||
tage: parseInt(row.tage, 10),
|
|
||||||
})) as InspectionAlert[];
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead });
|
|
||||||
throw new Error('Failed to fetch inspection alerts');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +281,14 @@ class VehicleService {
|
|||||||
createdBy: string
|
createdBy: string
|
||||||
): Promise<FahrzeugWartungslog> {
|
): Promise<FahrzeugWartungslog> {
|
||||||
try {
|
try {
|
||||||
|
const check = await pool.query(
|
||||||
|
`SELECT 1 FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[fahrzeugId]
|
||||||
|
);
|
||||||
|
if (check.rows.length === 0) {
|
||||||
|
throw new Error('Vehicle not found');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO fahrzeug_wartungslog (
|
`INSERT INTO fahrzeug_wartungslog (
|
||||||
fahrzeug_id, datum, art, beschreibung,
|
fahrzeug_id, datum, art, beschreibung,
|
||||||
@@ -529,15 +309,11 @@ class VehicleService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const entry = result.rows[0] as FahrzeugWartungslog;
|
const entry = result.rows[0] as FahrzeugWartungslog;
|
||||||
logger.info('Wartungslog entry added', {
|
logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy });
|
||||||
entryId: entry.id,
|
|
||||||
fahrzeugId,
|
|
||||||
by: createdBy,
|
|
||||||
});
|
|
||||||
return entry;
|
return entry;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('VehicleService.addWartungslog failed', { error, fahrzeugId });
|
logger.error('VehicleService.addWartungslog failed', { error, fahrzeugId });
|
||||||
throw new Error('Failed to add maintenance log entry');
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,14 +339,9 @@ class VehicleService {
|
|||||||
// DASHBOARD KPI
|
// DASHBOARD KPI
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns aggregate counts for the dashboard stats strip.
|
|
||||||
* inspectionsDue = vehicles with at least one inspection due within 30 days
|
|
||||||
* inspectionsOverdue = vehicles with at least one inspection already overdue
|
|
||||||
*/
|
|
||||||
async getVehicleStats(): Promise<VehicleStats> {
|
async getVehicleStats(): Promise<VehicleStats> {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const totalsResult = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
|
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
|
||||||
@@ -579,22 +350,31 @@ class VehicleService {
|
|||||||
) AS ausser_dienst,
|
) AS ausser_dienst,
|
||||||
COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang
|
COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang
|
||||||
FROM fahrzeuge
|
FROM fahrzeuge
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const alertResult = await pool.query(`
|
const alertResult = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT fahrzeug_id) FILTER (
|
COUNT(*) FILTER (
|
||||||
WHERE (faellig_am::date - CURRENT_DATE) BETWEEN 0 AND 30
|
WHERE (
|
||||||
|
(paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE BETWEEN 0 AND 30)
|
||||||
|
OR
|
||||||
|
(naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE BETWEEN 0 AND 30)
|
||||||
|
)
|
||||||
) AS inspections_due,
|
) AS inspections_due,
|
||||||
COUNT(DISTINCT fahrzeug_id) FILTER (
|
COUNT(*) FILTER (
|
||||||
WHERE faellig_am::date < CURRENT_DATE
|
WHERE (
|
||||||
|
(paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date < CURRENT_DATE)
|
||||||
|
OR
|
||||||
|
(naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date < CURRENT_DATE)
|
||||||
|
)
|
||||||
) AS inspections_overdue
|
) AS inspections_overdue
|
||||||
FROM fahrzeug_pruefungen
|
FROM fahrzeuge
|
||||||
WHERE durchgefuehrt_am IS NULL
|
WHERE deleted_at IS NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const totals = result.rows[0];
|
const totals = totalsResult.rows[0];
|
||||||
const alerts = alertResult.rows[0];
|
const alerts = alertResult.rows[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: parseInt(totals.total, 10),
|
total: parseInt(totals.total, 10),
|
||||||
@@ -609,6 +389,73 @@ class VehicleService {
|
|||||||
throw new Error('Failed to fetch vehicle stats');
|
throw new Error('Failed to fetch vehicle stats');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUpcomingInspections(daysAhead: number): Promise<InspectionAlert[]> {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
id AS fahrzeug_id,
|
||||||
|
bezeichnung,
|
||||||
|
kurzname,
|
||||||
|
paragraph57a_faellig_am,
|
||||||
|
paragraph57a_faellig_am::date - CURRENT_DATE AS paragraph57a_tage,
|
||||||
|
naechste_wartung_am,
|
||||||
|
naechste_wartung_am::date - CURRENT_DATE AS wartung_tage
|
||||||
|
FROM fahrzeuge
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
(paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE <= $1)
|
||||||
|
OR
|
||||||
|
(naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE <= $1)
|
||||||
|
)
|
||||||
|
ORDER BY LEAST(
|
||||||
|
CASE WHEN paragraph57a_faellig_am IS NOT NULL
|
||||||
|
THEN paragraph57a_faellig_am::date - CURRENT_DATE END,
|
||||||
|
CASE WHEN naechste_wartung_am IS NOT NULL
|
||||||
|
THEN naechste_wartung_am::date - CURRENT_DATE END
|
||||||
|
) ASC NULLS LAST`,
|
||||||
|
[daysAhead]
|
||||||
|
);
|
||||||
|
|
||||||
|
const alerts: InspectionAlert[] = [];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
if (row.paragraph57a_faellig_am !== null && row.paragraph57a_tage !== null) {
|
||||||
|
const tage = parseInt(row.paragraph57a_tage, 10);
|
||||||
|
if (tage <= daysAhead) {
|
||||||
|
alerts.push({
|
||||||
|
fahrzeugId: row.fahrzeug_id,
|
||||||
|
bezeichnung: row.bezeichnung,
|
||||||
|
kurzname: row.kurzname,
|
||||||
|
type: '57a',
|
||||||
|
faelligAm: row.paragraph57a_faellig_am,
|
||||||
|
tage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.naechste_wartung_am !== null && row.wartung_tage !== null) {
|
||||||
|
const tage = parseInt(row.wartung_tage, 10);
|
||||||
|
if (tage <= daysAhead) {
|
||||||
|
alerts.push({
|
||||||
|
fahrzeugId: row.fahrzeug_id,
|
||||||
|
bezeichnung: row.bezeichnung,
|
||||||
|
kurzname: row.kurzname,
|
||||||
|
type: 'wartung',
|
||||||
|
faelligAm: row.naechste_wartung_am,
|
||||||
|
tage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts.sort((a, b) => a.tage - b.tage);
|
||||||
|
return alerts;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead });
|
||||||
|
throw new Error('Failed to fetch inspection alerts');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new VehicleService();
|
export default new VehicleService();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Einsaetze from './pages/Einsaetze';
|
|||||||
import EinsatzDetail from './pages/EinsatzDetail';
|
import EinsatzDetail from './pages/EinsatzDetail';
|
||||||
import Fahrzeuge from './pages/Fahrzeuge';
|
import Fahrzeuge from './pages/Fahrzeuge';
|
||||||
import FahrzeugDetail from './pages/FahrzeugDetail';
|
import FahrzeugDetail from './pages/FahrzeugDetail';
|
||||||
|
import FahrzeugForm from './pages/FahrzeugForm';
|
||||||
import Ausruestung from './pages/Ausruestung';
|
import Ausruestung from './pages/Ausruestung';
|
||||||
import Mitglieder from './pages/Mitglieder';
|
import Mitglieder from './pages/Mitglieder';
|
||||||
import MitgliedDetail from './pages/MitgliedDetail';
|
import MitgliedDetail from './pages/MitgliedDetail';
|
||||||
@@ -76,6 +77,22 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/fahrzeuge/neu"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<FahrzeugForm />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/fahrzeuge/:id/bearbeiten"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<FahrzeugForm />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/fahrzeuge/:id"
|
path="/fahrzeuge/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ import {
|
|||||||
AlertTitle,
|
AlertTitle,
|
||||||
Box,
|
Box,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Collapse,
|
|
||||||
Link,
|
Link,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import { vehiclesApi } from '../../services/vehicles';
|
import { vehiclesApi } from '../../services/vehicles';
|
||||||
import { InspectionAlert, PruefungArtLabel, PruefungArt } from '../../types/vehicle.types';
|
import { InspectionAlert, InspectionAlertType } from '../../types/vehicle.types';
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString('de-DE', {
|
return new Date(iso).toLocaleDateString('de-DE', {
|
||||||
@@ -22,6 +19,10 @@ function formatDate(iso: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function alertTypeLabel(type: InspectionAlertType): string {
|
||||||
|
return type === '57a' ? '§57a Periodische Prüfung' : 'Nächste Wartung / Service';
|
||||||
|
}
|
||||||
|
|
||||||
type Urgency = 'overdue' | 'urgent' | 'warning';
|
type Urgency = 'overdue' | 'urgent' | 'warning';
|
||||||
|
|
||||||
function getUrgency(tage: number): Urgency {
|
function getUrgency(tage: number): Urgency {
|
||||||
@@ -36,12 +37,8 @@ const URGENCY_CONFIG: Record<Urgency, { severity: 'error' | 'warning'; label: st
|
|||||||
warning: { severity: 'warning', label: 'Fällig in Kürze (≤ 30 Tage)' },
|
warning: { severity: 'warning', label: 'Fällig in Kürze (≤ 30 Tage)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface InspectionAlertsProps {
|
interface InspectionAlertsProps {
|
||||||
/** How many days ahead to fetch — default 30 */
|
|
||||||
daysAhead?: number;
|
daysAhead?: number;
|
||||||
/** Collapse into a single banner if no alerts */
|
|
||||||
hideWhenEmpty?: boolean;
|
hideWhenEmpty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +52,6 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
const fetchAlerts = async () => {
|
const fetchAlerts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -68,7 +64,6 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
|||||||
if (mounted) setLoading(false);
|
if (mounted) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAlerts();
|
fetchAlerts();
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, [daysAhead]);
|
}, [daysAhead]);
|
||||||
@@ -92,12 +87,11 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
|||||||
if (hideWhenEmpty) return null;
|
if (hideWhenEmpty) return null;
|
||||||
return (
|
return (
|
||||||
<Alert severity="success">
|
<Alert severity="success">
|
||||||
Alle Prüfungsfristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen.
|
Alle Fristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen.
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by urgency
|
|
||||||
const overdue = alerts.filter((a) => a.tage < 0);
|
const overdue = alerts.filter((a) => a.tage < 0);
|
||||||
const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14);
|
const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14);
|
||||||
const warning = alerts.filter((a) => a.tage > 14);
|
const warning = alerts.filter((a) => a.tage > 14);
|
||||||
@@ -117,35 +111,37 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
|||||||
<AlertTitle sx={{ fontWeight: 600 }}>{label}</AlertTitle>
|
<AlertTitle sx={{ fontWeight: 600 }}>{label}</AlertTitle>
|
||||||
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
||||||
{items.map((alert) => {
|
{items.map((alert) => {
|
||||||
const artLabel = PruefungArtLabel[alert.pruefungArt as PruefungArt] ?? alert.pruefungArt;
|
const typeLabel = alertTypeLabel(alert.type);
|
||||||
const dateStr = formatDate(alert.faelligAm);
|
const dateStr = formatDate(alert.faelligAm);
|
||||||
const tageText = alert.tage < 0
|
const tageText = alert.tage < 0
|
||||||
? `seit ${Math.abs(alert.tage)} Tag${Math.abs(alert.tage) === 1 ? '' : 'en'} überfällig`
|
? `seit ${Math.abs(alert.tage)} Tag${Math.abs(alert.tage) === 1 ? '' : 'en'} überfällig`
|
||||||
: alert.tage === 0
|
: alert.tage === 0
|
||||||
? 'heute fällig'
|
? 'heute fällig'
|
||||||
: `fällig in ${alert.tage} Tag${alert.tage === 1 ? '' : 'en'}`;
|
: `fällig in ${alert.tage} Tag${alert.tage === 1 ? '' : 'en'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse key={alert.pruefungId} in timeout="auto">
|
<Box
|
||||||
<Box component="li" sx={{ mb: 0.5 }}>
|
key={`${alert.fahrzeugId}-${alert.type}`}
|
||||||
<Link
|
component="li"
|
||||||
component={RouterLink}
|
sx={{ mb: 0.5 }}
|
||||||
to={`/fahrzeuge/${alert.fahrzeugId}`}
|
>
|
||||||
color="inherit"
|
<Link
|
||||||
underline="hover"
|
component={RouterLink}
|
||||||
sx={{ fontWeight: 500 }}
|
to={`/fahrzeuge/${alert.fahrzeugId}`}
|
||||||
>
|
color="inherit"
|
||||||
{alert.bezeichnung}
|
underline="hover"
|
||||||
{alert.kurzname ? ` (${alert.kurzname})` : ''}
|
sx={{ fontWeight: 500 }}
|
||||||
</Link>
|
>
|
||||||
{' — '}
|
{alert.bezeichnung}
|
||||||
<strong>{artLabel}</strong>
|
{alert.kurzname ? ` (${alert.kurzname})` : ''}
|
||||||
{' '}
|
</Link>
|
||||||
<Typography component="span" variant="body2">
|
{' — '}
|
||||||
{tageText} ({dateStr})
|
<strong>{typeLabel}</strong>
|
||||||
</Typography>
|
{' '}
|
||||||
</Box>
|
<Typography component="span" variant="body2">
|
||||||
</Collapse>
|
{tageText} ({dateStr})
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -22,13 +22,6 @@ import {
|
|||||||
Tab,
|
Tab,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
Timeline,
|
|
||||||
TimelineConnector,
|
|
||||||
TimelineContent,
|
|
||||||
TimelineDot,
|
|
||||||
TimelineItem,
|
|
||||||
TimelineOppositeContent,
|
|
||||||
TimelineSeparator,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
@@ -39,6 +32,7 @@ import {
|
|||||||
Build,
|
Build,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
DirectionsCar,
|
DirectionsCar,
|
||||||
|
Edit,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
LocalFireDepartment,
|
LocalFireDepartment,
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
@@ -51,16 +45,12 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|||||||
import { vehiclesApi } from '../services/vehicles';
|
import { vehiclesApi } from '../services/vehicles';
|
||||||
import {
|
import {
|
||||||
FahrzeugDetail,
|
FahrzeugDetail,
|
||||||
FahrzeugPruefung,
|
|
||||||
FahrzeugWartungslog,
|
FahrzeugWartungslog,
|
||||||
FahrzeugStatus,
|
FahrzeugStatus,
|
||||||
FahrzeugStatusLabel,
|
FahrzeugStatusLabel,
|
||||||
PruefungArt,
|
|
||||||
PruefungArtLabel,
|
|
||||||
CreatePruefungPayload,
|
|
||||||
CreateWartungslogPayload,
|
CreateWartungslogPayload,
|
||||||
|
UpdateStatusPayload,
|
||||||
WartungslogArt,
|
WartungslogArt,
|
||||||
PruefungErgebnis,
|
|
||||||
} from '../types/vehicle.types';
|
} from '../types/vehicle.types';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
@@ -125,11 +115,24 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setNewStatus(vehicle.status);
|
||||||
|
setBemerkung(vehicle.status_bemerkung ?? '');
|
||||||
|
setSaveError(null);
|
||||||
|
setStatusDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setSaveError(null);
|
||||||
|
setStatusDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveStatus = async () => {
|
const handleSaveStatus = async () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
await vehiclesApi.updateStatus(vehicle.id, { status: newStatus, bemerkung });
|
const payload: UpdateStatusPayload = { status: newStatus, bemerkung };
|
||||||
|
await vehiclesApi.updateStatus(vehicle.id, payload);
|
||||||
setStatusDialogOpen(false);
|
setStatusDialogOpen(false);
|
||||||
onStatusUpdated();
|
onStatusUpdated();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -141,6 +144,12 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
|
|
||||||
const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden;
|
const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden;
|
||||||
|
|
||||||
|
// Inspection deadline badges
|
||||||
|
const inspItems: { label: string; faelligAm: string | null; tage: number | null }[] = [
|
||||||
|
{ label: '§57a Periodische Prüfung', faelligAm: vehicle.paragraph57a_faellig_am, tage: vehicle.paragraph57a_tage_bis_faelligkeit },
|
||||||
|
{ label: 'Nächste Wartung / Service', faelligAm: vehicle.naechste_wartung_am, tage: vehicle.wartung_tage_bis_faelligkeit },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{isSchaden && (
|
{isSchaden && (
|
||||||
@@ -156,9 +165,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
{STATUS_ICONS[vehicle.status]}
|
{STATUS_ICONS[vehicle.status]}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" fontWeight={600}>
|
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
|
||||||
Aktueller Status
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
<Chip
|
||||||
label={FahrzeugStatusLabel[vehicle.status]}
|
label={FahrzeugStatusLabel[vehicle.status]}
|
||||||
color={STATUS_CHIP_COLOR[vehicle.status]}
|
color={STATUS_CHIP_COLOR[vehicle.status]}
|
||||||
@@ -171,18 +178,11 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
{canChangeStatus && (
|
||||||
variant="outlined"
|
<Button variant="outlined" size="small" onClick={openDialog}>
|
||||||
size="small"
|
Status ändern
|
||||||
onClick={() => {
|
</Button>
|
||||||
setNewStatus(vehicle.status);
|
)}
|
||||||
setBemerkung(vehicle.status_bemerkung ?? '');
|
|
||||||
setStatusDialogOpen(true);
|
|
||||||
}}
|
|
||||||
sx={{ display: canChangeStatus ? undefined : 'none' }}
|
|
||||||
>
|
|
||||||
Status ändern
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -198,8 +198,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
|
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
|
||||||
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
|
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
|
||||||
{ label: 'Standort', value: vehicle.standort },
|
{ label: 'Standort', value: vehicle.standort },
|
||||||
{ label: '§57a fällig am', value: fmtDate(vehicle.paragraph57a_faellig_am) !== '—' ? fmtDate(vehicle.paragraph57a_faellig_am) : null },
|
|
||||||
{ label: 'Nächste Wartung', value: fmtDate(vehicle.naechste_wartung_am) !== '—' ? fmtDate(vehicle.naechste_wartung_am) : null },
|
|
||||||
].map(({ label, value }) => (
|
].map(({ label, value }) => (
|
||||||
<Grid item xs={12} sm={6} md={4} key={label}>
|
<Grid item xs={12} sm={6} md={4} key={label}>
|
||||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
||||||
@@ -210,48 +208,40 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Inspection status quick view */}
|
{/* Inspection deadline quick view */}
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
|
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
|
||||||
Prüffristen Übersicht
|
Prüf- und Wartungsfristen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={1.5}>
|
<Grid container spacing={1.5}>
|
||||||
{Object.entries(vehicle.pruefstatus).map(([key, ps]) => {
|
{inspItems.map(({ label, faelligAm, tage }) => {
|
||||||
const art = key.toUpperCase() as PruefungArt;
|
const color = inspectionBadgeColor(tage);
|
||||||
const label = PruefungArtLabel[art] ?? key;
|
|
||||||
const color = inspectionBadgeColor(ps.tage_bis_faelligkeit);
|
|
||||||
return (
|
return (
|
||||||
<Grid item xs={12} sm={6} md={3} key={key}>
|
<Grid item xs={12} sm={6} key={label}>
|
||||||
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
{ps.faellig_am ? (
|
{faelligAm ? (
|
||||||
<>
|
<>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
color={color}
|
color={color}
|
||||||
label={
|
label={
|
||||||
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
|
tage !== null && tage < 0
|
||||||
? `ÜBERFÄLLIG (${fmtDate(ps.faellig_am)})`
|
? `ÜBERFÄLLIG (${fmtDate(faelligAm)})`
|
||||||
: `Fällig: ${fmtDate(ps.faellig_am)}`
|
: `Fällig: ${fmtDate(faelligAm)}`
|
||||||
}
|
|
||||||
icon={
|
|
||||||
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
|
|
||||||
? <Warning fontSize="small" />
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
|
icon={tage !== null && tage < 0 ? <Warning fontSize="small" /> : undefined}
|
||||||
sx={{ mt: 0.5 }}
|
sx={{ mt: 0.5 }}
|
||||||
/>
|
/>
|
||||||
{ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit >= 0 && (
|
{tage !== null && tage >= 0 && (
|
||||||
<Typography variant="caption" display="block" color="text.secondary">
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
in {ps.tage_bis_faelligkeit} Tagen
|
in {tage} Tagen
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body2" color="text.disabled">
|
<Typography variant="body2" color="text.disabled">Kein Datum erfasst</Typography>
|
||||||
Keine Daten
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -260,12 +250,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Status change dialog */}
|
{/* Status change dialog */}
|
||||||
<Dialog
|
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||||
open={statusDialogOpen}
|
|
||||||
onClose={() => setStatusDialogOpen(false)}
|
|
||||||
maxWidth="sm"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>Fahrzeugstatus ändern</DialogTitle>
|
<DialogTitle>Fahrzeugstatus ändern</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||||
@@ -278,9 +263,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
onChange={(e) => setNewStatus(e.target.value as FahrzeugStatus)}
|
onChange={(e) => setNewStatus(e.target.value as FahrzeugStatus)}
|
||||||
>
|
>
|
||||||
{Object.values(FahrzeugStatus).map((s) => (
|
{Object.values(FahrzeugStatus).map((s) => (
|
||||||
<MenuItem key={s} value={s}>
|
<MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
|
||||||
{FahrzeugStatusLabel[s]}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -295,7 +278,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setStatusDialogOpen(false)}>Abbrechen</Button>
|
<Button onClick={closeDialog}>Abbrechen</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSaveStatus}
|
onClick={handleSaveStatus}
|
||||||
@@ -310,247 +293,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Prüfungen Tab ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface PruefungenTabProps {
|
|
||||||
fahrzeugId: string;
|
|
||||||
pruefungen: FahrzeugPruefung[];
|
|
||||||
onAdded: () => void;
|
|
||||||
canWrite: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ERGEBNIS_LABELS: Record<PruefungErgebnis, string> = {
|
|
||||||
bestanden: 'Bestanden',
|
|
||||||
bestanden_mit_maengeln: 'Bestanden mit Mängeln',
|
|
||||||
nicht_bestanden: 'Nicht bestanden',
|
|
||||||
ausstehend: 'Ausstehend',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ERGEBNIS_COLORS: Record<PruefungErgebnis, 'success' | 'warning' | 'error' | 'default'> = {
|
|
||||||
bestanden: 'success',
|
|
||||||
bestanden_mit_maengeln: 'warning',
|
|
||||||
nicht_bestanden: 'error',
|
|
||||||
ausstehend: 'default',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, onAdded, canWrite }) => {
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const emptyForm: CreatePruefungPayload = {
|
|
||||||
pruefung_art: PruefungArt.HU,
|
|
||||||
faellig_am: '',
|
|
||||||
durchgefuehrt_am: '',
|
|
||||||
ergebnis: 'ausstehend',
|
|
||||||
pruefende_stelle: '',
|
|
||||||
kosten: undefined,
|
|
||||||
bemerkung: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const [form, setForm] = useState<CreatePruefungPayload>(emptyForm);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!form.faellig_am) {
|
|
||||||
setSaveError('Fälligkeitsdatum ist erforderlich.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setSaving(true);
|
|
||||||
setSaveError(null);
|
|
||||||
const payload: CreatePruefungPayload = {
|
|
||||||
...form,
|
|
||||||
durchgefuehrt_am: form.durchgefuehrt_am || undefined,
|
|
||||||
kosten: form.kosten !== undefined && form.kosten !== null ? Number(form.kosten) : undefined,
|
|
||||||
};
|
|
||||||
await vehiclesApi.addPruefung(fahrzeugId, payload);
|
|
||||||
setDialogOpen(false);
|
|
||||||
setForm(emptyForm);
|
|
||||||
onAdded();
|
|
||||||
} catch {
|
|
||||||
setSaveError('Prüfung konnte nicht gespeichert werden.');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{pruefungen.length === 0 ? (
|
|
||||||
<Typography color="text.secondary">Noch keine Prüfungen erfasst.</Typography>
|
|
||||||
) : (
|
|
||||||
<Stack divider={<Divider />} spacing={0}>
|
|
||||||
{pruefungen.map((p) => {
|
|
||||||
const ergebnis = (p.ergebnis ?? 'ausstehend') as PruefungErgebnis;
|
|
||||||
const isFaellig = !p.durchgefuehrt_am && new Date(p.faellig_am) < new Date();
|
|
||||||
return (
|
|
||||||
<Box key={p.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
|
||||||
<Box sx={{ minWidth: 140 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{PruefungArtLabel[p.pruefung_art] ?? p.pruefung_art}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" fontWeight={600}>
|
|
||||||
Fällig: {fmtDate(p.faellig_am)}
|
|
||||||
</Typography>
|
|
||||||
{isFaellig && !p.durchgefuehrt_am && (
|
|
||||||
<Chip label="ÜBERFÄLLIG" color="error" size="small" sx={{ mt: 0.5 }} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 0.5 }}>
|
|
||||||
<Chip
|
|
||||||
label={ERGEBNIS_LABELS[ergebnis]}
|
|
||||||
color={ERGEBNIS_COLORS[ergebnis]}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
{p.durchgefuehrt_am && (
|
|
||||||
<Chip
|
|
||||||
label={`Durchgeführt: ${fmtDate(p.durchgefuehrt_am)}`}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{p.naechste_faelligkeit && (
|
|
||||||
<Chip
|
|
||||||
label={`Nächste: ${fmtDate(p.naechste_faelligkeit)}`}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{p.pruefende_stelle && (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{p.pruefende_stelle}
|
|
||||||
{p.kosten != null && ` · ${p.kosten.toFixed(2)} €`}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{p.bemerkung && (
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
||||||
{p.bemerkung}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* FAB */}
|
|
||||||
{canWrite && (
|
|
||||||
<Fab
|
|
||||||
color="primary"
|
|
||||||
size="small"
|
|
||||||
aria-label="Prüfung hinzufügen"
|
|
||||||
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
|
||||||
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
|
|
||||||
>
|
|
||||||
<Add />
|
|
||||||
</Fab>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add inspection dialog */}
|
|
||||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
|
||||||
<DialogTitle>Prüfung erfassen</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
|
||||||
|
|
||||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
|
||||||
<Grid item xs={12} sm={6}>
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>Prüfungsart</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Prüfungsart"
|
|
||||||
value={form.pruefung_art}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, pruefung_art: e.target.value as PruefungArt }))}
|
|
||||||
>
|
|
||||||
{Object.values(PruefungArt).map((art) => (
|
|
||||||
<MenuItem key={art} value={art}>{PruefungArtLabel[art]}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={6}>
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>Ergebnis</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Ergebnis"
|
|
||||||
value={form.ergebnis ?? 'ausstehend'}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, ergebnis: e.target.value as PruefungErgebnis }))}
|
|
||||||
>
|
|
||||||
{(Object.keys(ERGEBNIS_LABELS) as PruefungErgebnis[]).map((e) => (
|
|
||||||
<MenuItem key={e} value={e}>{ERGEBNIS_LABELS[e]}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={6}>
|
|
||||||
<TextField
|
|
||||||
label="Fällig am *"
|
|
||||||
type="date"
|
|
||||||
fullWidth
|
|
||||||
value={form.faellig_am}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, faellig_am: e.target.value }))}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={6}>
|
|
||||||
<TextField
|
|
||||||
label="Durchgeführt am"
|
|
||||||
type="date"
|
|
||||||
fullWidth
|
|
||||||
value={form.durchgefuehrt_am ?? ''}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, durchgefuehrt_am: e.target.value }))}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={8}>
|
|
||||||
<TextField
|
|
||||||
label="Prüfende Stelle"
|
|
||||||
fullWidth
|
|
||||||
value={form.pruefende_stelle ?? ''}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
|
|
||||||
placeholder="z.B. TÜV Süd Stuttgart"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={4}>
|
|
||||||
<TextField
|
|
||||||
label="Kosten (€)"
|
|
||||||
type="number"
|
|
||||||
fullWidth
|
|
||||||
value={form.kosten ?? ''}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))}
|
|
||||||
inputProps={{ min: 0, step: 0.01 }}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<TextField
|
|
||||||
label="Bemerkung"
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
rows={2}
|
|
||||||
value={form.bemerkung ?? ''}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, bemerkung: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={saving}
|
|
||||||
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Wartung Tab ───────────────────────────────────────────────────────────────
|
// ── Wartung Tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface WartungTabProps {
|
interface WartungTabProps {
|
||||||
@@ -561,11 +303,11 @@ interface WartungTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
|
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
|
||||||
Kraftstoff: <LocalFireDepartment color="action" />,
|
Kraftstoff: <LocalFireDepartment color="action" />,
|
||||||
Reparatur: <Build color="warning" />,
|
Reparatur: <Build color="warning" />,
|
||||||
Inspektion: <Assignment color="primary" />,
|
Inspektion: <Assignment color="primary" />,
|
||||||
Hauptuntersuchung:<CheckCircle color="success" />,
|
Hauptuntersuchung: <CheckCircle color="success" />,
|
||||||
default: <Build color="action" />,
|
default: <Build color="action" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => {
|
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => {
|
||||||
@@ -612,8 +354,6 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
{wartungslog.length === 0 ? (
|
{wartungslog.length === 0 ? (
|
||||||
<Typography color="text.secondary">Noch keine Wartungseinträge erfasst.</Typography>
|
<Typography color="text.secondary">Noch keine Wartungseinträge erfasst.</Typography>
|
||||||
) : (
|
) : (
|
||||||
// MUI Timeline is available via @mui/lab — using Paper list as fallback
|
|
||||||
// since @mui/lab is not in current package.json
|
|
||||||
<Stack divider={<Divider />} spacing={0}>
|
<Stack divider={<Divider />} spacing={0}>
|
||||||
{wartungslog.map((entry) => {
|
{wartungslog.map((entry) => {
|
||||||
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
|
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
|
||||||
@@ -623,9 +363,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
||||||
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
|
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
|
||||||
{entry.art && (
|
{entry.art && <Chip label={entry.art} size="small" variant="outlined" />}
|
||||||
<Chip label={entry.art} size="small" variant="outlined" />
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2">{entry.beschreibung}</Typography>
|
<Typography variant="body2">{entry.beschreibung}</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||||
@@ -795,11 +533,7 @@ function FahrzeugDetail() {
|
|||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Alert severity="error">{error ?? 'Fahrzeug nicht gefunden.'}</Alert>
|
<Alert severity="error">{error ?? 'Fahrzeug nicht gefunden.'}</Alert>
|
||||||
<Button
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
|
||||||
startIcon={<ArrowBack />}
|
|
||||||
onClick={() => navigate('/fahrzeuge')}
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
>
|
|
||||||
Zurück zur Übersicht
|
Zurück zur Übersicht
|
||||||
</Button>
|
</Button>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -807,10 +541,13 @@ function FahrzeugDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasOverdue =
|
||||||
|
(vehicle.paragraph57a_tage_bis_faelligkeit !== null && vehicle.paragraph57a_tage_bis_faelligkeit < 0) ||
|
||||||
|
(vehicle.wartung_tage_bis_faelligkeit !== null && vehicle.wartung_tage_bis_faelligkeit < 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
{/* Breadcrumb / back */}
|
|
||||||
<Button
|
<Button
|
||||||
startIcon={<ArrowBack />}
|
startIcon={<ArrowBack />}
|
||||||
onClick={() => navigate('/fahrzeuge')}
|
onClick={() => navigate('/fahrzeuge')}
|
||||||
@@ -820,7 +557,6 @@ function FahrzeugDetail() {
|
|||||||
Fahrzeugübersicht
|
Fahrzeugübersicht
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Page title */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||||
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
|
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
|
||||||
<Box>
|
<Box>
|
||||||
@@ -839,16 +575,26 @@ function FahrzeugDetail() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ ml: 'auto' }}>
|
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Chip
|
<Chip
|
||||||
icon={STATUS_ICONS[vehicle.status]}
|
icon={STATUS_ICONS[vehicle.status]}
|
||||||
label={FahrzeugStatusLabel[vehicle.status]}
|
label={FahrzeugStatusLabel[vehicle.status]}
|
||||||
color={STATUS_CHIP_COLOR[vehicle.status]}
|
color={STATUS_CHIP_COLOR[vehicle.status]}
|
||||||
/>
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<Tooltip title="Fahrzeug bearbeiten">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)}
|
||||||
|
aria-label="Fahrzeug bearbeiten"
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
@@ -858,42 +604,35 @@ function FahrzeugDetail() {
|
|||||||
<Tab label="Übersicht" />
|
<Tab label="Übersicht" />
|
||||||
<Tab
|
<Tab
|
||||||
label={
|
label={
|
||||||
vehicle.naechste_pruefung_tage !== null && vehicle.naechste_pruefung_tage < 0
|
hasOverdue
|
||||||
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
Prüfungen <Warning color="error" fontSize="small" />
|
Wartung <Warning color="error" fontSize="small" />
|
||||||
</Box>
|
</Box>
|
||||||
: 'Prüfungen'
|
: 'Wartung'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Tab label="Wartung" />
|
|
||||||
<Tab label="Einsätze" />
|
<Tab label="Einsätze" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
<TabPanel value={activeTab} index={0}>
|
<TabPanel value={activeTab} index={0}>
|
||||||
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} canChangeStatus={canChangeStatus} />
|
<UebersichtTab
|
||||||
</TabPanel>
|
vehicle={vehicle}
|
||||||
|
onStatusUpdated={fetchVehicle}
|
||||||
<TabPanel value={activeTab} index={1}>
|
canChangeStatus={canChangeStatus}
|
||||||
<PruefungenTab
|
|
||||||
fahrzeugId={vehicle.id}
|
|
||||||
pruefungen={vehicle.pruefungen}
|
|
||||||
onAdded={fetchVehicle}
|
|
||||||
canWrite={isAdmin}
|
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={activeTab} index={2}>
|
<TabPanel value={activeTab} index={1}>
|
||||||
<WartungTab
|
<WartungTab
|
||||||
fahrzeugId={vehicle.id}
|
fahrzeugId={vehicle.id}
|
||||||
wartungslog={vehicle.wartungslog}
|
wartungslog={vehicle.wartungslog}
|
||||||
onAdded={fetchVehicle}
|
onAdded={fetchVehicle}
|
||||||
canWrite={isAdmin}
|
canWrite={canChangeStatus}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={activeTab} index={3}>
|
<TabPanel value={activeTab} index={2}>
|
||||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||||
<Typography variant="h6" color="text.secondary">
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
|||||||
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 React, { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
CardActionArea,
|
CardActionArea,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
Alert,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Add,
|
Add,
|
||||||
@@ -35,8 +35,6 @@ import {
|
|||||||
FahrzeugListItem,
|
FahrzeugListItem,
|
||||||
FahrzeugStatus,
|
FahrzeugStatus,
|
||||||
FahrzeugStatusLabel,
|
FahrzeugStatusLabel,
|
||||||
PruefungArt,
|
|
||||||
PruefungArtLabel,
|
|
||||||
} from '../types/vehicle.types';
|
} from '../types/vehicle.types';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
@@ -64,13 +62,23 @@ function inspBadgeColor(tage: number | null): InspBadgeColor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inspBadgeLabel(art: string, tage: number | null, faelligAm: string | null): string {
|
function inspBadgeLabel(art: string, tage: number | null, faelligAm: string | null): string {
|
||||||
const artShort = art; // 'HU', 'AU', etc.
|
|
||||||
if (faelligAm === null) return '';
|
if (faelligAm === null) return '';
|
||||||
const date = new Date(faelligAm).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
const date = new Date(faelligAm).toLocaleDateString('de-DE', {
|
||||||
if (tage === null) return `${artShort}: ${date}`;
|
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||||
if (tage < 0) return `${artShort}: ÜBERFÄLLIG (${date})`;
|
});
|
||||||
if (tage === 0) return `${artShort}: heute (${date})`;
|
if (tage === null) return `${art}: ${date}`;
|
||||||
return `${artShort}: ${date}`;
|
if (tage < 0) return `${art}: ÜBERFÄLLIG (${date})`;
|
||||||
|
if (tage === 0) return `${art}: heute (${date})`;
|
||||||
|
return `${art}: ${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspTooltipTitle(fullLabel: string, tage: number | null, faelligAm: string | null): string {
|
||||||
|
if (!faelligAm) return fullLabel;
|
||||||
|
const date = new Date(faelligAm).toLocaleDateString('de-DE');
|
||||||
|
if (tage !== null && tage < 0) {
|
||||||
|
return `${fullLabel}: Seit ${Math.abs(tage)} Tagen überfällig!`;
|
||||||
|
}
|
||||||
|
return `${fullLabel}: Fällig am ${date}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vehicle Card ──────────────────────────────────────────────────────────────
|
// ── Vehicle Card ──────────────────────────────────────────────────────────────
|
||||||
@@ -81,15 +89,23 @@ interface VehicleCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
||||||
const status = vehicle.status as FahrzeugStatus;
|
const status = vehicle.status as FahrzeugStatus;
|
||||||
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
|
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
|
||||||
|
|
||||||
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
|
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
|
||||||
|
|
||||||
// Collect inspection badges (only for types where a faellig_am exists)
|
const inspBadges = [
|
||||||
const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [
|
{
|
||||||
{ art: '§57a', tage: vehicle.paragraph57a_tage_bis_faelligkeit, faelligAm: vehicle.paragraph57a_faellig_am },
|
art: '§57a',
|
||||||
{ art: 'Wartung', tage: vehicle.wartung_tage_bis_faelligkeit, faelligAm: vehicle.naechste_wartung_am },
|
fullLabel: '§57a Periodische Prüfung',
|
||||||
|
tage: vehicle.paragraph57a_tage_bis_faelligkeit,
|
||||||
|
faelligAm: vehicle.paragraph57a_faellig_am,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
art: 'Wartung',
|
||||||
|
fullLabel: 'Nächste Wartung / Service',
|
||||||
|
tage: vehicle.wartung_tage_bis_faelligkeit,
|
||||||
|
faelligAm: vehicle.naechste_wartung_am,
|
||||||
|
},
|
||||||
].filter((b) => b.faelligAm !== null);
|
].filter((b) => b.faelligAm !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -116,7 +132,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
onClick={() => onClick(vehicle.id)}
|
onClick={() => onClick(vehicle.id)}
|
||||||
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||||
>
|
>
|
||||||
{/* Vehicle image / placeholder */}
|
|
||||||
{vehicle.bild_url ? (
|
{vehicle.bild_url ? (
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
@@ -140,7 +155,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<CardContent sx={{ flexGrow: 1, pb: '8px !important' }}>
|
<CardContent sx={{ flexGrow: 1, pb: '8px !important' }}>
|
||||||
{/* Title row */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" component="div" lineHeight={1.2}>
|
<Typography variant="h6" component="div" lineHeight={1.2}>
|
||||||
@@ -159,7 +173,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Status badge */}
|
|
||||||
<Box sx={{ mb: 1 }}>
|
<Box sx={{ mb: 1 }}>
|
||||||
<Chip
|
<Chip
|
||||||
icon={statusCfg.icon}
|
icon={statusCfg.icon}
|
||||||
@@ -170,7 +183,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Crew config */}
|
|
||||||
{vehicle.besatzung_soll && (
|
{vehicle.besatzung_soll && (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
Besatzung: {vehicle.besatzung_soll}
|
Besatzung: {vehicle.besatzung_soll}
|
||||||
@@ -178,7 +190,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Inspection badges */}
|
|
||||||
{inspBadges.length > 0 && (
|
{inspBadges.length > 0 && (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{inspBadges.map((b) => {
|
{inspBadges.map((b) => {
|
||||||
@@ -188,11 +199,7 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={b.art}
|
key={b.art}
|
||||||
title={`${PruefungArtLabel[b.art as PruefungArt] ?? b.art}: ${
|
title={inspTooltipTitle(b.fullLabel, b.tage, b.faelligAm)}
|
||||||
b.tage !== null && b.tage < 0
|
|
||||||
? `Seit ${Math.abs(b.tage)} Tagen überfällig!`
|
|
||||||
: `Fällig am ${new Date(b.faelligAm!).toLocaleDateString('de-DE')}`
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
@@ -249,16 +256,18 @@ function Fahrzeuge() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Summary counts
|
|
||||||
const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length;
|
const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length;
|
||||||
|
|
||||||
|
// An overdue inspection exists if §57a OR Wartung is past due
|
||||||
const hasOverdue = vehicles.some(
|
const hasOverdue = vehicles.some(
|
||||||
(v) => v.naechste_pruefung_tage !== null && v.naechste_pruefung_tage < 0
|
(v) =>
|
||||||
|
(v.paragraph57a_tage_bis_faelligkeit !== null && v.paragraph57a_tage_bis_faelligkeit < 0) ||
|
||||||
|
(v.wartung_tage_bis_faelligkeit !== null && v.wartung_tage_bis_faelligkeit < 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="xl">
|
<Container maxWidth="xl">
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||||
@@ -268,12 +277,7 @@ function Fahrzeuge() {
|
|||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt
|
{vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt
|
||||||
{' · '}
|
{' · '}
|
||||||
<Typography
|
<Typography component="span" variant="body2" color="success.main" fontWeight={600}>
|
||||||
component="span"
|
|
||||||
variant="body2"
|
|
||||||
color="success.main"
|
|
||||||
fontWeight={600}
|
|
||||||
>
|
|
||||||
{einsatzbereit} einsatzbereit
|
{einsatzbereit} einsatzbereit
|
||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -281,15 +285,12 @@ function Fahrzeuge() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Overdue inspection global warning */}
|
|
||||||
{hasOverdue && (
|
{hasOverdue && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
|
||||||
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungsfrist.
|
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
|
||||||
Betroffene Fahrzeuge dürfen bis zur Durchführung der Prüfung ggf. nicht eingesetzt werden.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search bar */}
|
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
|
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -306,21 +307,18 @@ function Fahrzeuge() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error state */}
|
|
||||||
{!loading && error && (
|
{!loading && error && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{!loading && !error && filtered.length === 0 && (
|
{!loading && !error && filtered.length === 0 && (
|
||||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||||
@@ -332,7 +330,6 @@ function Fahrzeuge() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Vehicle grid */}
|
|
||||||
{!loading && !error && filtered.length > 0 && (
|
{!loading && !error && filtered.length > 0 && (
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{filtered.map((vehicle) => (
|
{filtered.map((vehicle) => (
|
||||||
@@ -346,7 +343,6 @@ function Fahrzeuge() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Fab
|
<Fab
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
@@ -2,47 +2,31 @@ import { api } from './api';
|
|||||||
import type {
|
import type {
|
||||||
FahrzeugListItem,
|
FahrzeugListItem,
|
||||||
FahrzeugDetail,
|
FahrzeugDetail,
|
||||||
FahrzeugPruefung,
|
|
||||||
FahrzeugWartungslog,
|
FahrzeugWartungslog,
|
||||||
VehicleStats,
|
VehicleStats,
|
||||||
InspectionAlert,
|
InspectionAlert,
|
||||||
CreateFahrzeugPayload,
|
CreateFahrzeugPayload,
|
||||||
UpdateFahrzeugPayload,
|
UpdateFahrzeugPayload,
|
||||||
UpdateStatusPayload,
|
UpdateStatusPayload,
|
||||||
CreatePruefungPayload,
|
|
||||||
CreateWartungslogPayload,
|
CreateWartungslogPayload,
|
||||||
} from '../types/vehicle.types';
|
} from '../types/vehicle.types';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
async function unwrap<T>(
|
||||||
// Internal: unwrap the standard { success, data } envelope
|
promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>
|
||||||
// ---------------------------------------------------------------------------
|
): Promise<T> {
|
||||||
async function unwrap<T>(promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>): Promise<T> {
|
|
||||||
const response = await promise;
|
const response = await promise;
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Vehicle API Service
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const vehiclesApi = {
|
export const vehiclesApi = {
|
||||||
|
|
||||||
// ── Fleet overview ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Fetch all vehicles with their next inspection badge data */
|
|
||||||
async getAll(): Promise<FahrzeugListItem[]> {
|
async getAll(): Promise<FahrzeugListItem[]> {
|
||||||
return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles'));
|
return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles'));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Dashboard KPI stats */
|
|
||||||
async getStats(): Promise<VehicleStats> {
|
async getStats(): Promise<VehicleStats> {
|
||||||
return unwrap(api.get<{ success: boolean; data: VehicleStats }>('/api/vehicles/stats'));
|
return unwrap(api.get<{ success: boolean; data: VehicleStats }>('/api/vehicles/stats'));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Upcoming and overdue inspection alerts.
|
|
||||||
* @param daysAhead How many days to look ahead (default 30, max 365).
|
|
||||||
*/
|
|
||||||
async getAlerts(daysAhead = 30): Promise<InspectionAlert[]> {
|
async getAlerts(daysAhead = 30): Promise<InspectionAlert[]> {
|
||||||
return unwrap(
|
return unwrap(
|
||||||
api.get<{ success: boolean; data: InspectionAlert[] }>(
|
api.get<{ success: boolean; data: InspectionAlert[] }>(
|
||||||
@@ -51,15 +35,10 @@ export const vehiclesApi = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Vehicle detail ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Full vehicle detail including inspection history and maintenance log */
|
|
||||||
async getById(id: string): Promise<FahrzeugDetail> {
|
async getById(id: string): Promise<FahrzeugDetail> {
|
||||||
return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`));
|
return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`));
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async create(payload: CreateFahrzeugPayload): Promise<FahrzeugDetail> {
|
async create(payload: CreateFahrzeugPayload): Promise<FahrzeugDetail> {
|
||||||
const response = await api.post<{ success: boolean; data: FahrzeugDetail }>(
|
const response = await api.post<{ success: boolean; data: FahrzeugDetail }>(
|
||||||
'/api/vehicles',
|
'/api/vehicles',
|
||||||
@@ -80,29 +59,10 @@ export const vehiclesApi = {
|
|||||||
await api.delete(`/api/vehicles/${id}`);
|
await api.delete(`/api/vehicles/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Live status change — Socket.IO event is emitted server-side in Tier 3 */
|
|
||||||
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> {
|
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> {
|
||||||
await api.patch(`/api/vehicles/${id}/status`, payload);
|
await api.patch(`/api/vehicles/${id}/status`, payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Inspections ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async getPruefungen(id: string): Promise<FahrzeugPruefung[]> {
|
|
||||||
return unwrap(
|
|
||||||
api.get<{ success: boolean; data: FahrzeugPruefung[] }>(`/api/vehicles/${id}/pruefungen`)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async addPruefung(id: string, payload: CreatePruefungPayload): Promise<FahrzeugPruefung> {
|
|
||||||
const response = await api.post<{ success: boolean; data: FahrzeugPruefung }>(
|
|
||||||
`/api/vehicles/${id}/pruefungen`,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Maintenance log ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> {
|
async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> {
|
||||||
return unwrap(
|
return unwrap(
|
||||||
api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`)
|
api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Vehicle Fleet Management — Frontend Type Definitions
|
// Vehicle Fleet Management — Frontend Type Definitions
|
||||||
// Mirror of backend/src/models/vehicle.model.ts (transport layer shapes)
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export enum FahrzeugStatus {
|
export enum FahrzeugStatus {
|
||||||
@@ -17,32 +16,6 @@ export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
|||||||
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
|
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum PruefungArt {
|
|
||||||
HU = 'HU',
|
|
||||||
AU = 'AU',
|
|
||||||
UVV = 'UVV',
|
|
||||||
Leiter = 'Leiter',
|
|
||||||
Kran = 'Kran',
|
|
||||||
Seilwinde = 'Seilwinde',
|
|
||||||
Sonstiges = 'Sonstiges',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PruefungArtLabel: Record<PruefungArt, string> = {
|
|
||||||
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
|
|
||||||
[PruefungArt.AU]: 'Abgasuntersuchung',
|
|
||||||
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
|
|
||||||
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
|
|
||||||
[PruefungArt.Kran]: 'Kranprüfung',
|
|
||||||
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
|
|
||||||
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PruefungErgebnis =
|
|
||||||
| 'bestanden'
|
|
||||||
| 'bestanden_mit_maengeln'
|
|
||||||
| 'nicht_bestanden'
|
|
||||||
| 'ausstehend';
|
|
||||||
|
|
||||||
export type WartungslogArt =
|
export type WartungslogArt =
|
||||||
| 'Inspektion'
|
| 'Inspektion'
|
||||||
| 'Reparatur'
|
| 'Reparatur'
|
||||||
@@ -65,14 +38,6 @@ export interface FahrzeugListItem {
|
|||||||
status: FahrzeugStatus;
|
status: FahrzeugStatus;
|
||||||
status_bemerkung: string | null;
|
status_bemerkung: string | null;
|
||||||
bild_url: string | null;
|
bild_url: string | null;
|
||||||
hu_faellig_am: string | null; // ISO date string from API
|
|
||||||
hu_tage_bis_faelligkeit: number | null;
|
|
||||||
au_faellig_am: string | null;
|
|
||||||
au_tage_bis_faelligkeit: number | null;
|
|
||||||
uvv_faellig_am: string | null;
|
|
||||||
uvv_tage_bis_faelligkeit: number | null;
|
|
||||||
leiter_faellig_am: string | null;
|
|
||||||
leiter_tage_bis_faelligkeit: number | null;
|
|
||||||
paragraph57a_faellig_am: string | null;
|
paragraph57a_faellig_am: string | null;
|
||||||
paragraph57a_tage_bis_faelligkeit: number | null;
|
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||||
naechste_wartung_am: string | null;
|
naechste_wartung_am: string | null;
|
||||||
@@ -80,29 +45,6 @@ export interface FahrzeugListItem {
|
|||||||
naechste_pruefung_tage: number | null;
|
naechste_pruefung_tage: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PruefungStatus {
|
|
||||||
pruefung_id: string | null;
|
|
||||||
faellig_am: string | null;
|
|
||||||
tage_bis_faelligkeit: number | null;
|
|
||||||
ergebnis: PruefungErgebnis | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FahrzeugPruefung {
|
|
||||||
id: string;
|
|
||||||
fahrzeug_id: string;
|
|
||||||
pruefung_art: PruefungArt;
|
|
||||||
faellig_am: string;
|
|
||||||
durchgefuehrt_am: string | null;
|
|
||||||
ergebnis: PruefungErgebnis | null;
|
|
||||||
naechste_faelligkeit: string | null;
|
|
||||||
pruefende_stelle: string | null;
|
|
||||||
kosten: number | null;
|
|
||||||
dokument_url: string | null;
|
|
||||||
bemerkung: string | null;
|
|
||||||
erfasst_von: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FahrzeugWartungslog {
|
export interface FahrzeugWartungslog {
|
||||||
id: string;
|
id: string;
|
||||||
fahrzeug_id: string;
|
fahrzeug_id: string;
|
||||||
@@ -137,14 +79,7 @@ export interface FahrzeugDetail {
|
|||||||
paragraph57a_tage_bis_faelligkeit: number | null;
|
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||||
naechste_wartung_am: string | null;
|
naechste_wartung_am: string | null;
|
||||||
wartung_tage_bis_faelligkeit: number | null;
|
wartung_tage_bis_faelligkeit: number | null;
|
||||||
pruefstatus: {
|
naechste_pruefung_tage: number | null;
|
||||||
hu: PruefungStatus;
|
|
||||||
au: PruefungStatus;
|
|
||||||
uvv: PruefungStatus;
|
|
||||||
leiter: PruefungStatus;
|
|
||||||
};
|
|
||||||
naechste_pruefung_tage: number | null;
|
|
||||||
pruefungen: FahrzeugPruefung[];
|
|
||||||
wartungslog: FahrzeugWartungslog[];
|
wartungslog: FahrzeugWartungslog[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,12 +92,13 @@ export interface VehicleStats {
|
|||||||
inspectionsOverdue: number;
|
inspectionsOverdue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InspectionAlertType = '57a' | 'wartung';
|
||||||
|
|
||||||
export interface InspectionAlert {
|
export interface InspectionAlert {
|
||||||
fahrzeugId: string;
|
fahrzeugId: string;
|
||||||
bezeichnung: string;
|
bezeichnung: string;
|
||||||
kurzname: string | null;
|
kurzname: string | null;
|
||||||
pruefungId: string;
|
type: InspectionAlertType;
|
||||||
pruefungArt: PruefungArt;
|
|
||||||
faelligAm: string;
|
faelligAm: string;
|
||||||
tage: number;
|
tage: number;
|
||||||
}
|
}
|
||||||
@@ -186,24 +122,15 @@ export interface CreateFahrzeugPayload {
|
|||||||
naechste_wartung_am?: string;
|
naechste_wartung_am?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>;
|
export type UpdateFahrzeugPayload = {
|
||||||
|
[K in keyof CreateFahrzeugPayload]?: CreateFahrzeugPayload[K] | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface UpdateStatusPayload {
|
export interface UpdateStatusPayload {
|
||||||
status: FahrzeugStatus;
|
status: FahrzeugStatus;
|
||||||
bemerkung?: string;
|
bemerkung?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePruefungPayload {
|
|
||||||
pruefung_art: PruefungArt;
|
|
||||||
faellig_am: string;
|
|
||||||
durchgefuehrt_am?: string;
|
|
||||||
ergebnis?: PruefungErgebnis;
|
|
||||||
pruefende_stelle?: string;
|
|
||||||
kosten?: number;
|
|
||||||
dokument_url?: string;
|
|
||||||
bemerkung?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateWartungslogPayload {
|
export interface CreateWartungslogPayload {
|
||||||
datum: string;
|
datum: string;
|
||||||
art?: WartungslogArt;
|
art?: WartungslogArt;
|
||||||
|
|||||||
Reference in New Issue
Block a user