featur add fahrmeister
This commit is contained in:
@@ -35,6 +35,7 @@ class AuthController {
|
|||||||
|
|
||||||
// Step 2: Get user info from Authentik
|
// Step 2: Get user info from Authentik
|
||||||
const userInfo = await authentikService.getUserInfo(tokens.access_token);
|
const userInfo = await authentikService.getUserInfo(tokens.access_token);
|
||||||
|
const groups = userInfo.groups ?? [];
|
||||||
|
|
||||||
// Step 3: Verify ID token if present
|
// Step 3: Verify ID token if present
|
||||||
if (tokens.id_token) {
|
if (tokens.id_token) {
|
||||||
@@ -65,6 +66,8 @@ class AuthController {
|
|||||||
profile_picture_url: userInfo.picture,
|
profile_picture_url: userInfo.picture,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await userService.updateGroups(user.id, groups);
|
||||||
|
|
||||||
// Audit: first-ever login (user record creation)
|
// Audit: first-ever login (user record creation)
|
||||||
auditService.logAudit({
|
auditService.logAudit({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
@@ -86,6 +89,7 @@ class AuthController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await userService.updateLastLogin(user.id);
|
await userService.updateLastLogin(user.id);
|
||||||
|
await userService.updateGroups(user.id, groups);
|
||||||
|
|
||||||
// Audit: returning user login
|
// Audit: returning user login
|
||||||
auditService.logAudit({
|
auditService.logAudit({
|
||||||
@@ -132,6 +136,7 @@ class AuthController {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
authentikSub: user.authentik_sub,
|
authentikSub: user.authentik_sub,
|
||||||
|
groups,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
@@ -161,6 +166,7 @@ class AuthController {
|
|||||||
familyName: user.family_name,
|
familyName: user.family_name,
|
||||||
profilePictureUrl: user.profile_picture_url,
|
profilePictureUrl: user.profile_picture_url,
|
||||||
isActive: user.is_active,
|
isActive: user.is_active,
|
||||||
|
groups,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const CreateFahrzeugSchema = z.object({
|
|||||||
status_bemerkung: z.string().max(500).optional(),
|
status_bemerkung: z.string().max(500).optional(),
|
||||||
standort: z.string().max(100).optional(),
|
standort: z.string().max(100).optional(),
|
||||||
bild_url: z.string().url().max(500).optional(),
|
bild_url: z.string().url().max(500).optional(),
|
||||||
|
paragraph57a_faellig_am: isoDate.optional(),
|
||||||
|
naechste_wartung_am: isoDate.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial();
|
const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial();
|
||||||
@@ -325,6 +327,25 @@ class VehicleController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/vehicles/:id
|
||||||
|
* Delete a vehicle. Requires dashboard_admin group.
|
||||||
|
*/
|
||||||
|
async deleteVehicle(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
await vehicleService.deleteVehicle(id, getUserId(req));
|
||||||
|
res.status(200).json({ success: true, message: 'Fahrzeug gelöscht' });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Vehicle not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('deleteVehicle error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/vehicles/:id/wartung
|
* GET /api/vehicles/:id/wartung
|
||||||
* Maintenance log for a vehicle.
|
* Maintenance log for a vehicle.
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
-- Migration 007: Authentik groups + vehicle inspection/service periods
|
||||||
|
-- Depends on: 001_create_users_table.sql, 005_create_fahrzeuge.sql
|
||||||
|
--
|
||||||
|
-- Changes:
|
||||||
|
-- 1. Add authentik_groups column to users (stores Authentik group memberships)
|
||||||
|
-- 2. Add paragraph57a_faellig_am + naechste_wartung_am to fahrzeuge
|
||||||
|
-- 3. Refresh the fahrzeuge_mit_pruefstatus view to expose the new columns
|
||||||
|
-- Rollback:
|
||||||
|
-- ALTER TABLE users DROP COLUMN IF EXISTS authentik_groups;
|
||||||
|
-- ALTER TABLE fahrzeuge DROP COLUMN IF EXISTS paragraph57a_faellig_am;
|
||||||
|
-- ALTER TABLE fahrzeuge DROP COLUMN IF EXISTS naechste_wartung_am;
|
||||||
|
|
||||||
|
-- ── 1. users: Authentik group memberships ─────────────────────────────────────
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS authentik_groups TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.authentik_groups IS
|
||||||
|
'Authentik group slugs synced on every login (e.g. dashboard_admin, fahrmeister)';
|
||||||
|
|
||||||
|
-- ── 2. fahrzeuge: §57a (Austrian periodic inspection) + service interval ──────
|
||||||
|
ALTER TABLE fahrzeuge
|
||||||
|
ADD COLUMN IF NOT EXISTS paragraph57a_faellig_am DATE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN fahrzeuge.paragraph57a_faellig_am IS
|
||||||
|
'§57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV)';
|
||||||
|
|
||||||
|
ALTER TABLE fahrzeuge
|
||||||
|
ADD COLUMN IF NOT EXISTS naechste_wartung_am DATE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN fahrzeuge.naechste_wartung_am IS
|
||||||
|
'Next scheduled service / maintenance due date';
|
||||||
|
|
||||||
|
-- ── 3. Refresh view to expose new vehicle columns ─────────────────────────────
|
||||||
|
-- Drop and recreate since CREATE OR REPLACE on views requires identical column list.
|
||||||
|
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
|
||||||
|
WITH latest_pruefungen AS (
|
||||||
|
SELECT DISTINCT ON (fahrzeug_id, pruefung_art)
|
||||||
|
fahrzeug_id,
|
||||||
|
pruefung_art,
|
||||||
|
id AS pruefung_id,
|
||||||
|
faellig_am,
|
||||||
|
durchgefuehrt_am,
|
||||||
|
ergebnis,
|
||||||
|
naechste_faelligkeit,
|
||||||
|
pruefende_stelle,
|
||||||
|
CURRENT_DATE - faellig_am::date AS tage_ueberfaellig,
|
||||||
|
faellig_am::date - CURRENT_DATE AS tage_bis_faelligkeit
|
||||||
|
FROM fahrzeug_pruefungen
|
||||||
|
ORDER BY
|
||||||
|
fahrzeug_id,
|
||||||
|
pruefung_art,
|
||||||
|
(durchgefuehrt_am IS NULL) DESC,
|
||||||
|
faellig_am DESC
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
-- §57a Austrian periodic inspection
|
||||||
|
f.paragraph57a_faellig_am,
|
||||||
|
f.paragraph57a_faellig_am::date - CURRENT_DATE AS paragraph57a_tage_bis_faelligkeit,
|
||||||
|
-- Next service/maintenance
|
||||||
|
f.naechste_wartung_am,
|
||||||
|
f.naechste_wartung_am::date - CURRENT_DATE AS wartung_tage_bis_faelligkeit,
|
||||||
|
-- Legacy pruefungen (HU / AU / UVV / Leiter) kept for backwards compat
|
||||||
|
hu.pruefung_id AS hu_pruefung_id,
|
||||||
|
hu.faellig_am AS hu_faellig_am,
|
||||||
|
hu.tage_bis_faelligkeit AS hu_tage_bis_faelligkeit,
|
||||||
|
hu.ergebnis AS hu_ergebnis,
|
||||||
|
au.pruefung_id AS au_pruefung_id,
|
||||||
|
au.faellig_am AS au_faellig_am,
|
||||||
|
au.tage_bis_faelligkeit AS au_tage_bis_faelligkeit,
|
||||||
|
au.ergebnis AS au_ergebnis,
|
||||||
|
uvv.pruefung_id AS uvv_pruefung_id,
|
||||||
|
uvv.faellig_am AS uvv_faellig_am,
|
||||||
|
uvv.tage_bis_faelligkeit AS uvv_tage_bis_faelligkeit,
|
||||||
|
uvv.ergebnis AS uvv_ergebnis,
|
||||||
|
leiter.pruefung_id AS leiter_pruefung_id,
|
||||||
|
leiter.faellig_am AS leiter_faellig_am,
|
||||||
|
leiter.tage_bis_faelligkeit AS leiter_tage_bis_faelligkeit,
|
||||||
|
leiter.ergebnis AS leiter_ergebnis,
|
||||||
|
-- Overall worst urgency: §57a + Wartung take precedence, legacy pruefungen kept
|
||||||
|
LEAST(
|
||||||
|
f.paragraph57a_faellig_am::date - CURRENT_DATE,
|
||||||
|
f.naechste_wartung_am::date - CURRENT_DATE,
|
||||||
|
hu.tage_bis_faelligkeit,
|
||||||
|
au.tage_bis_faelligkeit,
|
||||||
|
uvv.tage_bis_faelligkeit,
|
||||||
|
leiter.tage_bis_faelligkeit
|
||||||
|
) AS naechste_pruefung_tage
|
||||||
|
FROM
|
||||||
|
fahrzeuge f
|
||||||
|
LEFT JOIN latest_pruefungen hu ON hu.fahrzeug_id = f.id AND hu.pruefung_art = 'HU'
|
||||||
|
LEFT JOIN latest_pruefungen au ON au.fahrzeug_id = f.id AND au.pruefung_art = 'AU'
|
||||||
|
LEFT JOIN latest_pruefungen uvv ON uvv.fahrzeug_id = f.id AND uvv.pruefung_art = 'UVV'
|
||||||
|
LEFT JOIN latest_pruefungen leiter ON leiter.fahrzeug_id = f.id AND leiter.pruefung_art = 'Leiter';
|
||||||
@@ -43,6 +43,7 @@ declare global {
|
|||||||
email: string;
|
email: string;
|
||||||
authentikSub: string;
|
authentikSub: string;
|
||||||
role?: AppRole; // populated when role is stored in DB / JWT
|
role?: AppRole; // populated when role is stored in DB / JWT
|
||||||
|
groups?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,7 @@ export const authenticate = async (
|
|||||||
id: decoded.userId,
|
id: decoded.userId,
|
||||||
email: decoded.email,
|
email: decoded.email,
|
||||||
authentikSub: decoded.authentikSub,
|
authentikSub: decoded.authentikSub,
|
||||||
|
groups: decoded.groups ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.debug('User authenticated successfully', {
|
logger.debug('User authenticated successfully', {
|
||||||
@@ -225,6 +227,7 @@ export const optionalAuth = async (
|
|||||||
id: decoded.userId,
|
id: decoded.userId,
|
||||||
email: decoded.email,
|
email: decoded.email,
|
||||||
authentikSub: decoded.authentikSub,
|
authentikSub: decoded.authentikSub,
|
||||||
|
groups: decoded.groups ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -134,3 +134,44 @@ export function requirePermission(permission: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { getUserRole, hasPermission };
|
export { getUserRole, hasPermission };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware factory: requires the authenticated user to belong to at least
|
||||||
|
* one of the given Authentik groups (sourced from the JWT `groups` claim).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* router.post('/api/vehicles', authenticate, requireGroups(['dashboard_admin']), handler)
|
||||||
|
*/
|
||||||
|
export function requireGroups(requiredGroups: string[]) {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, message: 'Authentication required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userGroups: string[] = (req.user as any).groups ?? [];
|
||||||
|
const hasAccess = requiredGroups.some(g => userGroups.includes(g));
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
logger.warn('Group-based access denied', {
|
||||||
|
userId: req.user.id,
|
||||||
|
userGroups,
|
||||||
|
requiredGroups,
|
||||||
|
path: req.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
|
||||||
|
required_groups: requiredGroups,
|
||||||
|
user_groups: userGroups,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Keine Berechtigung für diese Aktion',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface User {
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
preferences?: any; // JSONB
|
preferences?: any; // JSONB
|
||||||
|
authentik_groups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateUserData {
|
export interface CreateUserData {
|
||||||
@@ -24,6 +25,7 @@ export interface CreateUserData {
|
|||||||
given_name?: string;
|
given_name?: string;
|
||||||
family_name?: string;
|
family_name?: string;
|
||||||
profile_picture_url?: string;
|
profile_picture_url?: string;
|
||||||
|
authentik_groups?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserData {
|
export interface UpdateUserData {
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export interface Fahrzeug {
|
|||||||
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;
|
||||||
|
/** Next scheduled service / maintenance due date */
|
||||||
|
naechste_wartung_am: Date | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -153,6 +157,10 @@ export interface FahrzeugWithPruefstatus extends Fahrzeug {
|
|||||||
uvv: PruefungStatus;
|
uvv: PruefungStatus;
|
||||||
leiter: 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) */
|
/** Minimum tage_bis_faelligkeit across all inspections (negative = any overdue) */
|
||||||
naechste_pruefung_tage: number | null;
|
naechste_pruefung_tage: number | null;
|
||||||
/** Full inspection history, ordered by faellig_am DESC */
|
/** Full inspection history, ordered by faellig_am DESC */
|
||||||
@@ -178,6 +186,13 @@ 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_tage_bis_faelligkeit: number | null;
|
||||||
|
/** Next service due date */
|
||||||
|
naechste_wartung_am: Date | null;
|
||||||
|
wartung_tage_bis_faelligkeit: number | null;
|
||||||
|
// Legacy pruefungen kept for backwards compat
|
||||||
hu_faellig_am: Date | null;
|
hu_faellig_am: Date | null;
|
||||||
hu_tage_bis_faelligkeit: number | null;
|
hu_tage_bis_faelligkeit: number | null;
|
||||||
au_faellig_am: Date | null;
|
au_faellig_am: Date | null;
|
||||||
@@ -229,6 +244,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'
|
||||||
|
naechste_wartung_am?: string; // ISO date 'YYYY-MM-DD'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateFahrzeugData {
|
export interface UpdateFahrzeugData {
|
||||||
@@ -244,6 +261,8 @@ 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'
|
||||||
|
naechste_wartung_am?: string | null; // ISO date 'YYYY-MM-DD'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePruefungData {
|
export interface CreatePruefungData {
|
||||||
|
|||||||
@@ -1,49 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import vehicleController from '../controllers/vehicle.controller';
|
import vehicleController from '../controllers/vehicle.controller';
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { requireGroups } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const ADMIN_GROUPS = ['dashboard_admin'];
|
||||||
// RBAC guard — requirePermission('vehicles:write')
|
const STATUS_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tier 1 will deliver a full RBAC middleware. Until then, this inline guard
|
|
||||||
// enforces that only admin/kommandant/gruppenfuehrer roles can mutate vehicle
|
|
||||||
// data. The role is expected on req.user once Tier 1 is complete.
|
|
||||||
// For now it uses a conservative allowlist that can be updated via Tier 1 RBAC.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
import { Request, Response, NextFunction } from 'express';
|
|
||||||
|
|
||||||
/** Roles that are allowed to write vehicle data */
|
|
||||||
const WRITE_ROLES = new Set(['admin', 'kommandant', 'gruppenfuehrer']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* requirePermission guard — temporary inline implementation.
|
|
||||||
* Replace with the Tier 1 RBAC middleware when available:
|
|
||||||
* import { requirePermission } from '../middleware/rbac.middleware';
|
|
||||||
*/
|
|
||||||
const requireVehicleWrite = (
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): void => {
|
|
||||||
// Once Tier 1 RBAC is merged, replace the body with:
|
|
||||||
// return requirePermission('vehicles:write')(req, res, next);
|
|
||||||
//
|
|
||||||
// Temporary implementation: check the role field on the JWT payload.
|
|
||||||
// The role is stored in req.user once authenticate() has run (Tier 1 adds it).
|
|
||||||
const role = (req.user as any)?.role as string | undefined;
|
|
||||||
|
|
||||||
if (!role || !WRITE_ROLES.has(role)) {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Keine Berechtigung für diese Aktion (vehicles:write erforderlich)',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -86,7 +47,7 @@ router.get('/:id/pruefungen', authenticate, vehicleController.getPruefungen.bind
|
|||||||
*/
|
*/
|
||||||
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
||||||
|
|
||||||
// ── Write endpoints (vehicles:write role required) ─────────────────────────────
|
// ── Write endpoints (dashboard_admin group required) ────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/vehicles
|
* POST /api/vehicles
|
||||||
@@ -95,7 +56,7 @@ router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehic
|
|||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireVehicleWrite,
|
requireGroups(ADMIN_GROUPS),
|
||||||
vehicleController.createVehicle.bind(vehicleController)
|
vehicleController.createVehicle.bind(vehicleController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -106,19 +67,19 @@ router.post(
|
|||||||
router.patch(
|
router.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireVehicleWrite,
|
requireGroups(ADMIN_GROUPS),
|
||||||
vehicleController.updateVehicle.bind(vehicleController)
|
vehicleController.updateVehicle.bind(vehicleController)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/vehicles/:id/status
|
* PATCH /api/vehicles/:id/status
|
||||||
* Live status change — Socket.IO hook point for Tier 3.
|
* Live status change — dashboard_admin or dashboard_fahrmeister required.
|
||||||
* The `io` instance is retrieved inside the controller via req.app.get('io').
|
* The `io` instance is retrieved inside the controller via req.app.get('io').
|
||||||
*/
|
*/
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:id/status',
|
'/:id/status',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireVehicleWrite,
|
requireGroups(STATUS_GROUPS),
|
||||||
vehicleController.updateVehicleStatus.bind(vehicleController)
|
vehicleController.updateVehicleStatus.bind(vehicleController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,7 +90,7 @@ router.patch(
|
|||||||
router.post(
|
router.post(
|
||||||
'/:id/pruefungen',
|
'/:id/pruefungen',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireVehicleWrite,
|
requireGroups(ADMIN_GROUPS),
|
||||||
vehicleController.addPruefung.bind(vehicleController)
|
vehicleController.addPruefung.bind(vehicleController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -140,8 +101,20 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
'/:id/wartung',
|
'/:id/wartung',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireVehicleWrite,
|
requireGroups(ADMIN_GROUPS),
|
||||||
vehicleController.addWartung.bind(vehicleController)
|
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;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class TokenService {
|
|||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
authentikSub: payload.authentikSub,
|
authentikSub: payload.authentikSub,
|
||||||
|
groups: payload.groups ?? [],
|
||||||
},
|
},
|
||||||
environment.jwt.secret,
|
environment.jwt.secret,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class UserService {
|
|||||||
const query = `
|
const query = `
|
||||||
SELECT id, email, authentik_sub, name, preferred_username, given_name,
|
SELECT id, email, authentik_sub, name, preferred_username, given_name,
|
||||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||||
is_active, last_login_at, created_at, updated_at, preferences
|
is_active, last_login_at, created_at, updated_at, preferences, authentik_groups
|
||||||
FROM users
|
FROM users
|
||||||
WHERE authentik_sub = $1
|
WHERE authentik_sub = $1
|
||||||
`;
|
`;
|
||||||
@@ -39,7 +39,7 @@ class UserService {
|
|||||||
const query = `
|
const query = `
|
||||||
SELECT id, email, authentik_sub, name, preferred_username, given_name,
|
SELECT id, email, authentik_sub, name, preferred_username, given_name,
|
||||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||||
is_active, last_login_at, created_at, updated_at, preferences
|
is_active, last_login_at, created_at, updated_at, preferences, authentik_groups
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
`;
|
`;
|
||||||
@@ -67,7 +67,7 @@ class UserService {
|
|||||||
const query = `
|
const query = `
|
||||||
SELECT id, email, authentik_sub, name, preferred_username, given_name,
|
SELECT id, email, authentik_sub, name, preferred_username, given_name,
|
||||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||||
is_active, last_login_at, created_at, updated_at, preferences
|
is_active, last_login_at, created_at, updated_at, preferences, authentik_groups
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`;
|
`;
|
||||||
@@ -101,12 +101,13 @@ class UserService {
|
|||||||
given_name,
|
given_name,
|
||||||
family_name,
|
family_name,
|
||||||
profile_picture_url,
|
profile_picture_url,
|
||||||
is_active
|
is_active,
|
||||||
|
authentik_groups
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8)
|
||||||
RETURNING id, email, authentik_sub, name, preferred_username, given_name,
|
RETURNING id, email, authentik_sub, name, preferred_username, given_name,
|
||||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||||
is_active, last_login_at, created_at, updated_at, preferences
|
is_active, last_login_at, created_at, updated_at, preferences, authentik_groups
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
@@ -117,6 +118,7 @@ class UserService {
|
|||||||
userData.given_name || null,
|
userData.given_name || null,
|
||||||
userData.family_name || null,
|
userData.family_name || null,
|
||||||
userData.profile_picture_url || null,
|
userData.profile_picture_url || null,
|
||||||
|
userData.authentik_groups ?? [],
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await pool.query(query, values);
|
const result = await pool.query(query, values);
|
||||||
@@ -185,7 +187,7 @@ class UserService {
|
|||||||
WHERE id = $${paramCount}
|
WHERE id = $${paramCount}
|
||||||
RETURNING id, email, authentik_sub, name, preferred_username, given_name,
|
RETURNING id, email, authentik_sub, name, preferred_username, given_name,
|
||||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||||
is_active, last_login_at, created_at, updated_at, preferences
|
is_active, last_login_at, created_at, updated_at, preferences, authentik_groups
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pool.query(query, values);
|
const result = await pool.query(query, values);
|
||||||
@@ -270,6 +272,22 @@ class UserService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync Authentik groups for a user
|
||||||
|
*/
|
||||||
|
async updateGroups(id: string, groups: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE users SET authentik_groups = $1 WHERE id = $2`,
|
||||||
|
[groups, id]
|
||||||
|
);
|
||||||
|
logger.debug('Updated authentik_groups', { userId: id });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating authentik_groups', { error, userId: id });
|
||||||
|
throw new Error('Failed to update user groups');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new UserService();
|
export default new UserService();
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ class VehicleService {
|
|||||||
status,
|
status,
|
||||||
status_bemerkung,
|
status_bemerkung,
|
||||||
bild_url,
|
bild_url,
|
||||||
|
paragraph57a_faellig_am,
|
||||||
|
paragraph57a_tage_bis_faelligkeit,
|
||||||
|
naechste_wartung_am,
|
||||||
|
wartung_tage_bis_faelligkeit,
|
||||||
hu_faellig_am,
|
hu_faellig_am,
|
||||||
hu_tage_bis_faelligkeit,
|
hu_tage_bis_faelligkeit,
|
||||||
au_faellig_am,
|
au_faellig_am,
|
||||||
@@ -78,6 +82,10 @@ class VehicleService {
|
|||||||
|
|
||||||
return result.rows.map((row) => ({
|
return result.rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
|
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
|
||||||
|
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
||||||
|
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
||||||
|
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
||||||
hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null
|
hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null
|
||||||
? parseInt(row.hu_tage_bis_faelligkeit, 10) : null,
|
? parseInt(row.hu_tage_bis_faelligkeit, 10) : null,
|
||||||
au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null
|
au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null
|
||||||
@@ -145,6 +153,8 @@ class VehicleService {
|
|||||||
status_bemerkung: row.status_bemerkung,
|
status_bemerkung: row.status_bemerkung,
|
||||||
standort: row.standort,
|
standort: row.standort,
|
||||||
bild_url: row.bild_url,
|
bild_url: row.bild_url,
|
||||||
|
paragraph57a_faellig_am: row.paragraph57a_faellig_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: {
|
pruefstatus: {
|
||||||
@@ -153,6 +163,10 @@ class VehicleService {
|
|||||||
uvv: mapPruefungStatus(row, 'uvv'),
|
uvv: mapPruefungStatus(row, 'uvv'),
|
||||||
leiter: mapPruefungStatus(row, 'leiter'),
|
leiter: mapPruefungStatus(row, 'leiter'),
|
||||||
},
|
},
|
||||||
|
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
|
||||||
|
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
||||||
|
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
||||||
|
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
||||||
naechste_pruefung_tage: row.naechste_pruefung_tage != null
|
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[],
|
pruefungen: pruefungenResult.rows as FahrzeugPruefung[],
|
||||||
@@ -179,8 +193,9 @@ class VehicleService {
|
|||||||
`INSERT INTO fahrzeuge (
|
`INSERT INTO fahrzeuge (
|
||||||
bezeichnung, kurzname, amtliches_kennzeichen, fahrgestellnummer,
|
bezeichnung, kurzname, amtliches_kennzeichen, fahrgestellnummer,
|
||||||
baujahr, hersteller, typ_schluessel, besatzung_soll,
|
baujahr, hersteller, typ_schluessel, besatzung_soll,
|
||||||
status, status_bemerkung, standort, bild_url
|
status, status_bemerkung, standort, bild_url,
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
paragraph57a_faellig_am, naechste_wartung_am
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.bezeichnung,
|
data.bezeichnung,
|
||||||
@@ -195,6 +210,8 @@ class VehicleService {
|
|||||||
data.status_bemerkung ?? null,
|
data.status_bemerkung ?? null,
|
||||||
data.standort ?? 'Feuerwehrhaus',
|
data.standort ?? 'Feuerwehrhaus',
|
||||||
data.bild_url ?? null,
|
data.bild_url ?? null,
|
||||||
|
data.paragraph57a_faellig_am ?? null,
|
||||||
|
data.naechste_wartung_am ?? null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -234,6 +251,8 @@ class VehicleService {
|
|||||||
if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung);
|
if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung);
|
||||||
if (data.standort !== undefined) addField('standort', data.standort);
|
if (data.standort !== undefined) addField('standort', data.standort);
|
||||||
if (data.bild_url !== undefined) addField('bild_url', data.bild_url);
|
if (data.bild_url !== undefined) addField('bild_url', data.bild_url);
|
||||||
|
if (data.paragraph57a_faellig_am !== undefined) addField('paragraph57a_faellig_am', data.paragraph57a_faellig_am);
|
||||||
|
if (data.naechste_wartung_am !== undefined) addField('naechste_wartung_am', data.naechste_wartung_am);
|
||||||
|
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
throw new Error('No fields to update');
|
throw new Error('No fields to update');
|
||||||
@@ -258,6 +277,24 @@ class VehicleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteVehicle(id: string, deletedBy: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM fahrzeuge WHERE id = $1 RETURNING id`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Vehicle not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Vehicle deleted', { id, by: deletedBy });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VehicleService.deleteVehicle failed', { error, id });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// STATUS MANAGEMENT
|
// STATUS MANAGEMENT
|
||||||
// Socket.io-ready: accepts optional `io` parameter.
|
// Socket.io-ready: accepts optional `io` parameter.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface JwtPayload {
|
|||||||
userId: string; // UUID
|
userId: string; // UUID
|
||||||
email: string;
|
email: string;
|
||||||
authentikSub: string;
|
authentikSub: string;
|
||||||
|
groups?: string[];
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
12
frontend/src/hooks/usePermissions.ts
Normal file
12
frontend/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const groups = user?.groups ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdmin: groups.includes('dashboard_admin'),
|
||||||
|
canChangeStatus: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
|
||||||
|
groups,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
WartungslogArt,
|
WartungslogArt,
|
||||||
PruefungErgebnis,
|
PruefungErgebnis,
|
||||||
} from '../types/vehicle.types';
|
} from '../types/vehicle.types';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
// ── Tab Panel ─────────────────────────────────────────────────────────────────
|
// ── Tab Panel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -114,9 +115,10 @@ function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'err
|
|||||||
interface UebersichtTabProps {
|
interface UebersichtTabProps {
|
||||||
vehicle: FahrzeugDetail;
|
vehicle: FahrzeugDetail;
|
||||||
onStatusUpdated: () => void;
|
onStatusUpdated: () => void;
|
||||||
|
canChangeStatus: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated }) => {
|
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus }) => {
|
||||||
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
||||||
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
|
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
|
||||||
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
|
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
|
||||||
@@ -177,6 +179,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated
|
|||||||
setBemerkung(vehicle.status_bemerkung ?? '');
|
setBemerkung(vehicle.status_bemerkung ?? '');
|
||||||
setStatusDialogOpen(true);
|
setStatusDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
|
sx={{ display: canChangeStatus ? undefined : 'none' }}
|
||||||
>
|
>
|
||||||
Status ändern
|
Status ändern
|
||||||
</Button>
|
</Button>
|
||||||
@@ -195,6 +198,8 @@ 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">
|
||||||
@@ -311,6 +316,7 @@ interface PruefungenTabProps {
|
|||||||
fahrzeugId: string;
|
fahrzeugId: string;
|
||||||
pruefungen: FahrzeugPruefung[];
|
pruefungen: FahrzeugPruefung[];
|
||||||
onAdded: () => void;
|
onAdded: () => void;
|
||||||
|
canWrite: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ERGEBNIS_LABELS: Record<PruefungErgebnis, string> = {
|
const ERGEBNIS_LABELS: Record<PruefungErgebnis, string> = {
|
||||||
@@ -327,7 +333,7 @@ const ERGEBNIS_COLORS: Record<PruefungErgebnis, 'success' | 'warning' | 'error'
|
|||||||
ausstehend: 'default',
|
ausstehend: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, onAdded }) => {
|
const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, onAdded, canWrite }) => {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
@@ -431,6 +437,7 @@ const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, o
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
|
{canWrite && (
|
||||||
<Fab
|
<Fab
|
||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -440,6 +447,7 @@ const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, o
|
|||||||
>
|
>
|
||||||
<Add />
|
<Add />
|
||||||
</Fab>
|
</Fab>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add inspection dialog */}
|
{/* Add inspection dialog */}
|
||||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
@@ -549,6 +557,7 @@ interface WartungTabProps {
|
|||||||
fahrzeugId: string;
|
fahrzeugId: string;
|
||||||
wartungslog: FahrzeugWartungslog[];
|
wartungslog: FahrzeugWartungslog[];
|
||||||
onAdded: () => void;
|
onAdded: () => void;
|
||||||
|
canWrite: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
|
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
|
||||||
@@ -559,7 +568,7 @@ const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
|
|||||||
default: <Build color="action" />,
|
default: <Build color="action" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded }) => {
|
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
@@ -634,6 +643,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canWrite && (
|
||||||
<Fab
|
<Fab
|
||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -643,6 +653,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
>
|
>
|
||||||
<Add />
|
<Add />
|
||||||
</Fab>
|
</Fab>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Wartung / Service eintragen</DialogTitle>
|
<DialogTitle>Wartung / Service eintragen</DialogTitle>
|
||||||
@@ -746,6 +757,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
|
|||||||
function FahrzeugDetail() {
|
function FahrzeugDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isAdmin, canChangeStatus } = usePermissions();
|
||||||
|
|
||||||
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
|
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -860,7 +872,7 @@ function FahrzeugDetail() {
|
|||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
<TabPanel value={activeTab} index={0}>
|
<TabPanel value={activeTab} index={0}>
|
||||||
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} />
|
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} canChangeStatus={canChangeStatus} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={activeTab} index={1}>
|
<TabPanel value={activeTab} index={1}>
|
||||||
@@ -868,6 +880,7 @@ function FahrzeugDetail() {
|
|||||||
fahrzeugId={vehicle.id}
|
fahrzeugId={vehicle.id}
|
||||||
pruefungen={vehicle.pruefungen}
|
pruefungen={vehicle.pruefungen}
|
||||||
onAdded={fetchVehicle}
|
onAdded={fetchVehicle}
|
||||||
|
canWrite={isAdmin}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
@@ -876,6 +889,7 @@ function FahrzeugDetail() {
|
|||||||
fahrzeugId={vehicle.id}
|
fahrzeugId={vehicle.id}
|
||||||
wartungslog={vehicle.wartungslog}
|
wartungslog={vehicle.wartungslog}
|
||||||
onAdded={fetchVehicle}
|
onAdded={fetchVehicle}
|
||||||
|
canWrite={isAdmin}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
PruefungArt,
|
PruefungArt,
|
||||||
PruefungArtLabel,
|
PruefungArtLabel,
|
||||||
} from '../types/vehicle.types';
|
} from '../types/vehicle.types';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
// ── Status chip config ────────────────────────────────────────────────────────
|
// ── Status chip config ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -87,10 +88,8 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
|
|
||||||
// Collect inspection badges (only for types where a faellig_am exists)
|
// Collect inspection badges (only for types where a faellig_am exists)
|
||||||
const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [
|
const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [
|
||||||
{ art: 'HU', tage: vehicle.hu_tage_bis_faelligkeit, faelligAm: vehicle.hu_faellig_am },
|
{ art: '§57a', tage: vehicle.paragraph57a_tage_bis_faelligkeit, faelligAm: vehicle.paragraph57a_faellig_am },
|
||||||
{ art: 'AU', tage: vehicle.au_tage_bis_faelligkeit, faelligAm: vehicle.au_faellig_am },
|
{ art: 'Wartung', tage: vehicle.wartung_tage_bis_faelligkeit, faelligAm: vehicle.naechste_wartung_am },
|
||||||
{ art: 'UVV', tage: vehicle.uvv_tage_bis_faelligkeit, faelligAm: vehicle.uvv_faellig_am },
|
|
||||||
{ art: 'Leiter', tage: vehicle.leiter_tage_bis_faelligkeit, faelligAm: vehicle.leiter_faellig_am },
|
|
||||||
].filter((b) => b.faelligAm !== null);
|
].filter((b) => b.faelligAm !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -218,6 +217,7 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
|
|||||||
|
|
||||||
function Fahrzeuge() {
|
function Fahrzeuge() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
|
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -347,6 +347,7 @@ function Fahrzeuge() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
|
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
|
||||||
|
{isAdmin && (
|
||||||
<Fab
|
<Fab
|
||||||
color="primary"
|
color="primary"
|
||||||
aria-label="Fahrzeug hinzufügen"
|
aria-label="Fahrzeug hinzufügen"
|
||||||
@@ -355,6 +356,7 @@ function Fahrzeuge() {
|
|||||||
>
|
>
|
||||||
<Add />
|
<Add />
|
||||||
</Fab>
|
</Fab>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ export const vehiclesApi = {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await api.delete(`/api/vehicles/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
/** Live status change — Socket.IO event is emitted server-side in Tier 3 */
|
/** 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);
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export interface FahrzeugListItem {
|
|||||||
uvv_tage_bis_faelligkeit: number | null;
|
uvv_tage_bis_faelligkeit: number | null;
|
||||||
leiter_faellig_am: string | null;
|
leiter_faellig_am: string | null;
|
||||||
leiter_tage_bis_faelligkeit: number | null;
|
leiter_tage_bis_faelligkeit: number | null;
|
||||||
|
paragraph57a_faellig_am: string | null;
|
||||||
|
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||||
|
naechste_wartung_am: string | null;
|
||||||
|
wartung_tage_bis_faelligkeit: number | null;
|
||||||
naechste_pruefung_tage: number | null;
|
naechste_pruefung_tage: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +133,10 @@ export interface FahrzeugDetail {
|
|||||||
bild_url: string | null;
|
bild_url: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
paragraph57a_faellig_am: string | null;
|
||||||
|
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||||
|
naechste_wartung_am: string | null;
|
||||||
|
wartung_tage_bis_faelligkeit: number | null;
|
||||||
pruefstatus: {
|
pruefstatus: {
|
||||||
hu: PruefungStatus;
|
hu: PruefungStatus;
|
||||||
au: PruefungStatus;
|
au: PruefungStatus;
|
||||||
@@ -174,6 +182,8 @@ export interface CreateFahrzeugPayload {
|
|||||||
status_bemerkung?: string;
|
status_bemerkung?: string;
|
||||||
standort?: string;
|
standort?: string;
|
||||||
bild_url?: string;
|
bild_url?: string;
|
||||||
|
paragraph57a_faellig_am?: string;
|
||||||
|
naechste_wartung_am?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>;
|
export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>;
|
||||||
|
|||||||
Reference in New Issue
Block a user