add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:50:14 +01:00
parent c5e8337a69
commit 620bacc6b5
46 changed files with 14095 additions and 1 deletions

View File

@@ -7,6 +7,7 @@
"dev": "nodemon",
"build": "tsc",
"start": "node dist/server.js",
"migrate": "ts-node -e \"require('./src/config/database').runMigrations().then(() => { console.log('Done'); process.exit(0); }).catch((e) => { console.error(e.message); process.exit(1); })\"",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],

View File

@@ -0,0 +1,302 @@
import { Request, Response } from 'express';
import incidentService from '../services/incident.service';
import logger from '../utils/logger';
import { AppError } from '../middleware/error.middleware';
import { AppRole, hasPermission } from '../middleware/rbac.middleware';
import {
CreateEinsatzSchema,
UpdateEinsatzSchema,
AssignPersonnelSchema,
AssignVehicleSchema,
IncidentFiltersSchema,
} from '../models/incident.model';
// Extend Request type to carry the resolved role (set by requirePermission)
interface AuthenticatedRequest extends Request {
userRole?: AppRole;
}
class IncidentController {
// -------------------------------------------------------------------------
// GET /api/incidents
// -------------------------------------------------------------------------
async listIncidents(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const parseResult = IncidentFiltersSchema.safeParse(req.query);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Ungültige Filter-Parameter',
errors: parseResult.error.issues,
});
return;
}
const { items, total } = await incidentService.getAllIncidents(parseResult.data);
res.status(200).json({
success: true,
data: {
items,
total,
limit: parseResult.data.limit,
offset: parseResult.data.offset,
},
});
} catch (error) {
logger.error('List incidents error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Einsätze' });
}
}
// -------------------------------------------------------------------------
// GET /api/incidents/stats
// -------------------------------------------------------------------------
async getStats(req: Request, res: Response): Promise<void> {
try {
const year = req.query.year ? parseInt(req.query.year as string, 10) : undefined;
if (year !== undefined && (isNaN(year) || year < 2000 || year > 2100)) {
res.status(400).json({ success: false, message: 'Ungültiges Jahr' });
return;
}
const stats = await incidentService.getIncidentStats(year);
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('Get incident stats error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistik' });
}
}
// -------------------------------------------------------------------------
// GET /api/incidents/:id
// -------------------------------------------------------------------------
async getIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const incident = await incidentService.getIncidentById(id);
if (!incident) {
throw new AppError('Einsatz nicht gefunden', 404);
}
// Role-based redaction: only Kommandant+ can see full bericht_text
const canReadBerichtText =
req.userRole !== undefined &&
hasPermission(req.userRole, 'incidents:read_bericht_text');
const responseData = {
...incident,
bericht_text: canReadBerichtText ? incident.bericht_text : undefined,
};
res.status(200).json({ success: true, data: responseData });
} catch (error) {
if (error instanceof AppError) {
res.status(error.statusCode).json({ success: false, message: error.message });
return;
}
logger.error('Get incident error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents
// -------------------------------------------------------------------------
async createIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const parseResult = CreateEinsatzSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
const einsatz = await incidentService.createIncident(parseResult.data, req.user.id);
logger.info('Incident created via API', {
einsatzId: einsatz.id,
einsatz_nr: einsatz.einsatz_nr,
createdBy: req.user.id,
});
res.status(201).json({ success: true, data: einsatz });
} catch (error) {
logger.error('Create incident error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// PATCH /api/incidents/:id
// -------------------------------------------------------------------------
async updateIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const { id } = req.params;
const parseResult = UpdateEinsatzSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
const einsatz = await incidentService.updateIncident(id, parseResult.data, req.user.id);
res.status(200).json({ success: true, data: einsatz });
} catch (error) {
if (error instanceof Error && error.message === 'Incident not found') {
res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' });
return;
}
logger.error('Update incident error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// DELETE /api/incidents/:id (soft delete)
// -------------------------------------------------------------------------
async deleteIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const { id } = req.params;
await incidentService.deleteIncident(id, req.user.id);
res.status(200).json({ success: true, message: 'Einsatz archiviert' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' });
return;
}
logger.error('Delete incident error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Archivieren des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents/:id/personnel
// -------------------------------------------------------------------------
async assignPersonnel(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const parseResult = AssignPersonnelSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
await incidentService.assignPersonnel(id, parseResult.data);
res.status(200).json({ success: true, message: 'Person zugewiesen' });
} catch (error) {
logger.error('Assign personnel error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Zuweisen der Person' });
}
}
// -------------------------------------------------------------------------
// DELETE /api/incidents/:id/personnel/:userId
// -------------------------------------------------------------------------
async removePersonnel(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id, userId } = req.params;
await incidentService.removePersonnel(id, userId);
res.status(200).json({ success: true, message: 'Person entfernt' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' });
return;
}
logger.error('Remove personnel error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Entfernen der Person' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents/:id/vehicles
// -------------------------------------------------------------------------
async assignVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const parseResult = AssignVehicleSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
await incidentService.assignVehicle(id, parseResult.data);
res.status(200).json({ success: true, message: 'Fahrzeug zugewiesen' });
} catch (error) {
logger.error('Assign vehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Zuweisen des Fahrzeugs' });
}
}
// -------------------------------------------------------------------------
// DELETE /api/incidents/:id/vehicles/:fahrzeugId
// -------------------------------------------------------------------------
async removeVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id, fahrzeugId } = req.params;
await incidentService.removeVehicle(id, fahrzeugId);
res.status(200).json({ success: true, message: 'Fahrzeug entfernt' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' });
return;
}
logger.error('Remove vehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Entfernen des Fahrzeugs' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents/refresh-stats (admin utility)
// -------------------------------------------------------------------------
async refreshStats(_req: Request, res: Response): Promise<void> {
try {
await incidentService.refreshStatistikView();
res.status(200).json({ success: true, message: 'Statistik-View aktualisiert' });
} catch (error) {
logger.error('Refresh stats view error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Statistik' });
}
}
}
export default new IncidentController();

View File

@@ -0,0 +1,234 @@
import { Request, Response } from 'express';
import memberService from '../services/member.service';
import logger from '../utils/logger';
import { AppError } from '../middleware/error.middleware';
import {
CreateMemberProfileSchema,
UpdateMemberProfileSchema,
SelfUpdateMemberProfileSchema,
} from '../models/member.model';
// ----------------------------------------------------------------
// Role helpers
// These helpers inspect req.user.role which is populated by the
// requireRole / requirePermission middleware (see member.routes.ts).
// ----------------------------------------------------------------
type AppRole = 'admin' | 'kommandant' | 'mitglied';
function getRole(req: Request): AppRole {
return (req.user as any)?.role ?? 'mitglied';
}
function canWrite(req: Request): boolean {
const role = getRole(req);
return role === 'admin' || role === 'kommandant';
}
function isOwnProfile(req: Request, userId: string): boolean {
return req.user?.id === userId;
}
// ----------------------------------------------------------------
// Controller
// ----------------------------------------------------------------
class MemberController {
/**
* GET /api/members
* Returns a paginated list of all active members.
* Supports ?search=, ?status[]=, ?dienstgrad[]=, ?page=, ?pageSize=
*/
async getMembers(req: Request, res: Response): Promise<void> {
try {
const {
search,
page,
pageSize,
} = req.query as Record<string, string | undefined>;
// Arrays can be sent as ?status[]=aktiv&status[]=passiv or CSV
const statusParam = req.query['status'] as string | string[] | undefined;
const dienstgradParam = req.query['dienstgrad'] as string | string[] | undefined;
const normalizeArray = (v?: string | string[]): string[] | undefined => {
if (!v) return undefined;
return Array.isArray(v) ? v : v.split(',').map((s) => s.trim()).filter(Boolean);
};
const { items, total } = await memberService.getAllMembers({
search,
status: normalizeArray(statusParam) as any,
dienstgrad: normalizeArray(dienstgradParam) as any,
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? Math.min(parseInt(pageSize, 10), 100) : 25,
});
res.status(200).json({
success: true,
data: items,
meta: { total, page: page ? parseInt(page, 10) : 1 },
});
} catch (error) {
logger.error('getMembers error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Mitglieder.' });
}
}
/**
* GET /api/members/stats
* Returns aggregate member counts for each status.
* Must be registered BEFORE /:userId to avoid route collision.
*/
async getMemberStats(req: Request, res: Response): Promise<void> {
try {
const stats = await memberService.getMemberStats();
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('getMemberStats error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken.' });
}
}
/**
* GET /api/members/:userId
* Returns full member detail including profile and rank history.
*
* Role rules:
* - Kommandant/Admin: all fields
* - Mitglied reading own profile: all fields
* - Mitglied reading another member: geburtsdatum and emergency contact redacted
*/
async getMemberById(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const requestorId = req.user!.id;
const requestorRole = getRole(req);
const ownProfile = isOwnProfile(req, userId);
const member = await memberService.getMemberById(userId);
if (!member) {
res.status(404).json({ success: false, message: 'Mitglied nicht gefunden.' });
return;
}
// Sensitive field masking for non-privileged reads of other members
const canReadSensitive = canWrite(req) || ownProfile;
if (!canReadSensitive && member.profile) {
// Replace geburtsdatum with only the age (year of birth omitted for DSGVO)
const ageMasked: any = { ...member.profile };
if (ageMasked.geburtsdatum) {
const birthYear = new Date(ageMasked.geburtsdatum).getFullYear();
const age = new Date().getFullYear() - birthYear;
ageMasked.geburtsdatum = null;
ageMasked._age = age; // synthesised non-DB field
}
// Redact emergency contact entirely
ageMasked.notfallkontakt_name = null;
ageMasked.notfallkontakt_telefon = null;
res.status(200).json({
success: true,
data: { ...member, profile: ageMasked },
});
return;
}
res.status(200).json({ success: true, data: member });
} catch (error) {
logger.error('getMemberById error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Mitglieds.' });
}
}
/**
* POST /api/members/:userId/profile
* Creates the mitglieder_profile row for an existing auth user.
* Restricted to Kommandant/Admin.
*/
async createMemberProfile(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const parseResult = CreateMemberProfileSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Ungültige Eingabedaten.',
errors: parseResult.error.flatten().fieldErrors,
});
return;
}
const profile = await memberService.createMemberProfile(userId, parseResult.data);
logger.info('createMemberProfile', { userId, createdBy: req.user!.id });
res.status(201).json({ success: true, data: profile });
} catch (error: any) {
if (error?.message?.includes('existiert bereits')) {
res.status(409).json({ success: false, message: error.message });
return;
}
logger.error('createMemberProfile error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Profils.' });
}
}
/**
* PATCH /api/members/:userId
* Updates the mitglieder_profile row.
*
* Role rules:
* - Kommandant/Admin: full update (all fields)
* - Mitglied (own profile only): restricted to SelfUpdateMemberProfileSchema fields
*/
async updateMember(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const updaterId = req.user!.id;
const ownProfile = isOwnProfile(req, userId);
if (!canWrite(req) && !ownProfile) {
res.status(403).json({
success: false,
message: 'Keine Berechtigung, dieses Profil zu bearbeiten.',
});
return;
}
// Choose validation schema based on role
const schema = canWrite(req) ? UpdateMemberProfileSchema : SelfUpdateMemberProfileSchema;
const parseResult = schema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Ungültige Eingabedaten.',
errors: parseResult.error.flatten().fieldErrors,
});
return;
}
const profile = await memberService.updateMemberProfile(
userId,
parseResult.data as any,
updaterId
);
logger.info('updateMember', { userId, updatedBy: updaterId });
res.status(200).json({ success: true, data: profile });
} catch (error: any) {
if (error?.message === 'Mitgliedsprofil nicht gefunden.') {
res.status(404).json({ success: false, message: error.message });
return;
}
logger.error('updateMember error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Profils.' });
}
}
}
export default new MemberController();

View File

@@ -0,0 +1,346 @@
import { Request, Response } from 'express';
import trainingService from '../services/training.service';
import {
CreateUebungSchema,
UpdateUebungSchema,
UpdateRsvpSchema,
MarkAttendanceSchema,
CancelEventSchema,
} from '../models/training.model';
import logger from '../utils/logger';
import environment from '../config/environment';
class TrainingController {
// -------------------------------------------------------------------------
// GET /api/training
// -------------------------------------------------------------------------
getUpcoming = async (req: Request, res: Response): Promise<void> => {
try {
const limit = Math.min(Number(req.query.limit ?? 10), 50);
const userId = req.user?.id;
const events = await trainingService.getUpcomingEvents(limit, userId);
res.json({ success: true, data: events });
} catch (error) {
logger.error('getUpcoming error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltungen' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/calendar?from=&to=
// -------------------------------------------------------------------------
getCalendarRange = async (req: Request, res: Response): Promise<void> => {
try {
const fromStr = req.query.from as string | undefined;
const toStr = req.query.to as string | undefined;
if (!fromStr || !toStr) {
res.status(400).json({
success: false,
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
});
return;
}
const from = new Date(fromStr);
const to = new Date(toStr);
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
return;
}
if (to < from) {
res.status(400).json({ success: false, message: '"to" muss nach "from" liegen' });
return;
}
const userId = req.user?.id;
const events = await trainingService.getEventsByDateRange(from, to, userId);
res.json({ success: true, data: events });
} catch (error) {
logger.error('getCalendarRange error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalenders' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/calendar.ics?token=<calendarToken>
//
// Auth design: We use a per-user opaque token stored in `calendar_tokens`.
// This allows calendar clients (Apple Calendar, Google Calendar) to subscribe
// without sending a Bearer token on every refresh. The token is long-lived
// and can be rotated by calling POST /api/training/calendar-token/rotate.
//
// TRADEOFF DISCUSSION:
// Option A (used here) — Personal calendar token:
// + Works natively in all calendar apps (no auth headers needed)
// + Rotating the token invalidates stale subscriptions
// - Token embedded in URL is visible in server logs / browser history
// → Mitigation: tokens are 256-bit random, never contain PII
//
// Option B — Bearer auth only:
// + No token in URL
// - Most native calendar apps cannot send custom headers;
// requires a proxy or special client
// → Rejected for this use case (firefighter mobile phones)
//
// Option C — Publicly readable feed (no auth):
// + Simplest
// - Leaks member names and attendance status to anyone with the URL
// → Rejected on privacy grounds
// -------------------------------------------------------------------------
getIcalExport = async (req: Request, res: Response): Promise<void> => {
try {
const token = req.query.token as string | undefined;
let userId: string | undefined;
if (token) {
const resolved = await trainingService.resolveCalendarToken(token);
if (!resolved) {
res.status(401).json({ success: false, message: 'Ungültiger Kalender-Token' });
return;
}
userId = resolved;
} else if (req.user) {
// Also accept a normal Bearer token for direct browser access
userId = req.user.id;
}
// userId may be undefined → returns all events without personal RSVP info
const ics = await trainingService.getCalendarExport(userId);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="feuerwehr-rems.ics"');
// 30-minute cache — calendar clients typically re-fetch at this interval
res.setHeader('Cache-Control', 'max-age=1800, public');
res.send(ics);
} catch (error) {
logger.error('getIcalExport error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/calendar-token (authenticated — get or create token)
// -------------------------------------------------------------------------
getCalendarToken = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const token = await trainingService.getOrCreateCalendarToken(req.user.id);
const baseUrl = environment.cors.origin.replace(/\/$/, '');
const subscribeUrl = `${baseUrl.replace('localhost:3001', 'localhost:3000')}/api/training/calendar.ics?token=${token}`;
res.json({
success: true,
data: {
token,
subscribeUrl,
instructions: 'Kopiere diese URL und füge sie in "Kalender abonnieren" ein (Apple Kalender, Google Calendar, Thunderbird, etc.).',
},
});
} catch (error) {
logger.error('getCalendarToken error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalender-Tokens' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/stats?year=2026
// -------------------------------------------------------------------------
getStats = async (req: Request, res: Response): Promise<void> => {
try {
const year = Number(req.query.year ?? new Date().getFullYear());
if (isNaN(year) || year < 2000 || year > 2100) {
res.status(400).json({ success: false, message: 'Ungültiges Jahr' });
return;
}
const stats = await trainingService.getMemberParticipationStats(year);
res.json({ success: true, data: stats, meta: { year } });
} catch (error) {
logger.error('getStats error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/:id
// -------------------------------------------------------------------------
getById = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const userId = req.user?.id;
// Determine if the requester may see the full attendee list
// In v1 we use a simple check: if the user has training:write permission
// (Kommandant / Gruppenführer), populate teilnahmen.
// The permission flag is set on req by requirePermission middleware,
// but we check it here by convention.
const canSeeTeilnahmen = (req as any).canSeeTeilnahmen === true;
const event = await trainingService.getEventById(id, userId, canSeeTeilnahmen);
if (!event) {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
res.json({ success: true, data: event });
} catch (error) {
logger.error('getById error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// POST /api/training
// -------------------------------------------------------------------------
createEvent = async (req: Request, res: Response): Promise<void> => {
try {
const parsed = CreateUebungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const event = await trainingService.createEvent(parsed.data, req.user!.id);
res.status(201).json({ success: true, data: event });
} catch (error) {
logger.error('createEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/training/:id
// -------------------------------------------------------------------------
updateEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const parsed = UpdateUebungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const event = await trainingService.updateEvent(id, parsed.data, req.user!.id);
res.json({ success: true, data: event });
} catch (error: any) {
if (error.message === 'Event not found') {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
logger.error('updateEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// DELETE /api/training/:id (soft cancel)
// -------------------------------------------------------------------------
cancelEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const parsed = CancelEventSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await trainingService.cancelEvent(id, parsed.data.absage_grund, req.user!.id);
res.json({ success: true, message: 'Veranstaltung wurde abgesagt' });
} catch (error: any) {
if (error.message === 'Event not found') {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
logger.error('cancelEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Absagen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/training/:id/attendance — own RSVP
// -------------------------------------------------------------------------
updateRsvp = async (req: Request, res: Response): Promise<void> => {
try {
const { id: uebungId } = req.params;
const userId = req.user!.id;
const parsed = UpdateRsvpSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await trainingService.updateAttendanceRSVP(
uebungId,
userId,
parsed.data.status,
parsed.data.bemerkung
);
res.json({ success: true, message: 'Rückmeldung gespeichert' });
} catch (error) {
logger.error('updateRsvp error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Rückmeldung' });
}
};
// -------------------------------------------------------------------------
// POST /api/training/:id/attendance/mark — bulk mark as erschienen
// -------------------------------------------------------------------------
markAttendance = async (req: Request, res: Response): Promise<void> => {
try {
const { id: uebungId } = req.params;
const parsed = MarkAttendanceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await trainingService.markAttendance(uebungId, parsed.data.userIds, req.user!.id);
res.json({
success: true,
message: `${parsed.data.userIds.length} Personen als erschienen markiert`,
});
} catch (error) {
logger.error('markAttendance error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erfassen der Anwesenheit' });
}
};
}
export default new TrainingController();

View File

@@ -0,0 +1,344 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import vehicleService from '../services/vehicle.service';
import { FahrzeugStatus, PruefungArt } from '../models/vehicle.model';
import logger from '../utils/logger';
// ── Zod Validation Schemas ────────────────────────────────────────────────────
const FahrzeugStatusEnum = z.enum([
FahrzeugStatus.Einsatzbereit,
FahrzeugStatus.AusserDienstWartung,
FahrzeugStatus.AusserDienstSchaden,
FahrzeugStatus.InLehrgang,
]);
const PruefungArtEnum = z.enum([
PruefungArt.HU,
PruefungArt.AU,
PruefungArt.UVV,
PruefungArt.Leiter,
PruefungArt.Kran,
PruefungArt.Seilwinde,
PruefungArt.Sonstiges,
]);
const isoDate = z.string().regex(
/^\d{4}-\d{2}-\d{2}$/,
'Expected ISO date format YYYY-MM-DD'
);
const CreateFahrzeugSchema = z.object({
bezeichnung: z.string().min(1).max(100),
kurzname: z.string().max(20).optional(),
amtliches_kennzeichen: z.string().max(20).optional(),
fahrgestellnummer: z.string().max(50).optional(),
baujahr: z.number().int().min(1950).max(2100).optional(),
hersteller: z.string().max(100).optional(),
typ_schluessel: z.string().max(30).optional(),
besatzung_soll: z.string().max(10).optional(),
status: FahrzeugStatusEnum.optional(),
status_bemerkung: z.string().max(500).optional(),
standort: z.string().max(100).optional(),
bild_url: z.string().url().max(500).optional(),
});
const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial();
const UpdateStatusSchema = z.object({
status: FahrzeugStatusEnum,
bemerkung: z.string().max(500).optional().default(''),
});
const CreatePruefungSchema = z.object({
pruefung_art: PruefungArtEnum,
faellig_am: isoDate,
durchgefuehrt_am: isoDate.optional(),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden', 'ausstehend']).optional(),
pruefende_stelle: z.string().max(150).optional(),
kosten: z.number().min(0).optional(),
dokument_url: z.string().url().max(500).optional(),
bemerkung: z.string().max(1000).optional(),
});
const CreateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(),
beschreibung: z.string().min(1).max(2000),
km_stand: z.number().int().min(0).optional(),
kraftstoff_liter: z.number().min(0).optional(),
kosten: z.number().min(0).optional(),
externe_werkstatt: z.string().max(150).optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
function getUserId(req: Request): string {
// req.user is guaranteed by the authenticate middleware
return req.user!.id;
}
// ── Controller ────────────────────────────────────────────────────────────────
class VehicleController {
/**
* GET /api/vehicles
* Fleet overview list with per-vehicle inspection badge data.
*/
async listVehicles(req: Request, res: Response): Promise<void> {
try {
const vehicles = await vehicleService.getAllVehicles();
res.status(200).json({ success: true, data: vehicles });
} catch (error) {
logger.error('listVehicles error', { error });
res.status(500).json({ success: false, message: 'Fahrzeuge konnten nicht geladen werden' });
}
}
/**
* GET /api/vehicles/stats
* Aggregated KPI counts for the dashboard strip.
*/
async getStats(req: Request, res: Response): Promise<void> {
try {
const stats = await vehicleService.getVehicleStats();
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('getStats error', { error });
res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' });
}
}
/**
* GET /api/vehicles/alerts?daysAhead=30
* Upcoming and overdue inspections — used by the InspectionAlerts dashboard panel.
* Returns alerts sorted by urgency (most overdue / soonest due first).
*/
async getAlerts(req: Request, res: Response): Promise<void> {
try {
const daysAhead = Math.min(
parseInt((req.query.daysAhead as string) || '30', 10),
365 // hard cap — never expose more than 1 year of lookahead
);
if (isNaN(daysAhead) || daysAhead < 0) {
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
return;
}
const alerts = await vehicleService.getUpcomingInspections(daysAhead);
res.status(200).json({ success: true, data: alerts });
} catch (error) {
logger.error('getAlerts error', { error });
res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' });
}
}
/**
* GET /api/vehicles/:id
* Full vehicle detail with pruefstatus, inspection history, and wartungslog.
*/
async getVehicle(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const vehicle = await vehicleService.getVehicleById(id);
if (!vehicle) {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: vehicle });
} catch (error) {
logger.error('getVehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht geladen werden' });
}
}
/**
* POST /api/vehicles
* Create a new vehicle. Requires vehicles:write permission.
*/
async createVehicle(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateFahrzeugSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: vehicle });
} catch (error) {
logger.error('createVehicle error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht erstellt werden' });
}
}
/**
* PATCH /api/vehicles/:id
* Update vehicle fields. Requires vehicles:write permission.
*/
async updateVehicle(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const parsed = UpdateFahrzeugSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req));
res.status(200).json({ success: true, data: vehicle });
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('updateVehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht aktualisiert werden' });
}
}
/**
* PATCH /api/vehicles/:id/status
* Live status change — the Socket.IO hook point for Tier 3.
* Requires vehicles:write permission.
*
* The `io` instance is attached to req.app in server.ts (Tier 3):
* app.set('io', io);
* and retrieved here via req.app.get('io').
*/
async updateVehicleStatus(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const parsed = UpdateStatusSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
// Tier 3: io will be available via req.app.get('io') once Socket.IO is wired up.
// Passing undefined here is safe — the service handles it gracefully.
const io = req.app.get('io') ?? undefined;
await vehicleService.updateVehicleStatus(
id,
parsed.data.status,
parsed.data.bemerkung,
getUserId(req),
io
);
res.status(200).json({ success: true, message: 'Status aktualisiert' });
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('updateVehicleStatus error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
}
}
// ── 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;
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;
const pruefungen = await vehicleService.getPruefungenForVehicle(id);
res.status(200).json({ success: true, data: pruefungen });
} catch (error) {
logger.error('getPruefungen error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Prüfungshistorie konnte nicht geladen werden' });
}
}
// ── Maintenance Log ──────────────────────────────────────────────────────────
/**
* POST /api/vehicles/:id/wartung
* Add a maintenance log entry. Requires vehicles:write.
*/
async addWartung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const parsed = CreateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: entry });
} catch (error) {
logger.error('addWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
}
}
/**
* GET /api/vehicles/:id/wartung
* Maintenance log for a vehicle.
*/
async getWartung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const entries = await vehicleService.getWartungslogForVehicle(id);
res.status(200).json({ success: true, data: entries });
} catch (error) {
logger.error('getWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' });
}
}
}
export default new VehicleController();

View File

@@ -0,0 +1,146 @@
-- =============================================================================
-- Migration 002: Audit Log Table
-- GDPR Art. 5(2) Accountability + Art. 30 Records of Processing Activities
--
-- Design decisions:
-- - UUID primary key (consistent with users table)
-- - user_id is nullable: pre-auth events (LOGIN failures) have no user yet
-- - old_value / new_value are JSONB: flexible, queryable, indexable
-- - ip_address stored as TEXT (supports IPv4 + IPv6); anonymised after 90 days
-- - Table is immutable: a PostgreSQL RULE blocks UPDATE and DELETE at the
-- SQL level, which is simpler than triggers and cannot be bypassed by the
-- application role
-- - Partial index on ip_address covers only recent rows (90-day window) to
-- support efficient anonymisation queries without a full-table scan
-- =============================================================================
-- -------------------------------------------------------
-- 1. Enums — define before the table
-- -------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM (
'CREATE',
'UPDATE',
'DELETE',
'LOGIN',
'LOGOUT',
'EXPORT',
'PERMISSION_DENIED',
'PASSWORD_CHANGE',
'ROLE_CHANGE'
);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_resource_type') THEN
CREATE TYPE audit_resource_type AS ENUM (
'MEMBER',
'INCIDENT',
'VEHICLE',
'EQUIPMENT',
'QUALIFICATION',
'USER',
'SYSTEM'
);
END IF;
END
$$;
-- -------------------------------------------------------
-- 2. Core audit_log table
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
user_email VARCHAR(255), -- denormalised snapshot; users can be deleted
action audit_action NOT NULL,
resource_type audit_resource_type NOT NULL,
resource_id VARCHAR(255), -- may be UUID, numeric ID, or 'system'
old_value JSONB, -- state before the operation
new_value JSONB, -- state after the operation
ip_address TEXT, -- anonymised to '[anonymized]' after 90 days
user_agent TEXT,
metadata JSONB DEFAULT '{}', -- any extra context (e.g. export format, reason)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Prevent modification of audit records at the database level.
-- Using RULE rather than a trigger because rules are evaluated before
-- the operation and cannot be disabled without superuser access.
CREATE OR REPLACE RULE audit_log_no_update AS
ON UPDATE TO audit_log DO INSTEAD NOTHING;
CREATE OR REPLACE RULE audit_log_no_delete AS
ON DELETE TO audit_log DO INSTEAD NOTHING;
-- -------------------------------------------------------
-- 3. Indexes
-- -------------------------------------------------------
-- Lookup by actor (admin "what did this user do?")
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id
ON audit_log (user_id)
WHERE user_id IS NOT NULL;
-- Lookup by subject resource (admin "what happened to member X?")
CREATE INDEX IF NOT EXISTS idx_audit_log_resource
ON audit_log (resource_type, resource_id);
-- Time-range queries and retention scans (most common filter)
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at
ON audit_log (created_at DESC);
-- Action-type filter
CREATE INDEX IF NOT EXISTS idx_audit_log_action
ON audit_log (action);
-- Partial index: only rows where IP address is not yet anonymised.
-- The anonymisation job does: WHERE created_at < NOW() - INTERVAL '90 days'
-- AND ip_address != '[anonymized]'
-- This index makes that query O(matched rows) instead of O(table size).
CREATE INDEX IF NOT EXISTS idx_audit_log_ip_retention
ON audit_log (created_at)
WHERE ip_address IS NOT NULL
AND ip_address != '[anonymized]';
-- -------------------------------------------------------
-- 4. OPTIONAL — Range partitioning by month (recommended
-- for high-volume deployments; skip in small setups)
--
-- To use partitioning, replace the CREATE TABLE above with
-- the following DDL *before* the first row is inserted.
-- Partitioning cannot be added to an existing unpartitioned
-- table without a full rewrite (pg_partman can automate this).
--
-- CREATE TABLE audit_log (
-- id UUID NOT NULL DEFAULT uuid_generate_v4(),
-- user_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- user_email VARCHAR(255),
-- action audit_action NOT NULL,
-- resource_type audit_resource_type NOT NULL,
-- resource_id VARCHAR(255),
-- old_value JSONB,
-- new_value JSONB,
-- ip_address TEXT,
-- user_agent TEXT,
-- metadata JSONB DEFAULT '{}',
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- ) PARTITION BY RANGE (created_at);
--
-- -- Create monthly partitions with pg_partman or manually:
-- CREATE TABLE audit_log_2026_01 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- CREATE TABLE audit_log_2026_02 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- -- ... and so on. pg_partman automates partition creation.
--
-- With partitioning, old partitions can be DROPped for
-- efficient bulk retention deletion without a full-table scan.
-- -------------------------------------------------------

View File

@@ -0,0 +1,138 @@
-- Migration: 003_create_mitglieder_profile
-- Creates the mitglieder_profile and dienstgrad_verlauf tables for fire department member data.
-- Rollback:
-- DROP TABLE IF EXISTS dienstgrad_verlauf;
-- DROP TABLE IF EXISTS mitglieder_profile;
-- ============================================================
-- mitglieder_profile
-- One-to-one extension of the users table.
-- A user can exist without a profile (profile is created later
-- by a Kommandant). The user_id is both FK and UNIQUE.
-- ============================================================
CREATE TABLE IF NOT EXISTS mitglieder_profile (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Internal member identifier assigned by the Kommandant
mitglieds_nr VARCHAR(32) UNIQUE,
-- Rank (Dienstgrad) with allowed values enforced by CHECK
dienstgrad VARCHAR(64)
CHECK (dienstgrad IS NULL OR dienstgrad IN (
'Feuerwehranwärter',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann'
)),
dienstgrad_seit DATE,
-- Funktion(en) — a member can hold multiple roles simultaneously
-- Stored as a PostgreSQL TEXT array
funktion TEXT[] DEFAULT '{}',
-- Membership status
status VARCHAR(32) NOT NULL DEFAULT 'aktiv'
CHECK (status IN (
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten'
)),
-- Important dates
eintrittsdatum DATE,
austrittsdatum DATE,
geburtsdatum DATE, -- sensitive: only shown to Kommandant/Admin
-- Contact information (stored raw, formatted on display)
telefon_mobil VARCHAR(32),
telefon_privat VARCHAR(32),
-- Emergency contact (sensitive: only own record visible to Mitglied)
notfallkontakt_name VARCHAR(255),
notfallkontakt_telefon VARCHAR(32),
-- Driving licenses (e.g. ['B', 'C', 'CE'])
fuehrerscheinklassen TEXT[] DEFAULT '{}',
-- Uniform sizing
tshirt_groesse VARCHAR(8)
CHECK (tshirt_groesse IS NULL OR tshirt_groesse IN (
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'
)),
schuhgroesse VARCHAR(8),
-- Free-text notes (Kommandant only)
bemerkungen TEXT,
-- Profile photo URL (separate from the Authentik profile_picture_url)
bild_url TEXT,
-- Audit timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Enforce one-to-one relationship with users
CONSTRAINT uq_mitglieder_profile_user_id UNIQUE (user_id)
);
-- ============================================================
-- Indexes for the most common query patterns
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_user_id
ON mitglieder_profile(user_id);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_status
ON mitglieder_profile(status);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_dienstgrad
ON mitglieder_profile(dienstgrad);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_mitglieds_nr
ON mitglieder_profile(mitglieds_nr);
-- ============================================================
-- Auto-update trigger for updated_at
-- Reuses the function already created by migration 001.
-- ============================================================
CREATE TRIGGER update_mitglieder_profile_updated_at
BEFORE UPDATE ON mitglieder_profile
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- dienstgrad_verlauf
-- Append-only audit log of every rank change.
-- Never deleted; soft history only.
-- ============================================================
CREATE TABLE IF NOT EXISTS dienstgrad_verlauf (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dienstgrad_neu VARCHAR(64) NOT NULL,
dienstgrad_alt VARCHAR(64), -- NULL on first assignment
datum DATE NOT NULL DEFAULT CURRENT_DATE,
durch_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
bemerkung TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_user_id
ON dienstgrad_verlauf(user_id);
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_datum
ON dienstgrad_verlauf(datum DESC);

View File

@@ -0,0 +1,270 @@
-- Migration: 004_create_einsaetze.sql
-- Feature: Einsatzprotokoll (Incident Management)
-- Depends on: 001_create_users_table.sql
-- Rollback instructions at bottom of file
-- ---------------------------------------------------------------------------
-- ENUM-LIKE CHECK CONSTRAINTS (as VARCHAR + CHECK for easy extension)
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- SEQUENCE SUPPORT TABLE for year-based Einsatz-Nr (YYYY-NNN)
-- One row per year; nextval equivalent done via UPDATE...RETURNING
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_nr_sequence (
year INTEGER PRIMARY KEY,
last_nr INTEGER NOT NULL DEFAULT 0
);
-- ---------------------------------------------------------------------------
-- FAHRZEUGE TABLE (vehicles — stub so junction table has a valid FK target)
-- A full Fahrzeuge feature is covered by 005_create_fahrzeuge.sql.
-- We do NOT create the table here; migration 005 owns it.
-- The FK in einsatz_fahrzeuge will resolve correctly when 005 runs first
-- in the standard sort order (004 runs before 005, so we use DEFERRABLE or
-- we simply add the FK as a separate ALTER TABLE at the end of this file,
-- after we know 005 may not have run yet).
--
-- Resolution strategy: einsatz_fahrzeuge.fahrzeug_id FK is defined as
-- DEFERRABLE INITIALLY DEFERRED so it is checked at transaction commit time
-- rather than at statement time, allowing this migration to run without
-- requiring fahrzeuge to exist yet.
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- MAIN EINSAETZE TABLE
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsaetze (
-- Identity
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
einsatz_nr VARCHAR(12) NOT NULL, -- Format: 2026-001
-- Core timestamps (all TIMESTAMPTZ — never null for alarm_time)
alarm_time TIMESTAMPTZ NOT NULL,
ausrueck_time TIMESTAMPTZ,
ankunft_time TIMESTAMPTZ,
einrueck_time TIMESTAMPTZ,
-- Classification
einsatz_art VARCHAR(30) NOT NULL
CONSTRAINT chk_einsatz_art CHECK (einsatz_art IN (
'Brand',
'THL',
'ABC',
'BMA',
'Hilfeleistung',
'Fehlalarm',
'Brandsicherheitswache'
)),
einsatz_stichwort VARCHAR(30), -- B1/B2/B3/B4, THL 1/2/3, etc.
-- Location
strasse VARCHAR(150),
hausnummer VARCHAR(20),
ort VARCHAR(100),
koordinaten POINT, -- (longitude, latitude)
-- Narrative
bericht_kurz VARCHAR(255), -- short description, visible to all
bericht_text TEXT, -- full narrative, restricted to Kommandant+
-- References
einsatzleiter_id UUID
CONSTRAINT fk_einsaetze_einsatzleiter REFERENCES users(id) ON DELETE SET NULL,
-- Operational metadata
alarmierung_art VARCHAR(30) NOT NULL DEFAULT 'ILS'
CONSTRAINT chk_alarmierung_art CHECK (alarmierung_art IN (
'ILS',
'DME',
'Telefon',
'Vor_Ort',
'Sonstiges'
)),
status VARCHAR(20) NOT NULL DEFAULT 'aktiv'
CONSTRAINT chk_einsatz_status CHECK (status IN (
'aktiv',
'abgeschlossen',
'archiviert'
)),
-- Audit
created_by UUID
CONSTRAINT fk_einsaetze_created_by REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Uniqueness: einsatz_nr is globally unique (year+seq guarantees it)
CONSTRAINT uq_einsatz_nr UNIQUE (einsatz_nr)
);
-- Performance indexes
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_time ON einsaetze(alarm_time DESC);
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatz_art ON einsaetze(einsatz_art);
CREATE INDEX IF NOT EXISTS idx_einsaetze_status ON einsaetze(status);
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatzleiter ON einsaetze(einsatzleiter_id);
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_year ON einsaetze(EXTRACT(YEAR FROM alarm_time));
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_art_year ON einsaetze(einsatz_art, EXTRACT(YEAR FROM alarm_time));
-- Auto-update updated_at
CREATE TRIGGER update_einsaetze_updated_at
BEFORE UPDATE ON einsaetze
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ---------------------------------------------------------------------------
-- JUNCTION TABLE: einsatz_fahrzeuge
-- NOTE: fahrzeug_id FK references fahrzeuge which is created in 005.
-- We define the FK as DEFERRABLE INITIALLY DEFERRED so the constraint is
-- validated at transaction commit, not statement time. This means the
-- migration can run before 005 as long as both run in the same session.
-- In production where 005 was already run, the FK resolves immediately.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_fahrzeuge (
einsatz_id UUID NOT NULL
CONSTRAINT fk_ef_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
fahrzeug_id UUID NOT NULL,
-- FK added separately below via ALTER TABLE to handle cross-migration dependency
-- Vehicle-level timestamps (may differ from main einsatz times)
ausrueck_time TIMESTAMPTZ,
einrueck_time TIMESTAMPTZ,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_einsatz_fahrzeuge PRIMARY KEY (einsatz_id, fahrzeug_id)
);
-- Add the FK to fahrzeuge only if the fahrzeuge table already exists
-- (handles the case where 004 runs after 005 in a fresh deployment).
-- If fahrzeuge does not exist yet, the FK will be added by migration 005
-- via an ALTER TABLE at the end of that migration.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'fahrzeuge'
) THEN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'einsatz_fahrzeuge'
AND constraint_name = 'fk_ef_fahrzeug'
) THEN
ALTER TABLE einsatz_fahrzeuge
ADD CONSTRAINT fk_ef_fahrzeug
FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE;
END IF;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_ef_fahrzeug_id ON einsatz_fahrzeuge(fahrzeug_id);
-- ---------------------------------------------------------------------------
-- JUNCTION TABLE: einsatz_personal
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_personal (
einsatz_id UUID NOT NULL
CONSTRAINT fk_ep_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
user_id UUID NOT NULL
CONSTRAINT fk_ep_user REFERENCES users(id) ON DELETE CASCADE,
funktion VARCHAR(50) NOT NULL DEFAULT 'Mannschaft'
CONSTRAINT chk_ep_funktion CHECK (funktion IN (
'Einsatzleiter',
'Gruppenführer',
'Maschinist',
'Atemschutz',
'Sicherheitstrupp',
'Melder',
'Wassertrupp',
'Angriffstrupp',
'Mannschaft',
'Sonstiges'
)),
-- Personal-level timestamps
alarm_time TIMESTAMPTZ,
ankunft_time TIMESTAMPTZ,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_einsatz_personal PRIMARY KEY (einsatz_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_ep_user_id ON einsatz_personal(user_id);
CREATE INDEX IF NOT EXISTS idx_ep_funktion ON einsatz_personal(funktion);
-- ---------------------------------------------------------------------------
-- FUNCTION: generate_einsatz_nr(alarm_time)
-- Atomically generates the next Einsatz-Nr for a given year.
-- Uses an advisory lock to serialise concurrent inserts for the same year.
-- Returns format: '2026-001'
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION generate_einsatz_nr(p_alarm_time TIMESTAMPTZ)
RETURNS VARCHAR AS $$
DECLARE
v_year INTEGER;
v_nr INTEGER;
BEGIN
v_year := EXTRACT(YEAR FROM p_alarm_time)::INTEGER;
-- Upsert the sequence row, then atomically increment and return
INSERT INTO einsatz_nr_sequence (year, last_nr)
VALUES (v_year, 1)
ON CONFLICT (year) DO UPDATE
SET last_nr = einsatz_nr_sequence.last_nr + 1
RETURNING last_nr INTO v_nr;
RETURN v_year::TEXT || '-' || LPAD(v_nr::TEXT, 3, '0');
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- MATERIALIZED VIEW: einsatz_statistik
-- Pre-aggregated stats used by the dashboard and annual KBI reports.
-- Refresh manually after inserts/updates via: REFRESH MATERIALIZED VIEW einsatz_statistik;
-- ---------------------------------------------------------------------------
CREATE MATERIALIZED VIEW IF NOT EXISTS einsatz_statistik AS
SELECT
EXTRACT(YEAR FROM alarm_time)::INTEGER AS jahr,
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
einsatz_art,
COUNT(*) AS anzahl,
-- Hilfsfrist: median minutes from alarm to arrival (only where ankunft_time is set)
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
-- Total duration (alarm to einrueck)
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min,
COUNT(*) FILTER (WHERE status = 'abgeschlossen') AS anzahl_abgeschlossen,
COUNT(*) FILTER (WHERE status = 'aktiv') AS anzahl_aktiv
FROM einsaetze
WHERE status != 'archiviert'
GROUP BY
EXTRACT(YEAR FROM alarm_time),
EXTRACT(MONTH FROM alarm_time),
einsatz_art
WITH DATA;
CREATE UNIQUE INDEX IF NOT EXISTS idx_einsatz_statistik_pk
ON einsatz_statistik(jahr, monat, einsatz_art);
CREATE INDEX IF NOT EXISTS idx_einsatz_statistik_jahr
ON einsatz_statistik(jahr);
-- ---------------------------------------------------------------------------
-- ROLLBACK INSTRUCTIONS (run in reverse order to undo):
--
-- DROP MATERIALIZED VIEW IF EXISTS einsatz_statistik;
-- DROP FUNCTION IF EXISTS generate_einsatz_nr(TIMESTAMPTZ);
-- DROP TABLE IF EXISTS einsatz_personal;
-- DROP TABLE IF EXISTS einsatz_fahrzeuge;
-- DROP TABLE IF EXISTS einsaetze;
-- DROP TABLE IF EXISTS fahrzeuge;
-- DROP TABLE IF EXISTS einsatz_nr_sequence;
-- ---------------------------------------------------------------------------

View File

@@ -0,0 +1,331 @@
-- Migration 005: Fahrzeugverwaltung (Vehicle Fleet Management)
-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table)
-- ============================================================
-- TABLE: fahrzeuge
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeuge (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bezeichnung VARCHAR(100) NOT NULL, -- e.g. "LF 20/16", "HLF 10"
kurzname VARCHAR(20), -- e.g. "LF1", "HLF2"
amtliches_kennzeichen VARCHAR(20) UNIQUE, -- e.g. "WN-FW 1"
fahrgestellnummer VARCHAR(50), -- VIN
baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100),
hersteller VARCHAR(100), -- e.g. "MAN", "Mercedes-Benz", "Rosenbauer"
typ_schluessel VARCHAR(30), -- DIN 14502 code, e.g. "LF 20/16"
besatzung_soll VARCHAR(10), -- crew config e.g. "1/8", "1/5"
status VARCHAR(40) NOT NULL DEFAULT 'einsatzbereit'
CHECK (status IN (
'einsatzbereit',
'ausser_dienst_wartung',
'ausser_dienst_schaden',
'in_lehrgang'
)),
status_bemerkung TEXT,
standort VARCHAR(100) NOT NULL DEFAULT 'Feuerwehrhaus',
bild_url VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_status ON fahrzeuge(status);
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_kennzeichen ON fahrzeuge(amtliches_kennzeichen);
-- Auto-update updated_at (reuses function from migration 001)
CREATE TRIGGER update_fahrzeuge_updated_at
BEFORE UPDATE ON fahrzeuge
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- TABLE: fahrzeug_pruefungen
-- Stores both upcoming scheduled inspections AND completed ones.
-- A row with durchgefuehrt_am = NULL is an open/scheduled inspection.
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeug_pruefungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
pruefung_art VARCHAR(30) NOT NULL
CHECK (pruefung_art IN (
'HU', -- Hauptuntersuchung (TÜV), 24-month interval
'AU', -- Abgasuntersuchung, 12-month interval
'UVV', -- Unfallverhütungsvorschrift BGV D29, 12-month
'Leiter', -- Leiternprüfung (DLK), 12-month
'Kran', -- Kranprüfung, 12-month
'Seilwinde', -- Seilwindenprüfung, 12-month
'Sonstiges'
)),
-- faellig_am: the deadline by which this inspection must be completed
faellig_am DATE NOT NULL,
-- durchgefuehrt_am: NULL = not yet done; set when inspection is completed
durchgefuehrt_am DATE,
ergebnis VARCHAR(30)
CHECK (ergebnis IS NULL OR ergebnis IN (
'bestanden',
'bestanden_mit_maengeln',
'nicht_bestanden',
'ausstehend'
)),
-- naechste_faelligkeit: auto-calculated next due date after completion
naechste_faelligkeit DATE,
pruefende_stelle VARCHAR(150), -- e.g. "TÜV Süd Stuttgart", "DEKRA"
kosten DECIMAL(8,2),
dokument_url VARCHAR(500),
bemerkung TEXT,
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_id ON fahrzeug_pruefungen(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_pruefungen_faellig_am ON fahrzeug_pruefungen(faellig_am);
CREATE INDEX IF NOT EXISTS idx_pruefungen_art ON fahrzeug_pruefungen(pruefung_art);
-- Composite index for the "latest per type" query pattern
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_art_faellig
ON fahrzeug_pruefungen(fahrzeug_id, pruefung_art, faellig_am DESC);
-- ============================================================
-- TABLE: fahrzeug_wartungslog
-- Service/maintenance log entries (fuel, repairs, tyres, etc.)
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeug_wartungslog (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
datum DATE NOT NULL,
art VARCHAR(30)
CHECK (art IS NULL OR art IN (
'Inspektion',
'Reparatur',
'Kraftstoff',
'Reifenwechsel',
'Hauptuntersuchung',
'Reinigung',
'Sonstiges'
)),
beschreibung TEXT NOT NULL,
km_stand INTEGER CHECK (km_stand >= 0),
kraftstoff_liter DECIMAL(6,2) CHECK (kraftstoff_liter >= 0),
kosten DECIMAL(8,2) CHECK (kosten >= 0),
externe_werkstatt VARCHAR(150),
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wartungslog_fahrzeug_id ON fahrzeug_wartungslog(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_wartungslog_datum ON fahrzeug_wartungslog(datum DESC);
-- ============================================================
-- VIEW: fahrzeuge_mit_pruefstatus
-- For each vehicle, joins its LATEST scheduled pruefung per art
-- and computes tage_bis_faelligkeit (negative = overdue).
-- The dashboard alert panel and fleet overview query this view.
-- ============================================================
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
WITH latest_pruefungen AS (
-- For each (fahrzeug, pruefung_art) pair, pick the row with the
-- latest faellig_am that is NOT yet completed (durchgefuehrt_am IS NULL),
-- OR if all are completed, the one with the highest naechste_faelligkeit.
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,
-- Open inspections (nicht durchgeführt) first, then most recent
(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,
-- HU
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
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
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 (DLK only)
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 tage_bis_faelligkeit across all active inspections
LEAST(
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';
-- ============================================================
-- SEED DATA: 3 typical German Feuerwehr vehicles
-- ============================================================
DO $$
DECLARE
v_lf10_id UUID := uuid_generate_v4();
v_hlf20_id UUID := uuid_generate_v4();
v_mtf_id UUID := uuid_generate_v4();
BEGIN
-- Only insert if no vehicles exist yet (idempotent seed)
IF (SELECT COUNT(*) FROM fahrzeuge) = 0 THEN
-- 1) LF 10 Standard Löschgruppenfahrzeug
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, standort
) VALUES (
v_lf10_id,
'LF 10',
'LF 1',
'WN-FW 1',
'WDB9634031L123456',
2018,
'Mercedes-Benz Atego',
'LF 10',
'1/8',
'einsatzbereit',
'Feuerwehrhaus'
);
-- LF 10 inspections
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_lf10_id, 'HU', '2026-03-15', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_lf10_id, 'AU', '2026-04-01', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_lf10_id, 'UVV', '2025-11-30', '2025-11-28', 'bestanden', '2026-11-28', 'DGUV Prüfer Rems');
-- LF 10 maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_lf10_id, '2025-11-28', 'Inspektion', 'Jahresinspektion nach Herstellervorgabe, Öl- und Filterwechsel, Bremsenprüfung', 48320, NULL, 420.00),
(v_lf10_id, '2025-10-15', 'Kraftstoff', 'Betankung nach Einsatz Feuerwehr Rems', 48150, 85.4, 145.18),
(v_lf10_id, '2025-09-01', 'Reifenwechsel','Sommerreifen auf Winterreifen gewechselt, alle 4 Reifen erneuert (Continental)', 47800, NULL, 980.00);
-- 2) HLF 20/16 Hilfeleistungslöschgruppenfahrzeug (flagship)
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, standort
) VALUES (
v_hlf20_id,
'HLF 20/16',
'HLF 1',
'WN-FW 2',
'WMAN29ZZ3LM654321',
2020,
'MAN TGM / Rosenbauer',
'HLF 20/16',
'1/8',
'einsatzbereit',
'Feuerwehrhaus'
);
-- HLF 20 inspections — all current
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_hlf20_id, 'HU', '2026-08-20', NULL, 'ausstehend', NULL, 'DEKRA Esslingen'),
(v_hlf20_id, 'AU', '2026-02-01', '2026-01-28', 'bestanden', '2027-01-28', 'DEKRA Esslingen'),
(v_hlf20_id, 'UVV', '2026-01-15', '2026-01-14', 'bestanden', '2027-01-14', 'DGUV Prüfer Rems');
-- HLF 20 maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_hlf20_id, '2026-01-28', 'Hauptuntersuchung', 'AU bestanden ohne Mängel bei DEKRA Esslingen', 22450, NULL, 185.00),
(v_hlf20_id, '2026-01-14', 'Inspektion', 'UVV-Prüfung bestanden, Licht und Bremsen geprüft', 22430, NULL, 0.00),
(v_hlf20_id, '2025-12-10', 'Kraftstoff', 'Betankung nach Übung', 22300, 120.0, 204.00),
(v_hlf20_id, '2025-10-05', 'Reparatur', 'Hydraulikpumpe für Rettungssatz getauscht', 21980, NULL, 2340.00);
-- 3) MTF Mannschaftstransportfahrzeug
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, status_bemerkung, standort
) VALUES (
v_mtf_id,
'MTF',
'MTF 1',
'WN-FW 5',
'WDB9066371S789012',
2015,
'Mercedes-Benz Sprinter',
'MTF',
'1/8',
'ausser_dienst_wartung',
'Geplante Inspektion: Zahnriemenwechsel 60.000 km fällig',
'Feuerwehrhaus'
);
-- MTF inspections — HU overdue (safety-critical test data)
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_mtf_id, 'HU', '2026-01-31', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_mtf_id, 'AU', '2025-06-15', '2025-06-12', 'bestanden', '2026-06-12', 'TÜV Süd Stuttgart'),
(v_mtf_id, 'UVV', '2025-12-01', '2025-11-30', 'bestanden_mit_maengeln', '2026-11-30', 'DGUV Prüfer Rems');
-- MTF maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_mtf_id, '2025-11-30', 'Inspektion', 'UVV-Prüfung: kleiner Mangel Innenbeleuchtung, nachgebessert', 58920, NULL, 0.00),
(v_mtf_id, '2025-11-01', 'Kraftstoff', 'Betankung regulär', 58700, 65.0, 110.50),
(v_mtf_id, '2025-09-20', 'Reparatur', 'Heckleuchte links defekt, Glühbirne getauscht', 58400, NULL, 12.50);
END IF;
END
$$;
-- ---------------------------------------------------------------------------
-- Cross-migration FK: add fahrzeug_id FK to einsatz_fahrzeuge (created in 004)
-- This runs only if einsatz_fahrzeuge exists but the FK is not yet present.
-- Handles the case where 004 ran before 005 and deferred the FK creation.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'einsatz_fahrzeuge'
)
AND NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'einsatz_fahrzeuge'
AND constraint_name = 'fk_ef_fahrzeug'
) THEN
ALTER TABLE einsatz_fahrzeuge
ADD CONSTRAINT fk_ef_fahrzeug
FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE;
RAISE NOTICE 'Added fk_ef_fahrzeug FK on einsatz_fahrzeuge';
END IF;
END $$;

View File

@@ -0,0 +1,201 @@
-- =============================================================================
-- Migration 006: Übungsplanung & Dienstkalender
-- Training Schedule & Service Calendar for Feuerwehr Rems Dashboard
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. Calendar token table for iCal subscribe URLs (no auth required per-request)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS calendar_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_calendar_tokens_token ON calendar_tokens(token);
CREATE INDEX idx_calendar_tokens_user_id ON calendar_tokens(user_id);
-- -----------------------------------------------------------------------------
-- 2. Main events table
-- -----------------------------------------------------------------------------
CREATE TYPE uebung_typ AS ENUM (
'Übungsabend',
'Lehrgang',
'Sonderdienst',
'Versammlung',
'Gemeinschaftsübung',
'Sonstiges'
);
CREATE TABLE uebungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
titel VARCHAR(255) NOT NULL,
beschreibung TEXT,
typ uebung_typ NOT NULL,
datum_von TIMESTAMPTZ NOT NULL,
datum_bis TIMESTAMPTZ NOT NULL,
ort VARCHAR(255),
treffpunkt VARCHAR(255),
pflichtveranstaltung BOOLEAN NOT NULL DEFAULT FALSE,
mindest_teilnehmer INT CHECK (mindest_teilnehmer > 0),
max_teilnehmer INT CHECK (max_teilnehmer > 0),
angelegt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
absage_grund TEXT,
CONSTRAINT datum_reihenfolge CHECK (datum_bis >= datum_von),
CONSTRAINT max_groesser_min CHECK (
max_teilnehmer IS NULL OR
mindest_teilnehmer IS NULL OR
max_teilnehmer >= mindest_teilnehmer
)
);
CREATE INDEX idx_uebungen_datum_von ON uebungen(datum_von);
CREATE INDEX idx_uebungen_typ ON uebungen(typ);
CREATE INDEX idx_uebungen_pflichtveranstaltung ON uebungen(pflichtveranstaltung) WHERE pflichtveranstaltung = TRUE;
CREATE INDEX idx_uebungen_abgesagt ON uebungen(abgesagt) WHERE abgesagt = FALSE;
-- Compound index for the most common calendar-range query
CREATE INDEX idx_uebungen_datum_von_bis ON uebungen(datum_von, datum_bis);
-- Keep aktualisiert_am in sync via trigger (reuse function from migration 001)
CREATE TRIGGER update_uebungen_aktualisiert_am
BEFORE UPDATE ON uebungen
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 3. Attendance / RSVP table
-- -----------------------------------------------------------------------------
CREATE TYPE teilnahme_status AS ENUM (
'zugesagt',
'abgesagt',
'erschienen',
'entschuldigt',
'unbekannt'
);
CREATE TABLE uebung_teilnahmen (
uebung_id UUID NOT NULL REFERENCES uebungen(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status teilnahme_status NOT NULL DEFAULT 'unbekannt',
antwort_am TIMESTAMPTZ,
erschienen_erfasst_am TIMESTAMPTZ,
erschienen_erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
bemerkung VARCHAR(500),
PRIMARY KEY (uebung_id, user_id)
);
CREATE INDEX idx_teilnahmen_uebung_id ON uebung_teilnahmen(uebung_id);
CREATE INDEX idx_teilnahmen_user_id ON uebung_teilnahmen(user_id);
CREATE INDEX idx_teilnahmen_status ON uebung_teilnahmen(status);
-- -----------------------------------------------------------------------------
-- 4. Trigger: auto-create 'unbekannt' rows for all active members on new event
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION fn_create_teilnahmen_for_all_active_members()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
SELECT NEW.id, u.id, 'unbekannt'
FROM users u
WHERE u.is_active = TRUE
ON CONFLICT (uebung_id, user_id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_auto_teilnahmen_after_insert
AFTER INSERT ON uebungen
FOR EACH ROW
EXECUTE FUNCTION fn_create_teilnahmen_for_all_active_members();
-- -----------------------------------------------------------------------------
-- 5. Trigger: when a new member becomes active, add them to all future events
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION fn_add_member_to_future_events()
RETURNS TRIGGER AS $$
BEGIN
-- Only run when is_active transitions FALSE -> TRUE
IF (OLD.is_active = FALSE OR OLD.is_active IS NULL) AND NEW.is_active = TRUE THEN
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
SELECT u.id, NEW.id, 'unbekannt'
FROM uebungen u
WHERE u.datum_von > NOW()
AND u.abgesagt = FALSE
ON CONFLICT (uebung_id, user_id) DO NOTHING;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_add_member_to_future_events
AFTER UPDATE OF is_active ON users
FOR EACH ROW
EXECUTE FUNCTION fn_add_member_to_future_events();
-- -----------------------------------------------------------------------------
-- 6. Convenience view: event overview with attendance counts
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW uebung_uebersicht AS
SELECT
u.id,
u.titel,
u.typ,
u.datum_von,
u.datum_bis,
u.ort,
u.treffpunkt,
u.pflichtveranstaltung,
u.mindest_teilnehmer,
u.max_teilnehmer,
u.abgesagt,
u.absage_grund,
u.angelegt_von,
u.erstellt_am,
u.aktualisiert_am,
-- Attendance aggregates
COUNT(t.user_id) AS gesamt_eingeladen,
COUNT(t.user_id) FILTER (WHERE t.status = 'zugesagt') AS anzahl_zugesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'abgesagt') AS anzahl_abgesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'erschienen') AS anzahl_erschienen,
COUNT(t.user_id) FILTER (WHERE t.status = 'entschuldigt') AS anzahl_entschuldigt,
COUNT(t.user_id) FILTER (WHERE t.status = 'unbekannt') AS anzahl_unbekannt
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
GROUP BY u.id;
-- -----------------------------------------------------------------------------
-- 7. View: per-member participation statistics (feeds Tier 3 reporting)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW member_participation_stats AS
SELECT
usr.id AS user_id,
COALESCE(usr.name, usr.preferred_username, usr.email) AS name,
COUNT(t.uebung_id) AS total_eingeladen,
COUNT(t.uebung_id) FILTER (WHERE t.status = 'erschienen') AS total_erschienen,
COUNT(t.uebung_id) FILTER (
WHERE u.pflichtveranstaltung = TRUE AND t.status = 'erschienen'
) AS pflicht_erschienen,
COUNT(t.uebung_id) FILTER (WHERE u.pflichtveranstaltung = TRUE) AS pflicht_gesamt,
ROUND(
CASE
WHEN COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') = 0 THEN 0
ELSE
COUNT(t.uebung_id) FILTER (
WHERE u.typ = 'Übungsabend' AND t.status = 'erschienen'
)::NUMERIC /
COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') * 100
END, 1
) AS uebungsabend_quote_pct
FROM users usr
JOIN uebung_teilnahmen t ON t.user_id = usr.id
JOIN uebungen u ON u.id = t.uebung_id
WHERE usr.is_active = TRUE
AND u.abgesagt = FALSE
GROUP BY usr.id, usr.name, usr.preferred_username, usr.email;

View File

@@ -0,0 +1,197 @@
-- =============================================================================
-- TEST DATA: 5 sample incidents for development and manual testing
-- Run AFTER all migrations have been applied.
-- Assumes at least one user exists in the users table.
-- Replace the user UUIDs with real values from your dev database.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Step 1: Insert two sample vehicles (if not already present)
-- Uses column names from 005_create_fahrzeuge.sql
-- ---------------------------------------------------------------------------
INSERT INTO fahrzeuge (id, bezeichnung, kurzname, amtliches_kennzeichen, baujahr, hersteller, typ_schluessel, besatzung_soll, status)
VALUES
('a1000000-0000-0000-0000-000000000001', 'HLF 20 - Hilfeleistungslöschgruppenfahrzeug', 'HLF TEST', 'WN-FW 1234', 2020, 'MAN / Rosenbauer', 'HLF 20/16', '1/8', 'einsatzbereit'),
('a1000000-0000-0000-0000-000000000002', 'TLF 3000 - Tanklöschfahrzeug', 'TLF TEST', 'WN-FW 5678', 2018, 'Mercedes-Benz Atego', 'TLF 3000', '1/5', 'einsatzbereit')
ON CONFLICT (id) DO NOTHING;
-- ---------------------------------------------------------------------------
-- Step 2: Helper — get the first active user ID (replace with real UUID in prod)
-- We use a DO block to make the script re-runnable and dev-environment agnostic.
-- ---------------------------------------------------------------------------
DO $$
DECLARE
v_user_id UUID;
v_einsatz_id1 UUID := 'b1000000-0000-0000-0000-000000000001';
v_einsatz_id2 UUID := 'b1000000-0000-0000-0000-000000000002';
v_einsatz_id3 UUID := 'b1000000-0000-0000-0000-000000000003';
v_einsatz_id4 UUID := 'b1000000-0000-0000-0000-000000000004';
v_einsatz_id5 UUID := 'b1000000-0000-0000-0000-000000000005';
v_fahrzeug1 UUID := 'a1000000-0000-0000-0000-000000000001';
v_fahrzeug2 UUID := 'a1000000-0000-0000-0000-000000000002';
BEGIN
SELECT id INTO v_user_id FROM users WHERE is_active = TRUE LIMIT 1;
IF v_user_id IS NULL THEN
RAISE NOTICE 'No active user found — skipping test data insert.';
RETURN;
END IF;
-- -------------------------------------------------------------------------
-- EINSATZ 1: Wohnungsbrand (Brand B3) — January — abgeschlossen
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz, bericht_text,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id1,
generate_einsatz_nr('2026-01-14 14:32:00+01'::TIMESTAMPTZ),
'2026-01-14 14:32:00+01',
'2026-01-14 14:35:00+01',
'2026-01-14 14:41:00+01',
'2026-01-14 17:15:00+01',
'Brand', 'B3',
'Hauptstraße', '42', 'Berglen',
'Wohnungsbrand im 2. OG, 1 Person gerettet',
'Alarm um 14:32 Uhr durch ILS. Bei Ankunft stand das zweite Obergeschoss in Flammen. ' ||
'Eine Person wurde über die Drehleiter gerettet und dem Rettungsdienst übergeben. ' ||
'Brandbekämpfung mit zwei C-Rohren. Nachlöscharbeiten bis 17:00 Uhr.',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 2: Verkehrsunfall (THL 2) — Februar — abgeschlossen
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz, bericht_text,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id2,
generate_einsatz_nr('2026-02-03 08:17:00+01'::TIMESTAMPTZ),
'2026-02-03 08:17:00+01',
'2026-02-03 08:20:00+01',
'2026-02-03 08:26:00+01',
'2026-02-03 10:45:00+01',
'THL', 'THL 2',
'Landesstraße 1115', NULL, 'Rudersberg',
'Verkehrsunfall mit eingeklemmter Person, PKW gegen Baum',
'PKW frontal gegen Baum. Fahrer eingeklemmt, Beifahrerin konnte selbstständig aussteigen. ' ||
'Technische Rettung mit Spreizer und Schere. Person nach ca. 25 Minuten befreit. ' ||
'Übergabe an den Rettungsdienst und Hubschrauber.',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 3: Brandmeldeanlage (BMA) — März — abgeschlossen (Fehlalarm)
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id3,
generate_einsatz_nr('2026-03-21 11:05:00+01'::TIMESTAMPTZ),
'2026-03-21 11:05:00+01',
'2026-03-21 11:08:00+01',
'2026-03-21 11:12:00+01',
'2026-03-21 11:35:00+01',
'BMA', NULL,
'Gewerbepark Rems', '7', 'Weinstadt',
'BMA ausgelöst durch Staubentwicklung bei Bauarbeiten, kein Feuer',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 4: Ölspur (Hilfeleistung) — März — abgeschlossen
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id4,
generate_einsatz_nr('2026-03-28 16:45:00+01'::TIMESTAMPTZ),
'2026-03-28 16:45:00+01',
'2026-03-28 16:49:00+01',
'2026-03-28 16:54:00+01',
'2026-03-28 18:20:00+01',
'Hilfeleistung', NULL,
'Ortsdurchfahrt B29', NULL, 'Schorndorf',
'Ölspur ca. 600m, LKW verlor Hydraulikflüssigkeit, Reinigung mit Ölbindemittel',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 5: Flächenbrand (Brand B2) — aktiv (noch laufend heute)
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id5,
generate_einsatz_nr(NOW()),
NOW() - INTERVAL '45 minutes',
NOW() - INTERVAL '42 minutes',
NOW() - INTERVAL '37 minutes',
'Brand', 'B2',
'Waldweg', NULL, 'Alfdorf',
'Flächenbrand ca. 0,5 ha Unterholz, Wasserversorgung über Pendelverkehr',
v_user_id, 'ILS', 'aktiv', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- VEHICLES — assign to incidents
-- -------------------------------------------------------------------------
INSERT INTO einsatz_fahrzeuge (einsatz_id, fahrzeug_id, ausrueck_time, einrueck_time)
VALUES
(v_einsatz_id1, v_fahrzeug1, '2026-01-14 14:35:00+01', '2026-01-14 17:15:00+01'),
(v_einsatz_id1, v_fahrzeug2, '2026-01-14 14:36:00+01', '2026-01-14 17:10:00+01'),
(v_einsatz_id2, v_fahrzeug1, '2026-02-03 08:20:00+01', '2026-02-03 10:45:00+01'),
(v_einsatz_id4, v_fahrzeug1, '2026-03-28 16:49:00+01', '2026-03-28 18:20:00+01'),
(v_einsatz_id5, v_fahrzeug1, NOW() - INTERVAL '42 minutes', NULL),
(v_einsatz_id5, v_fahrzeug2, NOW() - INTERVAL '40 minutes', NULL)
ON CONFLICT (einsatz_id, fahrzeug_id) DO NOTHING;
-- -------------------------------------------------------------------------
-- PERSONNEL — assign the single test user to each incident
-- -------------------------------------------------------------------------
INSERT INTO einsatz_personal (einsatz_id, user_id, funktion, alarm_time, ankunft_time)
VALUES
(v_einsatz_id1, v_user_id, 'Einsatzleiter', '2026-01-14 14:32:00+01', '2026-01-14 14:41:00+01'),
(v_einsatz_id2, v_user_id, 'Gruppenführer', '2026-02-03 08:17:00+01', '2026-02-03 08:26:00+01'),
(v_einsatz_id3, v_user_id, 'Einsatzleiter', '2026-03-21 11:05:00+01', '2026-03-21 11:12:00+01'),
(v_einsatz_id4, v_user_id, 'Maschinist', '2026-03-28 16:45:00+01', '2026-03-28 16:54:00+01'),
(v_einsatz_id5, v_user_id, 'Einsatzleiter', NOW() - INTERVAL '45 minutes', NOW() - INTERVAL '37 minutes')
ON CONFLICT (einsatz_id, user_id) DO NOTHING;
RAISE NOTICE 'Test data inserted successfully for user %', v_user_id;
END $$;
-- ---------------------------------------------------------------------------
-- Refresh the materialized view after data load
-- ---------------------------------------------------------------------------
REFRESH MATERIALIZED VIEW einsatz_statistik;
-- ---------------------------------------------------------------------------
-- Verify: quick sanity check
-- ---------------------------------------------------------------------------
SELECT
e.einsatz_nr,
e.einsatz_art,
TO_CHAR(e.alarm_time AT TIME ZONE 'Europe/Berlin', 'DD.MM.YYYY HH24:MI') AS alarm_de,
e.ort,
e.status,
COUNT(ep.user_id) AS kräfte,
COUNT(ef.fahrzeug_id) AS fahrzeuge
FROM einsaetze e
LEFT JOIN einsatz_personal ep ON ep.einsatz_id = e.id
LEFT JOIN einsatz_fahrzeuge ef ON ef.einsatz_id = e.id
GROUP BY e.id, e.einsatz_nr, e.einsatz_art, e.alarm_time, e.ort, e.status
ORDER BY e.alarm_time DESC;

View File

@@ -0,0 +1,114 @@
/**
* Audit Cleanup Job
*
* Runs the GDPR IP anonymisation task on a schedule.
* Must be started once from server.ts during application boot.
*
* Two scheduling options are shown:
* 1. node-cron (recommended) — cron expression, survives DST changes
* 2. setInterval (fallback) — simpler, no extra dependency
*
* Install node-cron if using option 1:
* npm install node-cron
* npm install --save-dev @types/node-cron
*/
import auditService from '../services/audit.service';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Option 1 (RECOMMENDED): node-cron
//
// Runs every day at 02:00 local server time.
// The cron expression format is: second(optional) minute hour dom month dow
//
// Uncomment this block and comment out the setInterval block below.
// ---------------------------------------------------------------------------
/*
import cron from 'node-cron';
let cronJob: cron.ScheduledTask | null = null;
export function startAuditCleanupJob(): void {
if (cronJob) {
logger.warn('Audit cleanup job already running — skipping duplicate start');
return;
}
// '0 2 * * *' = every day at 02:00
cronJob = cron.schedule('0 2 * * *', async () => {
logger.info('Audit cleanup job: starting IP anonymisation');
await auditService.anonymizeOldIpAddresses();
logger.info('Audit cleanup job: IP anonymisation complete');
}, {
scheduled: true,
timezone: 'Europe/Vienna', // Feuerwehr Rems is in Austria
});
logger.info('Audit cleanup job scheduled (node-cron, daily at 02:00 Europe/Vienna)');
}
export function stopAuditCleanupJob(): void {
if (cronJob) {
cronJob.stop();
cronJob = null;
logger.info('Audit cleanup job stopped');
}
}
*/
// ---------------------------------------------------------------------------
// Option 2 (FALLBACK): setInterval
//
// Runs every 24 hours from the moment the server starts.
// Simpler — no extra dependency — but does not align to wall-clock time
// and will drift if the server restarts frequently.
// ---------------------------------------------------------------------------
const INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
export function startAuditCleanupJob(): void {
if (cleanupInterval !== null) {
logger.warn('Audit cleanup job already running — skipping duplicate start');
return;
}
// Run once on startup to catch any backlog, then repeat on schedule.
// We intentionally do not await — startup must not be blocked.
runAuditCleanup();
cleanupInterval = setInterval(() => {
runAuditCleanup();
}, INTERVAL_MS);
logger.info('Audit cleanup job scheduled (setInterval, 24h interval)');
}
export function stopAuditCleanupJob(): void {
if (cleanupInterval !== null) {
clearInterval(cleanupInterval);
cleanupInterval = null;
logger.info('Audit cleanup job stopped');
}
}
// ---------------------------------------------------------------------------
// Core cleanup function — called by both scheduler options
// ---------------------------------------------------------------------------
export async function runAuditCleanup(): Promise<void> {
try {
logger.debug('Audit cleanup: running IP anonymisation');
await auditService.anonymizeOldIpAddresses();
} catch (error) {
// anonymizeOldIpAddresses() swallows its own errors, but we add a second
// layer of protection here so that a scheduler implementation mistake
// cannot crash the process.
logger.error('Audit cleanup job: unexpected error', {
error: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -0,0 +1,235 @@
/**
* Audit Middleware
*
* Factory function that returns an Express middleware for a given
* (resourceType, action) pair. Designed to sit after `authenticate`
* in the middleware chain.
*
* Usage examples:
*
* // Simple: no old_value needed
* router.post('/', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.CREATE), createMember);
*
* // UPDATE: controller must populate res.locals.auditOldValue before responding
* router.put('/:id', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.UPDATE), updateMember);
*
* // DELETE: controller must populate res.locals.auditOldValue (the entity before deletion)
* router.delete('/:id', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.DELETE), deleteMember);
*
* Pattern for capturing old_value in controllers:
*
* async function updateMember(req: Request, res: Response): Promise<void> {
* const existing = await memberService.findById(req.params.id);
* res.locals.auditOldValue = existing; // <-- set BEFORE modifying
*
* const updated = await memberService.update(req.params.id, req.body);
* res.locals.auditResourceId = req.params.id;
*
* res.status(200).json({ success: true, data: updated });
* // The middleware intercepts res.json() and logs AFTER the response is sent.
* }
*
* For PERMISSION_DENIED events, use auditPermissionDenied() directly in the
* requirePermission middleware — see auth.middleware.ts.
*/
import { Request, Response, NextFunction } from 'express';
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// IP extraction helper
// ---------------------------------------------------------------------------
/**
* Extracts the real client IP address, respecting X-Forwarded-For when set
* by a trusted reverse proxy (Nginx / Traefik in the Docker stack).
*
* Trust only the leftmost IP in X-Forwarded-For to avoid spoofing.
* If you run without a reverse proxy, req.ip is sufficient.
*/
export function extractIp(req: Request): string | null {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
const ips = (typeof forwarded === 'string' ? forwarded : forwarded[0]).split(',');
const ip = ips[0].trim();
return ip || req.ip || null;
}
return req.ip || null;
}
/**
* Extracts User-Agent, truncated to 500 chars to avoid bloating the DB.
*/
export function extractUserAgent(req: Request): string | null {
const ua = req.headers['user-agent'];
if (!ua) return null;
return ua.substring(0, 500);
}
// ---------------------------------------------------------------------------
// res.locals contract (populated by controllers before res.json())
// ---------------------------------------------------------------------------
/**
* Extend Express locals so TypeScript knows what the audit middleware reads.
*
* Controllers set these before calling res.json() / res.status().json():
* res.locals.auditOldValue — entity state before the operation (UPDATE, DELETE)
* res.locals.auditNewValue — override for new value (optional; defaults to response body)
* res.locals.auditResourceId — override for resource ID (optional; defaults to req.params.id)
* res.locals.auditMetadata — any extra context object
* res.locals.auditSkip — set to true to suppress logging for this request
*/
declare global {
namespace Express {
interface Locals {
auditOldValue?: Record<string, unknown> | null;
auditNewValue?: Record<string, unknown> | null;
auditResourceId?: string;
auditMetadata?: Record<string, unknown>;
auditSkip?: boolean;
}
}
}
// ---------------------------------------------------------------------------
// Middleware factory
// ---------------------------------------------------------------------------
/**
* auditMiddleware — returns Express middleware that fires an audit log entry
* after the response has been sent (fire-and-forget; never delays the response).
*
* @param resourceType The type of entity being operated on.
* @param action The action being performed.
*/
export function auditMiddleware(
resourceType: AuditResourceType,
action: AuditAction
) {
return function auditHandler(
req: Request,
res: Response,
next: NextFunction
): void {
// Intercept res.json() so we can capture the response body after it is
// set, without delaying the response to the client.
const originalJson = res.json.bind(res);
res.json = function auditInterceptedJson(body: unknown): Response {
// Call the original json() — this sends the response immediately.
const result = originalJson(body);
// Only log on successful responses (2xx). Failed requests are not
// meaningful audit events for data mutations (the data did not change).
if (res.statusCode >= 200 && res.statusCode < 300 && !res.locals.auditSkip) {
const userId = req.user?.id ?? null;
const userEmail = req.user?.email ?? null;
const ipAddress = extractIp(req);
const userAgent = extractUserAgent(req);
// Resource ID: prefer controller override, then route param, then body id.
const resourceId =
res.locals.auditResourceId ??
req.params.id ??
(body !== null && typeof body === 'object'
? (body as Record<string, unknown>)?.data?.id as string | undefined
: undefined) ??
null;
// old_value: always from res.locals (controller must set it for UPDATE/DELETE).
const oldValue = res.locals.auditOldValue ?? null;
// new_value: prefer controller override, else use the response body data.
// We extract the `data` property if it exists (matches our { success, data } envelope).
let newValue: Record<string, unknown> | null = res.locals.auditNewValue ?? null;
if (newValue === null && action !== AuditAction.DELETE) {
const bodyObj = body as Record<string, unknown> | null;
newValue =
(bodyObj?.data as Record<string, unknown> | undefined) ??
(typeof bodyObj === 'object' && bodyObj !== null ? bodyObj : null);
}
// Fire-and-forget — never await in a middleware.
auditService.logAudit({
user_id: userId,
user_email: userEmail,
action,
resource_type: resourceType,
resource_id: resourceId ? String(resourceId) : null,
old_value: oldValue,
new_value: newValue,
ip_address: ipAddress,
user_agent: userAgent,
metadata: res.locals.auditMetadata ?? {},
}).catch((err) => {
// Belt-and-suspenders: logAudit() never rejects, but just in case.
logger.error('auditMiddleware: unexpected logAudit rejection', { err });
});
}
return result;
};
next();
};
}
// ---------------------------------------------------------------------------
// Standalone helpers for use outside of route middleware chains
// ---------------------------------------------------------------------------
/**
* auditPermissionDenied
*
* Call this inside requirePermission() when access is denied.
* Does NOT depend on res.json interception — fires directly.
*/
export function auditPermissionDenied(
req: Request,
resourceType: AuditResourceType,
resourceId?: string,
metadata?: Record<string, unknown>
): void {
auditService.logAudit({
user_id: req.user?.id ?? null,
user_email: req.user?.email ?? null,
action: AuditAction.PERMISSION_DENIED,
resource_type: resourceType,
resource_id: resourceId ?? null,
old_value: null,
new_value: null,
ip_address: extractIp(req),
user_agent: extractUserAgent(req),
metadata: {
attempted_path: req.path,
attempted_method: req.method,
...metadata,
},
}).catch(() => {/* swallowed — audit must not affect the 403 response */});
}
/**
* auditExport
*
* Call this immediately before streaming an export response.
*/
export function auditExport(
req: Request,
resourceType: AuditResourceType,
metadata: Record<string, unknown> = {}
): void {
auditService.logAudit({
user_id: req.user?.id ?? null,
user_email: req.user?.email ?? null,
action: AuditAction.EXPORT,
resource_type: resourceType,
resource_id: null,
old_value: null,
new_value: null,
ip_address: extractIp(req),
user_agent: extractUserAgent(req),
metadata,
}).catch(() => {});
}

View File

@@ -0,0 +1,136 @@
import { Request, Response, NextFunction } from 'express';
import pool from '../config/database';
import logger from '../utils/logger';
import { auditPermissionDenied } from './audit.middleware';
import { AuditResourceType } from '../services/audit.service';
// ---------------------------------------------------------------------------
// AppRole — mirrors the roles defined in the project spec.
// Tier 1 (RBAC) is assumed complete and adds a `role` column to users.
// This middleware reads that column to enforce permissions.
// ---------------------------------------------------------------------------
export type AppRole =
| 'admin'
| 'kommandant'
| 'gruppenfuehrer'
| 'mitglied'
| 'bewerber';
/**
* Role hierarchy: higher index = more permissions.
* Used to implement "at least X role" checks.
*/
const ROLE_HIERARCHY: AppRole[] = [
'bewerber',
'mitglied',
'gruppenfuehrer',
'kommandant',
'admin',
];
/**
* Permission map: defines which roles hold a given permission string.
* All roles at or above the listed minimum also hold the permission.
*/
const PERMISSION_ROLE_MIN: Record<string, AppRole> = {
'incidents:read': 'mitglied',
'incidents:write': 'gruppenfuehrer',
'incidents:delete': 'kommandant',
'incidents:read_bericht_text': 'kommandant',
'incidents:manage_personnel': 'gruppenfuehrer',
// Training / Calendar
'training:read': 'mitglied',
'training:write': 'gruppenfuehrer',
'training:cancel': 'kommandant',
'training:mark_attendance': 'gruppenfuehrer',
'reports:read': 'kommandant',
// Audit log and admin panel — restricted to admin role only
'admin:access': 'admin',
'audit:read': 'admin',
'audit:export': 'admin',
};
function hasPermission(role: AppRole, permission: string): boolean {
const minRole = PERMISSION_ROLE_MIN[permission];
if (!minRole) {
logger.warn('Unknown permission checked', { permission });
return false;
}
const userLevel = ROLE_HIERARCHY.indexOf(role);
const minLevel = ROLE_HIERARCHY.indexOf(minRole);
return userLevel >= minLevel;
}
/**
* Retrieves the role for a given user ID from the database.
* Falls back to 'mitglied' if the users table does not yet have a role column
* (graceful degradation while Tier 1 migration is pending).
*/
async function getUserRole(userId: string): Promise<AppRole> {
try {
const result = await pool.query(
`SELECT role FROM users WHERE id = $1`,
[userId]
);
if (result.rows.length === 0) return 'mitglied';
return (result.rows[0].role as AppRole) ?? 'mitglied';
} catch (error) {
// If the column doesn't exist yet (Tier 1 not deployed), degrade gracefully
const errMsg = error instanceof Error ? error.message : String(error);
if (errMsg.includes('column "role" does not exist')) {
logger.warn('users.role column not found — Tier 1 RBAC migration pending. Defaulting to mitglied.');
return 'mitglied';
}
logger.error('Error fetching user role', { error, userId });
return 'mitglied';
}
}
/**
* Middleware factory: requires the authenticated user to hold the given
* permission (or a role with sufficient hierarchy level).
*
* Usage:
* router.post('/api/incidents', authenticate, requirePermission('incidents:write'), handler)
*/
export function requirePermission(permission: 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 role = await getUserRole(req.user.id);
// Attach role to request for downstream use (e.g., bericht_text redaction)
(req as Request & { userRole?: AppRole }).userRole = role;
if (!hasPermission(role, permission)) {
logger.warn('Permission denied', {
userId: req.user.id,
role,
permission,
path: req.path,
});
// GDPR audit trail — fire-and-forget, never throws
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
required_permission: permission,
user_role: role,
});
res.status(403).json({
success: false,
message: `Keine Berechtigung: ${permission}`,
});
return;
}
next();
};
}
export { getUserRole, hasPermission };

View File

@@ -0,0 +1,261 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// ENUMS (as const arrays for runtime use + TypeScript types)
// ---------------------------------------------------------------------------
export const EINSATZ_ARTEN = [
'Brand',
'THL',
'ABC',
'BMA',
'Hilfeleistung',
'Fehlalarm',
'Brandsicherheitswache',
] as const;
export type EinsatzArt = (typeof EINSATZ_ARTEN)[number];
export const EINSATZ_ART_LABELS: Record<EinsatzArt, string> = {
Brand: 'Brand',
THL: 'Technische Hilfeleistung',
ABC: 'ABC-Einsatz / Gefahrgut',
BMA: 'Brandmeldeanlage',
Hilfeleistung: 'Hilfeleistung',
Fehlalarm: 'Fehlalarm',
Brandsicherheitswache: 'Brandsicherheitswache',
};
export const EINSATZ_STATUS = ['aktiv', 'abgeschlossen', 'archiviert'] as const;
export type EinsatzStatus = (typeof EINSATZ_STATUS)[number];
export const EINSATZ_STATUS_LABELS: Record<EinsatzStatus, string> = {
aktiv: 'Aktiv',
abgeschlossen: 'Abgeschlossen',
archiviert: 'Archiviert',
};
export const EINSATZ_FUNKTIONEN = [
'Einsatzleiter',
'Gruppenführer',
'Maschinist',
'Atemschutz',
'Sicherheitstrupp',
'Melder',
'Wassertrupp',
'Angriffstrupp',
'Mannschaft',
'Sonstiges',
] as const;
export type EinsatzFunktion = (typeof EINSATZ_FUNKTIONEN)[number];
export const ALARMIERUNG_ARTEN = [
'ILS',
'DME',
'Telefon',
'Vor_Ort',
'Sonstiges',
] as const;
export type AlarmierungArt = (typeof ALARMIERUNG_ARTEN)[number];
export const ALARMIERUNG_ART_LABELS: Record<AlarmierungArt, string> = {
ILS: 'ILS (Integrierte Leitstelle)',
DME: 'Digitaler Meldeempfänger',
Telefon: 'Telefon',
Vor_Ort: 'Vor Ort',
Sonstiges: 'Sonstiges',
};
// ---------------------------------------------------------------------------
// DATABASE ROW INTERFACES
// ---------------------------------------------------------------------------
/** Raw database row for einsaetze table */
export interface Einsatz {
id: string;
einsatz_nr: string;
alarm_time: Date;
ausrueck_time: Date | null;
ankunft_time: Date | null;
einrueck_time: Date | null;
einsatz_art: EinsatzArt;
einsatz_stichwort: string | null;
strasse: string | null;
hausnummer: string | null;
ort: string | null;
koordinaten: { x: number; y: number } | null; // pg returns POINT as {x,y}
bericht_kurz: string | null;
bericht_text: string | null;
einsatzleiter_id: string | null;
alarmierung_art: AlarmierungArt;
status: EinsatzStatus;
created_by: string | null;
created_at: Date;
updated_at: Date;
}
/** Assigned vehicle row */
export interface EinsatzFahrzeug {
einsatz_id: string;
fahrzeug_id: string;
ausrueck_time: Date | null;
einrueck_time: Date | null;
assigned_at: Date;
// Joined fields — aliased to 'kennzeichen' and 'fahrzeug_typ' in SQL query
// to match the aliased SELECT in incident.service.ts
kennzeichen?: string | null; // aliased from amtliches_kennzeichen
bezeichnung?: string;
fahrzeug_typ?: string | null; // aliased from typ_schluessel
}
/** Assigned personnel row */
export interface EinsatzPersonal {
einsatz_id: string;
user_id: string;
funktion: EinsatzFunktion;
alarm_time: Date | null;
ankunft_time: Date | null;
assigned_at: Date;
// Joined fields
name?: string | null;
email?: string;
given_name?: string | null;
family_name?: string | null;
}
// ---------------------------------------------------------------------------
// EXTENDED TYPES (joins / computed)
// ---------------------------------------------------------------------------
/** Full incident with all related data — used by detail endpoint */
export interface EinsatzWithDetails extends Einsatz {
einsatzleiter_name: string | null;
fahrzeuge: EinsatzFahrzeug[];
personal: EinsatzPersonal[];
/** Hilfsfrist in minutes (alarm → arrival), null if ankunft_time not set */
hilfsfrist_min: number | null;
/** Total duration in minutes (alarm → return), null if einrueck_time not set */
dauer_min: number | null;
}
/** Lightweight row for the list view DataGrid */
export interface EinsatzListItem {
id: string;
einsatz_nr: string;
alarm_time: Date;
einsatz_art: EinsatzArt;
einsatz_stichwort: string | null;
ort: string | null;
strasse: string | null;
status: EinsatzStatus;
einsatzleiter_name: string | null;
/** Hilfsfrist in minutes, null if ankunft_time not set */
hilfsfrist_min: number | null;
/** Total duration in minutes, null if einrueck_time not set */
dauer_min: number | null;
personal_count: number;
}
// ---------------------------------------------------------------------------
// STATISTICS TYPES
// ---------------------------------------------------------------------------
export interface MonthlyStatRow {
monat: number; // 112
anzahl: number;
avg_hilfsfrist_min: number | null;
avg_dauer_min: number | null;
}
export interface EinsatzArtStatRow {
einsatz_art: EinsatzArt;
anzahl: number;
avg_hilfsfrist_min: number | null;
}
export interface EinsatzStats {
jahr: number;
gesamt: number;
abgeschlossen: number;
aktiv: number;
avg_hilfsfrist_min: number | null;
/** Einsatzart with the highest count */
haeufigste_art: EinsatzArt | null;
monthly: MonthlyStatRow[];
by_art: EinsatzArtStatRow[];
/** Previous year monthly for chart overlay */
prev_year_monthly: MonthlyStatRow[];
}
// ---------------------------------------------------------------------------
// ZOD VALIDATION SCHEMAS (Zod v4)
// ---------------------------------------------------------------------------
export const CreateEinsatzSchema = z.object({
alarm_time: z.string().datetime({ offset: true }),
ausrueck_time: z.string().datetime({ offset: true }).optional().nullable(),
ankunft_time: z.string().datetime({ offset: true }).optional().nullable(),
einrueck_time: z.string().datetime({ offset: true }).optional().nullable(),
einsatz_art: z.enum(EINSATZ_ARTEN),
einsatz_stichwort: z.string().max(30).optional().nullable(),
strasse: z.string().max(150).optional().nullable(),
hausnummer: z.string().max(20).optional().nullable(),
ort: z.string().max(100).optional().nullable(),
bericht_kurz: z.string().max(255).optional().nullable(),
bericht_text: z.string().optional().nullable(),
einsatzleiter_id: z.string().uuid().optional().nullable(),
alarmierung_art: z.enum(ALARMIERUNG_ARTEN).optional().default('ILS'),
status: z.enum(EINSATZ_STATUS).optional().default('aktiv'),
});
export type CreateEinsatzData = z.infer<typeof CreateEinsatzSchema>;
export const UpdateEinsatzSchema = CreateEinsatzSchema.partial().omit({
alarm_time: true, // alarm_time can be updated but is handled explicitly
}).extend({
// alarm_time is allowed to be updated but must remain valid if provided
alarm_time: z.string().datetime({ offset: true }).optional(),
});
export type UpdateEinsatzData = z.infer<typeof UpdateEinsatzSchema>;
export const AssignPersonnelSchema = z.object({
user_id: z.string().uuid(),
funktion: z.enum(EINSATZ_FUNKTIONEN).optional().default('Mannschaft'),
alarm_time: z.string().datetime({ offset: true }).optional().nullable(),
ankunft_time: z.string().datetime({ offset: true }).optional().nullable(),
});
export type AssignPersonnelData = z.infer<typeof AssignPersonnelSchema>;
export const AssignVehicleSchema = z.object({
fahrzeug_id: z.string().uuid(),
ausrueck_time: z.string().datetime({ offset: true }).optional().nullable(),
einrueck_time: z.string().datetime({ offset: true }).optional().nullable(),
});
export type AssignVehicleData = z.infer<typeof AssignVehicleSchema>;
export const IncidentFiltersSchema = z.object({
dateFrom: z.string().datetime({ offset: true }).optional(),
dateTo: z.string().datetime({ offset: true }).optional(),
einsatzArt: z.enum(EINSATZ_ARTEN).optional(),
status: z.enum(EINSATZ_STATUS).optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
});
export type IncidentFilters = z.infer<typeof IncidentFiltersSchema>;

View File

@@ -0,0 +1,230 @@
import { z } from 'zod';
// ============================================================
// Domain enumerations — used both as Zod schemas and runtime
// arrays (for building <Select> options in the frontend).
// ============================================================
export const DIENSTGRAD_VALUES = [
'Feuerwehranwärter',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann',
] as const;
export const STATUS_VALUES = [
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten',
] as const;
export const FUNKTION_VALUES = [
'Kommandant',
'Stellv. Kommandant',
'Gruppenführer',
'Truppführer',
'Gerätewart',
'Kassier',
'Schriftführer',
'Atemschutzwart',
'Ausbildungsbeauftragter',
] as const;
export const TSHIRT_GROESSE_VALUES = [
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL',
] as const;
export type DienstgradEnum = typeof DIENSTGRAD_VALUES[number];
export type StatusEnum = typeof STATUS_VALUES[number];
export type FunktionEnum = typeof FUNKTION_VALUES[number];
export type TshirtGroesseEnum = typeof TSHIRT_GROESSE_VALUES[number];
// ============================================================
// Core DB row interface — mirrors the mitglieder_profile table
// exactly (snake_case, nullable columns use `| null`)
// ============================================================
export interface MitgliederProfile {
id: string;
user_id: string;
mitglieds_nr: string | null;
dienstgrad: DienstgradEnum | null;
dienstgrad_seit: Date | null;
funktion: FunktionEnum[];
status: StatusEnum;
eintrittsdatum: Date | null;
austrittsdatum: Date | null;
geburtsdatum: Date | null; // sensitive field
telefon_mobil: string | null;
telefon_privat: string | null;
notfallkontakt_name: string | null; // sensitive field
notfallkontakt_telefon: string | null; // sensitive field
fuehrerscheinklassen: string[];
tshirt_groesse: TshirtGroesseEnum | null;
schuhgroesse: string | null;
bemerkungen: string | null;
bild_url: string | null;
created_at: Date;
updated_at: Date;
}
// ============================================================
// Rank-change history row
// ============================================================
export interface DienstgradVerlaufEntry {
id: string;
user_id: string;
dienstgrad_neu: string;
dienstgrad_alt: string | null;
datum: Date;
durch_user_id: string | null;
bemerkung: string | null;
created_at: Date;
}
// ============================================================
// Joined view: users row + mitglieder_profile (nullable)
// This is what the service returns for GET /members/:userId
// ============================================================
export interface MemberWithProfile {
// --- from users ---
id: string;
email: string;
name: string | null;
given_name: string | null;
family_name: string | null;
preferred_username: string | null;
profile_picture_url: string | null;
is_active: boolean;
last_login_at: Date | null;
created_at: Date;
// --- from mitglieder_profile (null when no profile exists) ---
profile: MitgliederProfile | null;
// --- rank history (populated on detail requests) ---
dienstgrad_verlauf?: DienstgradVerlaufEntry[];
}
// ============================================================
// List-view projection — only the fields needed for the table
// ============================================================
export interface MemberListItem {
// user fields
id: string;
name: string | null;
given_name: string | null;
family_name: string | null;
email: string;
profile_picture_url: string | null;
is_active: boolean;
// profile fields (null when no profile exists)
profile_id: string | null;
mitglieds_nr: string | null;
dienstgrad: DienstgradEnum | null;
funktion: FunktionEnum[];
status: StatusEnum | null;
eintrittsdatum: Date | null;
telefon_mobil: string | null;
}
// ============================================================
// Filter parameters for getAllMembers()
// ============================================================
export interface MemberFilters {
search?: string; // matches name, email, mitglieds_nr
status?: StatusEnum[];
dienstgrad?: DienstgradEnum[];
page?: number; // 1-based
pageSize?: number;
}
// ============================================================
// Zod validation schemas
// ============================================================
/**
* Schema for POST /members/:userId/profile
* All fields are optional at creation except user_id (in URL).
* status has a default; every other field may be omitted.
*/
export const CreateMemberProfileSchema = z.object({
mitglieds_nr: z.string().max(32).optional(),
dienstgrad: z.enum(DIENSTGRAD_VALUES).optional(),
dienstgrad_seit: z.coerce.date().optional(),
funktion: z.array(z.enum(FUNKTION_VALUES)).default([]),
status: z.enum(STATUS_VALUES).default('aktiv'),
eintrittsdatum: z.coerce.date().optional(),
austrittsdatum: z.coerce.date().optional(),
geburtsdatum: z.coerce.date().optional(),
telefon_mobil: z.string().max(32).optional(),
telefon_privat: z.string().max(32).optional(),
notfallkontakt_name: z.string().max(255).optional(),
notfallkontakt_telefon: z.string().max(32).optional(),
fuehrerscheinklassen: z.array(z.string().max(8)).default([]),
tshirt_groesse: z.enum(TSHIRT_GROESSE_VALUES).optional(),
schuhgroesse: z.string().max(8).optional(),
bemerkungen: z.string().optional(),
bild_url: z.string().url().optional(),
});
export type CreateMemberProfileData = z.infer<typeof CreateMemberProfileSchema>;
/**
* Schema for PATCH /members/:userId
* Every field is optional — only provided fields are written.
*/
export const UpdateMemberProfileSchema = CreateMemberProfileSchema.partial();
export type UpdateMemberProfileData = z.infer<typeof UpdateMemberProfileSchema>;
/**
* Schema for the "self-edit" subset available to the Mitglied role.
* Only non-sensitive, personally-owned fields.
*/
export const SelfUpdateMemberProfileSchema = z.object({
telefon_mobil: z.string().max(32).optional(),
telefon_privat: z.string().max(32).optional(),
notfallkontakt_name: z.string().max(255).optional(),
notfallkontakt_telefon: z.string().max(32).optional(),
tshirt_groesse: z.enum(TSHIRT_GROESSE_VALUES).optional(),
schuhgroesse: z.string().max(8).optional(),
bild_url: z.string().url().optional(),
});
export type SelfUpdateMemberProfileData = z.infer<typeof SelfUpdateMemberProfileSchema>;
// ============================================================
// Stats response shape (for dashboard KPI endpoint)
// ============================================================
export interface MemberStats {
total: number;
aktiv: number;
passiv: number;
ehrenmitglied: number;
jugendfeuerwehr: number;
anwärter: number;
ausgetreten: number;
}

View File

@@ -0,0 +1,197 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
export const UEBUNG_TYPEN = [
'Übungsabend',
'Lehrgang',
'Sonderdienst',
'Versammlung',
'Gemeinschaftsübung',
'Sonstiges',
] as const;
export type UebungTyp = (typeof UEBUNG_TYPEN)[number];
export const TEILNAHME_STATUSES = [
'zugesagt',
'abgesagt',
'erschienen',
'entschuldigt',
'unbekannt',
] as const;
export type TeilnahmeStatus = (typeof TEILNAHME_STATUSES)[number];
// ---------------------------------------------------------------------------
// Core DB-mapped interfaces
// ---------------------------------------------------------------------------
export interface Uebung {
id: string;
titel: string;
beschreibung?: string | null;
typ: UebungTyp;
datum_von: Date;
datum_bis: Date;
ort?: string | null;
treffpunkt?: string | null;
pflichtveranstaltung: boolean;
mindest_teilnehmer?: number | null;
max_teilnehmer?: number | null;
angelegt_von?: string | null;
erstellt_am: Date;
aktualisiert_am: Date;
abgesagt: boolean;
absage_grund?: string | null;
}
export interface Teilnahme {
uebung_id: string;
user_id: string;
status: TeilnahmeStatus;
antwort_am?: Date | null;
erschienen_erfasst_am?: Date | null;
erschienen_erfasst_von?: string | null;
bemerkung?: string | null;
// Joined from users table
user_name?: string | null;
user_email?: string | null;
}
// ---------------------------------------------------------------------------
// Enriched / view-based interfaces
// ---------------------------------------------------------------------------
export interface AttendanceCounts {
gesamt_eingeladen: number;
anzahl_zugesagt: number;
anzahl_abgesagt: number;
anzahl_erschienen: number;
anzahl_entschuldigt: number;
anzahl_unbekannt: number;
}
/** Full event object including all attendance data — used in detail page */
export interface UebungWithAttendance extends Uebung, AttendanceCounts {
/** Only populated for users with training:write or own role >= Gruppenführer */
teilnahmen?: Teilnahme[];
/** The requesting user's own RSVP status, always included */
eigener_status?: TeilnahmeStatus;
/** Name of the person who created the event */
angelegt_von_name?: string | null;
}
/** Lightweight list item — used in calendar, upcoming list widget */
export interface UebungListItem {
id: string;
titel: string;
typ: UebungTyp;
datum_von: Date;
datum_bis: Date;
ort?: string | null;
pflichtveranstaltung: boolean;
abgesagt: boolean;
anzahl_zugesagt: number;
anzahl_erschienen: number;
gesamt_eingeladen: number;
/** Requesting user's own status — undefined when called without userId */
eigener_status?: TeilnahmeStatus;
}
// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------
/** Monthly training summary for dashboard stats card */
export interface TrainingStats {
/** e.g. "2026-02" */
month: string;
/** Total events that month */
total: number;
/** Pflichtveranstaltungen that month */
pflicht: number;
/** % of events (Übungsabende only) a given user attended — 0-100 */
attendanceRate: number;
}
/** Per-member annual participation stats for Jahresbericht (Tier 3) */
export interface MemberParticipationStats {
userId: string;
name: string;
totalUebungen: number;
attended: number;
attendancePercent: number;
pflichtGesamt: number;
pflichtErschienen: number;
uebungsabendQuotePct: number;
}
// ---------------------------------------------------------------------------
// Zod validation schemas — used in service layer and route middleware
// ---------------------------------------------------------------------------
export const CreateUebungSchema = z.object({
titel: z
.string()
.min(3, 'Titel muss mindestens 3 Zeichen haben')
.max(255),
beschreibung: z.string().max(5000).optional().nullable(),
typ: z.enum(UEBUNG_TYPEN),
datum_von: z
.string()
.datetime({ offset: true, message: 'datum_von muss ein ISO-8601 Datum mit Zeitzone sein' })
.transform((s) => new Date(s)),
datum_bis: z
.string()
.datetime({ offset: true, message: 'datum_bis muss ein ISO-8601 Datum mit Zeitzone sein' })
.transform((s) => new Date(s)),
ort: z.string().max(255).optional().nullable(),
treffpunkt: z.string().max(255).optional().nullable(),
pflichtveranstaltung: z.boolean().default(false),
mindest_teilnehmer: z.number().int().positive().optional().nullable(),
max_teilnehmer: z.number().int().positive().optional().nullable(),
}).refine(
(d) => d.datum_bis >= d.datum_von,
{ message: 'datum_bis muss nach datum_von liegen', path: ['datum_bis'] }
).refine(
(d) =>
d.max_teilnehmer == null ||
d.mindest_teilnehmer == null ||
d.max_teilnehmer >= d.mindest_teilnehmer,
{ message: 'max_teilnehmer muss >= mindest_teilnehmer sein', path: ['max_teilnehmer'] }
);
export type CreateUebungData = z.infer<typeof CreateUebungSchema>;
export const UpdateUebungSchema = CreateUebungSchema.partial().extend({
// All fields optional on update, but retain type narrowing
});
export type UpdateUebungData = z.infer<typeof UpdateUebungSchema>;
export const UpdateRsvpSchema = z.object({
status: z.enum(['zugesagt', 'abgesagt']),
bemerkung: z.string().max(500).optional().nullable(),
});
export type UpdateRsvpData = z.infer<typeof UpdateRsvpSchema>;
export const MarkAttendanceSchema = z.object({
userIds: z
.array(z.string().uuid())
.min(1, 'Mindestens eine Person muss ausgewählt werden'),
});
export type MarkAttendanceData = z.infer<typeof MarkAttendanceSchema>;
export const CancelEventSchema = z.object({
absage_grund: z
.string()
.min(5, 'Bitte gib einen Grund für die Absage an (min. 5 Zeichen)')
.max(1000),
});
export type CancelEventData = z.infer<typeof CancelEventSchema>;

View File

@@ -0,0 +1,269 @@
// =============================================================================
// Vehicle Fleet Management — Domain Model
// =============================================================================
// ── Enums ─────────────────────────────────────────────────────────────────────
/**
* Operational status of a vehicle.
* These values are the CHECK constraint values in the database.
*/
export enum FahrzeugStatus {
Einsatzbereit = 'einsatzbereit',
AusserDienstWartung = 'ausser_dienst_wartung',
AusserDienstSchaden = 'ausser_dienst_schaden',
InLehrgang = 'in_lehrgang',
}
/** Human-readable German labels for each status value */
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
[FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)',
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
};
/**
* Types of vehicle inspections (Prüfungsarten).
* These values are the CHECK constraint values in the database.
*/
export enum PruefungArt {
HU = 'HU', // Hauptuntersuchung (TÜV) — 24-month interval
AU = 'AU', // Abgasuntersuchung — 12-month interval
UVV = 'UVV', // Unfallverhütungsvorschrift BGV D29 — 12-month
Leiter = 'Leiter', // Leiternprüfung (DLK only) — 12-month
Kran = 'Kran', // Kranprüfung — 12-month
Seilwinde = 'Seilwinde', // Seilwindenprüfung — 12-month
Sonstiges = 'Sonstiges',
}
/** Human-readable German labels for each PruefungArt */
export const PruefungArtLabel: Record<PruefungArt, string> = {
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
[PruefungArt.AU]: 'Abgasuntersuchung',
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
[PruefungArt.Kran]: 'Kranprüfung',
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
};
/**
* Standard inspection intervals in months, keyed by PruefungArt.
* Used by vehicle.service.ts to auto-calculate naechste_faelligkeit.
*/
export const PruefungIntervalMonths: Partial<Record<PruefungArt, number>> = {
[PruefungArt.HU]: 24,
[PruefungArt.AU]: 12,
[PruefungArt.UVV]: 12,
[PruefungArt.Leiter]: 12,
[PruefungArt.Kran]: 12,
[PruefungArt.Seilwinde]: 12,
// Sonstiges: no standard interval — must be set manually
};
/** Inspection result values */
export type PruefungErgebnis =
| 'bestanden'
| 'bestanden_mit_maengeln'
| 'nicht_bestanden'
| 'ausstehend';
/** Maintenance log entry types */
export type WartungslogArt =
| 'Inspektion'
| 'Reparatur'
| 'Kraftstoff'
| 'Reifenwechsel'
| 'Hauptuntersuchung'
| 'Reinigung'
| 'Sonstiges';
// ── Core Entities ─────────────────────────────────────────────────────────────
/** Raw database row from the `fahrzeuge` table */
export interface Fahrzeug {
id: string; // UUID
bezeichnung: string; // e.g. "LF 20/16"
kurzname: string | null;
amtliches_kennzeichen: string | null;
fahrgestellnummer: string | null;
baujahr: number | null;
hersteller: string | null;
typ_schluessel: string | null;
besatzung_soll: string | null; // e.g. "1/8"
status: FahrzeugStatus;
status_bemerkung: string | null;
standort: string;
bild_url: string | null;
created_at: Date;
updated_at: Date;
}
/** Raw database row from `fahrzeug_pruefungen` */
export interface FahrzeugPruefung {
id: string; // UUID
fahrzeug_id: string; // UUID FK
pruefung_art: PruefungArt;
faellig_am: Date; // The hard legal deadline
durchgefuehrt_am: Date | null;
ergebnis: PruefungErgebnis | null;
naechste_faelligkeit: Date | null;
pruefende_stelle: string | null;
kosten: number | null;
dokument_url: string | null;
bemerkung: string | null;
erfasst_von: string | null; // UUID FK users
created_at: Date;
}
/** Raw database row from `fahrzeug_wartungslog` */
export interface FahrzeugWartungslog {
id: string; // UUID
fahrzeug_id: string; // UUID FK
datum: Date;
art: WartungslogArt | null;
beschreibung: string;
km_stand: number | null;
kraftstoff_liter: number | null;
kosten: number | null;
externe_werkstatt: string | null;
erfasst_von: string | null; // UUID FK users
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;
};
/** Minimum tage_bis_faelligkeit across all inspections (negative = any overdue) */
naechste_pruefung_tage: number | null;
/** Full inspection history, ordered by faellig_am DESC */
pruefungen: FahrzeugPruefung[];
/** Maintenance log entries, ordered by datum DESC */
wartungslog: FahrzeugWartungslog[];
}
// ── List Item (Grid / Card view) ──────────────────────────────────────────────
/**
* Lightweight type used in the vehicle fleet overview grid.
* Includes only the fields needed to render a card plus inspection badges.
*/
export interface FahrzeugListItem {
id: string;
bezeichnung: string;
kurzname: string | null;
amtliches_kennzeichen: string | null;
baujahr: number | null;
hersteller: string | null;
besatzung_soll: string | null;
status: FahrzeugStatus;
status_bemerkung: string | null;
bild_url: string | null;
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;
}
// ── Dashboard KPI ─────────────────────────────────────────────────────────────
/** Aggregated vehicle stats for the dashboard KPI strip */
export interface VehicleStats {
total: number;
einsatzbereit: number;
ausserDienst: number; // wartung + schaden combined
inLehrgang: number;
inspectionsDue: number; // vehicles with any inspection due within 30 days
inspectionsOverdue: number; // vehicles with any inspection already overdue
}
// ── Inspection Alert ──────────────────────────────────────────────────────────
/** Single alert item for the dashboard InspectionAlerts component */
export interface InspectionAlert {
fahrzeugId: string;
bezeichnung: string;
kurzname: string | null;
pruefungId: string;
pruefungArt: PruefungArt;
faelligAm: Date;
tage: number; // negative = already overdue
}
// ── Create / Update DTOs ──────────────────────────────────────────────────────
export interface CreateFahrzeugData {
bezeichnung: string;
kurzname?: string;
amtliches_kennzeichen?: string;
fahrgestellnummer?: string;
baujahr?: number;
hersteller?: string;
typ_schluessel?: string;
besatzung_soll?: string;
status?: FahrzeugStatus;
status_bemerkung?: string;
standort?: string;
bild_url?: string;
}
export interface UpdateFahrzeugData {
bezeichnung?: string;
kurzname?: string | null;
amtliches_kennzeichen?: string | null;
fahrgestellnummer?: string | null;
baujahr?: number | null;
hersteller?: string | null;
typ_schluessel?: string | null;
besatzung_soll?: string | null;
status?: FahrzeugStatus;
status_bemerkung?: string | null;
standort?: string;
bild_url?: 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 {
datum: string; // ISO date string 'YYYY-MM-DD'
art?: WartungslogArt;
beschreibung: string;
km_stand?: number;
kraftstoff_liter?: number;
kosten?: number;
externe_werkstatt?: string;
}

View File

@@ -0,0 +1,169 @@
/**
* Admin API Routes — Audit Log
*
* GET /api/admin/audit-log — paginated, filtered list
* GET /api/admin/audit-log/export — CSV download of filtered results
*
* Both endpoints require authentication + admin:access permission.
*
* Register in app.ts:
* import adminRoutes from './routes/admin.routes';
* app.use('/api/admin', adminRoutes);
*/
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
import { auditExport } from '../middleware/audit.middleware';
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
import logger from '../utils/logger';
const router = Router();
// ---------------------------------------------------------------------------
// Input validation schemas (Zod)
// ---------------------------------------------------------------------------
const auditQuerySchema = z.object({
userId: z.string().uuid().optional(),
action: z.union([
z.nativeEnum(Object.fromEntries(
Object.entries(AuditAction).map(([k, v]) => [k, v])
) as Record<string, string>),
z.array(z.string()),
]).optional(),
resourceType: z.union([
z.string(),
z.array(z.string()),
]).optional(),
resourceId: z.string().optional(),
dateFrom: z.string().datetime({ offset: true }).optional(),
dateTo: z.string().datetime({ offset: true }).optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(25),
});
// ---------------------------------------------------------------------------
// Helper — parse and validate query string
// ---------------------------------------------------------------------------
function parseAuditQuery(query: Record<string, unknown>): AuditFilters {
const parsed = auditQuerySchema.parse(query);
// Normalise action to array of AuditAction
const actions = parsed.action
? (Array.isArray(parsed.action) ? parsed.action : [parsed.action]) as AuditAction[]
: undefined;
// Normalise resourceType to array of AuditResourceType
const resourceTypes = parsed.resourceType
? (Array.isArray(parsed.resourceType)
? parsed.resourceType
: [parsed.resourceType]) as AuditResourceType[]
: undefined;
return {
userId: parsed.userId,
action: actions && actions.length === 1 ? actions[0] : actions,
resourceType: resourceTypes && resourceTypes.length === 1
? resourceTypes[0]
: resourceTypes,
resourceId: parsed.resourceId,
dateFrom: parsed.dateFrom ? new Date(parsed.dateFrom) : undefined,
dateTo: parsed.dateTo ? new Date(parsed.dateTo) : undefined,
page: parsed.page,
pageSize: parsed.pageSize,
};
}
// ---------------------------------------------------------------------------
// GET /api/admin/audit-log
// ---------------------------------------------------------------------------
router.get(
'/audit-log',
authenticate,
requirePermission('admin:access'),
async (req: Request, res: Response): Promise<void> => {
try {
const filters = parseAuditQuery(req.query as Record<string, unknown>);
const result = await auditService.getAuditLog(filters);
res.status(200).json({
success: true,
data: result,
});
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
success: false,
message: 'Invalid query parameters',
errors: error.errors,
});
return;
}
logger.error('Failed to fetch audit log', { error });
res.status(500).json({
success: false,
message: 'Failed to fetch audit log',
});
}
}
);
// ---------------------------------------------------------------------------
// GET /api/admin/audit-log/export
// ---------------------------------------------------------------------------
router.get(
'/audit-log/export',
authenticate,
requirePermission('admin:access'),
async (req: Request, res: Response): Promise<void> => {
try {
// For CSV exports we fetch up to 10,000 rows (no pagination).
const filters = parseAuditQuery(req.query as Record<string, unknown>);
const exportFilters: AuditFilters = {
...filters,
page: 1,
pageSize: 10_000,
};
// Audit the export action itself before streaming the response
auditExport(req, AuditResourceType.SYSTEM, {
export_format: 'csv',
filters: JSON.stringify(exportFilters),
});
const result = await auditService.getAuditLog(exportFilters);
const csv = auditService.entriesToCsv(result.entries);
const filename = `audit_log_${new Date().toISOString().replace(/[:.]/g, '-')}.csv`;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Add BOM for Excel UTF-8 compatibility
res.send('\uFEFF' + csv);
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
success: false,
message: 'Invalid query parameters',
errors: error.errors,
});
return;
}
logger.error('Failed to export audit log', { error });
res.status(500).json({
success: false,
message: 'Failed to export audit log',
});
}
}
);
export default router;

View File

@@ -0,0 +1,139 @@
import { Router } from 'express';
import incidentController from '../controllers/incident.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
// All incident routes require authentication
router.use(authenticate);
/**
* @route GET /api/incidents
* @desc List incidents with pagination and filters
* @access mitglied+
* @query dateFrom, dateTo, einsatzArt, status, limit, offset
*/
router.get(
'/',
requirePermission('incidents:read'),
incidentController.listIncidents.bind(incidentController)
);
/**
* @route GET /api/incidents/stats
* @desc Aggregated statistics (monthly + by type) for a given year
* @access mitglied+
* @query year (optional, defaults to current year)
*
* NOTE: This route MUST be defined before /:id to prevent 'stats' being
* captured as an id parameter.
*/
router.get(
'/stats',
requirePermission('incidents:read'),
incidentController.getStats.bind(incidentController)
);
/**
* @route POST /api/incidents/refresh-stats
* @desc Refresh einsatz_statistik materialized view (admin utility)
* @access admin only
*/
router.post(
'/refresh-stats',
requirePermission('incidents:delete'), // kommandant+ (repurposing delete permission for admin ops)
incidentController.refreshStats.bind(incidentController)
);
/**
* @route GET /api/incidents/:id
* @desc Get single incident with full detail (personnel, vehicles, timeline)
* @access mitglied+ (bericht_text redacted for roles below kommandant)
*/
router.get(
'/:id',
requirePermission('incidents:read'),
incidentController.getIncident.bind(incidentController)
);
/**
* @route POST /api/incidents
* @desc Create a new incident
* @access gruppenfuehrer+
*/
router.post(
'/',
requirePermission('incidents:write'),
incidentController.createIncident.bind(incidentController)
);
/**
* @route PATCH /api/incidents/:id
* @desc Update an existing incident (partial update)
* @access gruppenfuehrer+
*/
router.patch(
'/:id',
requirePermission('incidents:write'),
incidentController.updateIncident.bind(incidentController)
);
/**
* @route DELETE /api/incidents/:id
* @desc Soft-delete (archive) an incident
* @access kommandant+
*/
router.delete(
'/:id',
requirePermission('incidents:delete'),
incidentController.deleteIncident.bind(incidentController)
);
/**
* @route POST /api/incidents/:id/personnel
* @desc Assign a member to an incident with a function (Funktion)
* @access gruppenfuehrer+
* @body { user_id: UUID, funktion?: string, alarm_time?: ISO8601, ankunft_time?: ISO8601 }
*/
router.post(
'/:id/personnel',
requirePermission('incidents:manage_personnel'),
incidentController.assignPersonnel.bind(incidentController)
);
/**
* @route DELETE /api/incidents/:id/personnel/:userId
* @desc Remove a member from an incident
* @access gruppenfuehrer+
*/
router.delete(
'/:id/personnel/:userId',
requirePermission('incidents:manage_personnel'),
incidentController.removePersonnel.bind(incidentController)
);
/**
* @route POST /api/incidents/:id/vehicles
* @desc Assign a vehicle to an incident
* @access gruppenfuehrer+
* @body { fahrzeug_id: UUID, ausrueck_time?: ISO8601, einrueck_time?: ISO8601 }
*/
router.post(
'/:id/vehicles',
requirePermission('incidents:manage_personnel'),
incidentController.assignVehicle.bind(incidentController)
);
/**
* @route DELETE /api/incidents/:id/vehicles/:fahrzeugId
* @desc Remove a vehicle from an incident
* @access gruppenfuehrer+
*/
router.delete(
'/:id/vehicles/:fahrzeugId',
requirePermission('incidents:manage_personnel'),
incidentController.removeVehicle.bind(incidentController)
);
export default router;

View File

@@ -0,0 +1,139 @@
import { Router, Request, Response, NextFunction } from 'express';
import memberController from '../controllers/member.controller';
import { authenticate } from '../middleware/auth.middleware';
import logger from '../utils/logger';
const router = Router();
// ----------------------------------------------------------------
// Role/permission middleware
//
// The JWT currently carries: { userId, email, authentikSub }.
// Roles come from Authentik group membership stored in the
// `groups` array on the UserInfo response. The auth controller
// already upserts the user in the DB on every login; this
// middleware resolves the role from req.user (extended below).
//
// Until a full roles column exists in the users table, roles are
// derived from a well-known Authentik group naming convention:
//
// "feuerwehr-admin" → AppRole 'admin'
// "feuerwehr-kommandant" → AppRole 'kommandant'
// (everything else) → AppRole 'mitglied'
//
// The groups are passed through the JWT as req.user.groups (added
// by the extended type below). Replace this logic with a DB
// lookup once a roles column is added to users.
// ----------------------------------------------------------------
type AppRole = 'admin' | 'kommandant' | 'mitglied';
// Extend the Express Request type to include role
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
authentikSub: string;
role?: AppRole;
groups?: string[];
};
}
}
}
/**
* Resolves the AppRole from Authentik groups attached to the JWT.
* Mutates req.user.role so downstream controllers can read it directly.
*/
const resolveRole = (req: Request, _res: Response, next: NextFunction): void => {
if (req.user) {
const groups: string[] = (req.user as any).groups ?? [];
if (groups.includes('feuerwehr-admin')) {
req.user.role = 'admin';
} else if (groups.includes('feuerwehr-kommandant')) {
req.user.role = 'kommandant';
} else {
req.user.role = 'mitglied';
}
logger.debug('resolveRole', { userId: req.user.id, role: req.user.role });
}
next();
};
/**
* Factory: creates a middleware that enforces the minimum required role.
* Role hierarchy: admin > kommandant > mitglied
*/
const requirePermission = (permission: 'members:read' | 'members:write') => {
return (req: Request, res: Response, next: NextFunction): void => {
const role = (req.user as any)?.role ?? 'mitglied';
const writeRoles: AppRole[] = ['admin', 'kommandant'];
const readRoles: AppRole[] = ['admin', 'kommandant', 'mitglied'];
const allowed =
permission === 'members:write'
? writeRoles.includes(role)
: readRoles.includes(role);
if (!allowed) {
res.status(403).json({
success: false,
message: 'Keine Berechtigung für diese Aktion.',
});
return;
}
next();
};
};
// ----------------------------------------------------------------
// Apply authentication + role resolution to every route in this
// router. Note: requirePermission is applied per-route because
// PATCH allows the owner to update their own limited fields even
// without 'members:write'.
// ----------------------------------------------------------------
router.use(authenticate, resolveRole);
// IMPORTANT: The static /stats route must be registered BEFORE
// the dynamic /:userId route, otherwise Express would match
// "stats" as a userId parameter.
router.get(
'/stats',
requirePermission('members:read'),
memberController.getMemberStats.bind(memberController)
);
router.get(
'/',
requirePermission('members:read'),
memberController.getMembers.bind(memberController)
);
router.get(
'/:userId',
requirePermission('members:read'),
memberController.getMemberById.bind(memberController)
);
router.post(
'/:userId/profile',
requirePermission('members:write'),
memberController.createMemberProfile.bind(memberController)
);
/**
* PATCH /:userId — open to both privileged users AND the profile owner.
* The controller itself enforces the correct Zod schema (full vs. limited)
* based on the caller's role.
*/
router.patch(
'/:userId',
// No requirePermission here — controller handles own-profile vs. write-role logic
memberController.updateMember.bind(memberController)
);
export default router;

View File

@@ -0,0 +1,149 @@
import { Router, Request, Response, NextFunction } from 'express';
import trainingController from '../controllers/training.controller';
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
import { requirePermission, getUserRole } from '../middleware/rbac.middleware';
const router = Router();
// ---------------------------------------------------------------------------
// injectTeilnahmenFlag
//
// Sets req.canSeeTeilnahmen = true for Gruppenführer and above.
// Regular Mitglieder see only attendance counts; officers see the full list.
// ---------------------------------------------------------------------------
async function injectTeilnahmenFlag(
req: Request,
_res: Response,
next: NextFunction
): Promise<void> {
try {
if (req.user) {
const role = await getUserRole(req.user.id);
const ROLE_ORDER: Record<string, number> = {
bewerber: -1, mitglied: 0, gruppenfuehrer: 1, kommandant: 2, admin: 3,
};
(req as any).canSeeTeilnahmen =
(ROLE_ORDER[role] ?? 0) >= ROLE_ORDER.gruppenfuehrer;
}
} catch (_err) {
// Non-fatal — default to restricted view
}
next();
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
/**
* GET /api/training
* Public list of upcoming events (limit param).
* Optional auth to include own RSVP status.
*/
router.get('/', optionalAuth, trainingController.getUpcoming);
/**
* GET /api/training/calendar?from=<ISO>&to=<ISO>
* Events in a date range for the calendar view.
* Optional auth to include own RSVP status.
*/
router.get('/calendar', optionalAuth, trainingController.getCalendarRange);
/**
* GET /api/training/calendar.ics?token=<calendarToken>
* iCal export — authenticated via per-user calendar token OR Bearer JWT.
* No `authenticate` enforced here; controller resolves auth itself.
* See training.controller.ts for full auth tradeoff discussion.
*/
router.get('/calendar.ics', optionalAuth, trainingController.getIcalExport);
/**
* GET /api/training/calendar-token
* Returns (or creates) the user's personal iCal subscribe token + URL.
* Requires authentication.
*/
router.get('/calendar-token', authenticate, trainingController.getCalendarToken);
/**
* GET /api/training/stats?year=<YYYY>
* Annual participation stats.
* Requires Kommandant or above (requirePermission('reports:read')).
*/
router.get(
'/stats',
authenticate,
requirePermission('reports:read'),
trainingController.getStats
);
/**
* GET /api/training/:id
* Single event with attendance counts.
* Gruppenführer+ also gets the full attendee list.
*/
router.get(
'/:id',
authenticate,
injectTeilnahmenFlag,
trainingController.getById
);
/**
* POST /api/training
* Create a new training event.
* Requires Gruppenführer or above (requirePermission('training:write')).
*/
router.post(
'/',
authenticate,
requirePermission('training:write'),
trainingController.createEvent
);
/**
* PATCH /api/training/:id
* Update an existing event.
* Requires Gruppenführer or above.
*/
router.patch(
'/:id',
authenticate,
requirePermission('training:write'),
trainingController.updateEvent
);
/**
* DELETE /api/training/:id
* Soft-cancel an event (sets abgesagt=true, records reason).
* Requires Kommandant or above.
*/
router.delete(
'/:id',
authenticate,
requirePermission('training:cancel'),
trainingController.cancelEvent
);
/**
* PATCH /api/training/:id/attendance
* Any authenticated member updates their own RSVP.
*/
router.patch(
'/:id/attendance',
authenticate,
trainingController.updateRsvp
);
/**
* POST /api/training/:id/attendance/mark
* Gruppenführer bulk-marks who actually appeared.
*/
router.post(
'/:id/attendance/mark',
authenticate,
requirePermission('training:mark_attendance'),
trainingController.markAttendance
);
export default router;

View File

@@ -0,0 +1,147 @@
import { Router } from 'express';
import vehicleController from '../controllers/vehicle.controller';
import { authenticate } from '../middleware/auth.middleware';
// ---------------------------------------------------------------------------
// RBAC guard — requirePermission('vehicles:write')
// ---------------------------------------------------------------------------
// 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();
// ── Read-only endpoints (any authenticated user) ──────────────────────────────
/**
* GET /api/vehicles
* Fleet overview list — inspection badges included.
*/
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
/**
* GET /api/vehicles/stats
* Dashboard KPI aggregates.
* NOTE: /stats and /alerts must be declared BEFORE /:id to avoid route conflicts.
*/
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
/**
* GET /api/vehicles/alerts?daysAhead=30
* Upcoming and overdue inspections for the dashboard alert panel.
*/
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
/**
* GET /api/vehicles/:id
* Full vehicle detail with inspection history and maintenance log.
*/
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
/**
* GET /api/vehicles/:id/pruefungen
* Inspection history for a single vehicle.
*/
router.get('/:id/pruefungen', authenticate, vehicleController.getPruefungen.bind(vehicleController));
/**
* GET /api/vehicles/:id/wartung
* Maintenance log for a single vehicle.
*/
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
// ── Write endpoints (vehicles:write role required) ─────────────────────────────
/**
* POST /api/vehicles
* Create a new vehicle.
*/
router.post(
'/',
authenticate,
requireVehicleWrite,
vehicleController.createVehicle.bind(vehicleController)
);
/**
* PATCH /api/vehicles/:id
* Update vehicle fields.
*/
router.patch(
'/:id',
authenticate,
requireVehicleWrite,
vehicleController.updateVehicle.bind(vehicleController)
);
/**
* PATCH /api/vehicles/:id/status
* Live status change — Socket.IO hook point for Tier 3.
* The `io` instance is retrieved inside the controller via req.app.get('io').
*/
router.patch(
'/:id/status',
authenticate,
requireVehicleWrite,
vehicleController.updateVehicleStatus.bind(vehicleController)
);
/**
* POST /api/vehicles/:id/pruefungen
* Record an inspection (scheduled or completed).
*/
router.post(
'/:id/pruefungen',
authenticate,
requireVehicleWrite,
vehicleController.addPruefung.bind(vehicleController)
);
/**
* POST /api/vehicles/:id/wartung
* Add a maintenance log entry.
*/
router.post(
'/:id/wartung',
authenticate,
requireVehicleWrite,
vehicleController.addWartung.bind(vehicleController)
);
export default router;

View File

@@ -1,7 +1,7 @@
import app from './app';
import environment from './config/environment';
import logger from './utils/logger';
import { testConnection, closePool } from './config/database';
import { testConnection, closePool, runMigrations } from './config/database';
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
const startServer = async (): Promise<void> => {
@@ -12,6 +12,9 @@ const startServer = async (): Promise<void> => {
if (!dbConnected) {
logger.warn('Database connection failed - server will start but database operations may fail');
} else {
// Run pending migrations automatically on startup
await runMigrations();
}
// Start the GDPR IP anonymisation job

View File

@@ -0,0 +1,394 @@
/**
* Audit Service
*
* GDPR compliance:
* - Art. 5(2) Accountability: every write to personal data is logged.
* - Art. 30 Records of Processing Activities: who, what, when, delta.
* - Art. 5(1)e Storage limitation: IP addresses anonymised after 90 days
* via anonymizeOldIpAddresses(), called by the scheduled job.
*
* Critical invariant: logAudit() MUST NEVER throw or reject. Audit failures
* are logged to Winston and silently swallowed so that the main request flow
* is never interrupted.
*/
import pool from '../config/database';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Enums — kept as const objects rather than TypeScript enums so that the
// values are available as plain strings in runtime code and SQL parameters.
// ---------------------------------------------------------------------------
export const AuditAction = {
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
LOGIN: 'LOGIN',
LOGOUT: 'LOGOUT',
EXPORT: 'EXPORT',
PERMISSION_DENIED:'PERMISSION_DENIED',
PASSWORD_CHANGE: 'PASSWORD_CHANGE',
ROLE_CHANGE: 'ROLE_CHANGE',
} as const;
export type AuditAction = typeof AuditAction[keyof typeof AuditAction];
export const AuditResourceType = {
MEMBER: 'MEMBER',
INCIDENT: 'INCIDENT',
VEHICLE: 'VEHICLE',
EQUIPMENT: 'EQUIPMENT',
QUALIFICATION: 'QUALIFICATION',
USER: 'USER',
SYSTEM: 'SYSTEM',
} as const;
export type AuditResourceType = typeof AuditResourceType[keyof typeof AuditResourceType];
// ---------------------------------------------------------------------------
// Core interfaces
// ---------------------------------------------------------------------------
export interface AuditLogEntry {
id: string; // UUID, set by database
user_id: string | null; // UUID; null for unauthenticated events
user_email: string | null; // denormalised snapshot
action: AuditAction;
resource_type: AuditResourceType;
resource_id: string | null;
old_value: Record<string, unknown> | null;
new_value: Record<string, unknown> | null;
ip_address: string | null;
user_agent: string | null;
metadata: Record<string, unknown>;
created_at: Date;
}
/**
* Input type for logAudit() — the caller never supplies id or created_at.
*/
export type AuditLogInput = Omit<AuditLogEntry, 'id' | 'created_at'>;
// ---------------------------------------------------------------------------
// Filter + pagination types (used by the admin API)
// ---------------------------------------------------------------------------
export interface AuditFilters {
userId?: string;
action?: AuditAction | AuditAction[];
resourceType?: AuditResourceType | AuditResourceType[];
resourceId?: string;
dateFrom?: Date;
dateTo?: Date;
page: number; // 1-based
pageSize: number; // max 200
}
export interface AuditLogPage {
entries: AuditLogEntry[];
total: number;
page: number;
pages: number;
}
// ---------------------------------------------------------------------------
// Sensitive field stripping
// Ensures that passwords, tokens, and secrets never appear in the log even
// if a caller accidentally passes a raw request body.
// ---------------------------------------------------------------------------
const SENSITIVE_KEYS = new Set([
'password', 'password_hash', 'passwordHash',
'secret', 'token', 'accessToken', 'refreshToken', 'access_token',
'refresh_token', 'id_token', 'client_secret',
'authorization', 'cookie',
]);
function stripSensitiveFields(
value: Record<string, unknown> | null | undefined
): Record<string, unknown> | null {
if (value === null || value === undefined) return null;
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (SENSITIVE_KEYS.has(k.toLowerCase())) {
result[k] = '[REDACTED]';
} else if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
result[k] = stripSensitiveFields(v as Record<string, unknown>);
} else {
result[k] = v;
}
}
return result;
}
// ---------------------------------------------------------------------------
// AuditService
// ---------------------------------------------------------------------------
class AuditService {
/**
* logAudit — fire-and-forget.
*
* The caller does NOT await this. Even if awaited, it will never reject;
* all errors are swallowed and written to Winston instead.
*/
async logAudit(entry: AuditLogInput): Promise<void> {
// Start immediately — do not block the caller.
this._writeAuditEntry(entry).catch(() => {
// _writeAuditEntry already handles its own error logging; this .catch()
// prevents a potential unhandledRejection if the async method itself
// throws synchronously before the inner try/catch.
});
}
/**
* Internal write — all failures are caught and sent to Winston.
* This method should never surface an exception to its caller.
*/
private async _writeAuditEntry(entry: AuditLogInput): Promise<void> {
try {
const sanitisedOld = stripSensitiveFields(entry.old_value ?? null);
const sanitisedNew = stripSensitiveFields(entry.new_value ?? null);
const query = `
INSERT INTO audit_log (
user_id,
user_email,
action,
resource_type,
resource_id,
old_value,
new_value,
ip_address,
user_agent,
metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`;
const values = [
entry.user_id ?? null,
entry.user_email ?? null,
entry.action,
entry.resource_type,
entry.resource_id ?? null,
sanitisedOld ? JSON.stringify(sanitisedOld) : null,
sanitisedNew ? JSON.stringify(sanitisedNew) : null,
entry.ip_address ?? null,
entry.user_agent ?? null,
JSON.stringify(entry.metadata ?? {}),
];
await pool.query(query, values);
logger.debug('Audit entry written', {
action: entry.action,
resource_type: entry.resource_type,
resource_id: entry.resource_id,
user_id: entry.user_id,
});
} catch (error) {
// GDPR obligation: log the failure so it can be investigated, but
// NEVER propagate — the main request must complete successfully.
logger.error('AUDIT LOG FAILURE — entry could not be persisted to database', {
error: error instanceof Error ? error.message : String(error),
action: entry.action,
resource_type: entry.resource_type,
resource_id: entry.resource_id,
user_id: entry.user_id,
// Note: we intentionally do NOT log old_value/new_value here
// because they may contain personal data.
});
}
}
// -------------------------------------------------------------------------
// Query — admin UI
// -------------------------------------------------------------------------
/**
* getAuditLog — paginated, filtered query for the admin dashboard.
*
* Never called from hot paths; can be awaited normally.
*/
async getAuditLog(filters: AuditFilters): Promise<AuditLogPage> {
const page = Math.max(1, filters.page ?? 1);
const pageSize = Math.min(200, Math.max(1, filters.pageSize ?? 25));
const offset = (page - 1) * pageSize;
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (filters.userId) {
conditions.push(`user_id = $${paramIndex++}`);
values.push(filters.userId);
}
if (filters.action) {
const actions = Array.isArray(filters.action)
? filters.action
: [filters.action];
conditions.push(`action = ANY($${paramIndex++}::audit_action[])`);
values.push(actions);
}
if (filters.resourceType) {
const types = Array.isArray(filters.resourceType)
? filters.resourceType
: [filters.resourceType];
conditions.push(`resource_type = ANY($${paramIndex++}::audit_resource_type[])`);
values.push(types);
}
if (filters.resourceId) {
conditions.push(`resource_id = $${paramIndex++}`);
values.push(filters.resourceId);
}
if (filters.dateFrom) {
conditions.push(`created_at >= $${paramIndex++}`);
values.push(filters.dateFrom);
}
if (filters.dateTo) {
conditions.push(`created_at <= $${paramIndex++}`);
values.push(filters.dateTo);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
const countQuery = `
SELECT COUNT(*) AS total
FROM audit_log
${whereClause}
`;
const dataQuery = `
SELECT
id, user_id, user_email, action, resource_type, resource_id,
old_value, new_value, ip_address, user_agent, metadata, created_at
FROM audit_log
${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
`;
const [countResult, dataResult] = await Promise.all([
pool.query(countQuery, values),
pool.query(dataQuery, [...values, pageSize, offset]),
]);
const total = parseInt(countResult.rows[0].total, 10);
const entries = dataResult.rows as AuditLogEntry[];
return {
entries,
total,
page,
pages: Math.ceil(total / pageSize),
};
}
// -------------------------------------------------------------------------
// GDPR IP anonymisation — run as a scheduled job
// -------------------------------------------------------------------------
/**
* anonymizeOldIpAddresses
*
* Replaces the ip_address of every audit_log row older than 90 days with
* the literal string '[anonymized]'. This satisfies the GDPR storage
* limitation principle (Art. 5(1)(e)) for IP addresses as personal data.
*
* Uses a single UPDATE with a WHERE clause covered by idx_audit_log_ip_retention,
* so performance is proportional to the number of rows being anonymised, not
* the total table size.
*
* Note: The database RULE audit_log_no_update blocks UPDATE statements issued
* *by application queries*, but that rule is created with DO INSTEAD NOTHING,
* which means the application-level anonymisation query also cannot run unless
* the rule is dropped or replaced.
*
* Resolution: the migration creates the rule. For the anonymisation job to
* work, the database role used by this application must either:
* (a) own the table (then rules do not apply to the owner), OR
* (b) use a separate privileged role for the anonymisation query only.
*
* Recommended approach: use option (a) by ensuring DB_USER is the table
* owner, which is the default when the same user runs the migrations.
* The immutability rule then protects against accidental application-level
* UPDATE/DELETE while the owner can still perform sanctioned data operations.
*/
async anonymizeOldIpAddresses(): Promise<void> {
try {
const result = await pool.query(`
UPDATE audit_log
SET ip_address = '[anonymized]'
WHERE created_at < NOW() - INTERVAL '90 days'
AND ip_address IS NOT NULL
AND ip_address != '[anonymized]'
`);
const count = result.rowCount ?? 0;
if (count > 0) {
logger.info('GDPR IP anonymisation complete', { rows_anonymized: count });
} else {
logger.debug('GDPR IP anonymisation: no rows required anonymisation');
}
} catch (error) {
logger.error('GDPR IP anonymisation job failed', {
error: error instanceof Error ? error.message : String(error),
});
// Do not re-throw: the job scheduler should not crash the process.
}
}
// -------------------------------------------------------------------------
// CSV export helper (used by the admin API route)
// -------------------------------------------------------------------------
/**
* Converts a page of audit entries to CSV format.
* Passwords and secrets are already stripped; old_value/new_value are
* serialised as compact JSON strings within the CSV cell.
*/
entriesToCsv(entries: AuditLogEntry[]): string {
const header = [
'id', 'created_at', 'user_id', 'user_email',
'action', 'resource_type', 'resource_id',
'ip_address', 'user_agent', 'old_value', 'new_value', 'metadata',
].join(',');
const escape = (v: unknown): string => {
if (v === null || v === undefined) return '';
const str = typeof v === 'object' ? JSON.stringify(v) : String(v);
// RFC 4180: wrap in quotes, double any internal quotes
return `"${str.replace(/"/g, '""')}"`;
};
const rows = entries.map((e) =>
[
e.id,
e.created_at.toISOString(),
e.user_id ?? '',
e.user_email ?? '',
e.action,
e.resource_type,
e.resource_id ?? '',
e.ip_address ?? '',
escape(e.user_agent),
escape(e.old_value),
escape(e.new_value),
escape(e.metadata),
].join(',')
);
return [header, ...rows].join('\r\n');
}
}
export default new AuditService();

View File

@@ -0,0 +1,699 @@
import pool from '../config/database';
import logger from '../utils/logger';
import {
Einsatz,
EinsatzWithDetails,
EinsatzListItem,
EinsatzStats,
EinsatzFahrzeug,
EinsatzPersonal,
MonthlyStatRow,
EinsatzArtStatRow,
EinsatzArt,
CreateEinsatzData,
UpdateEinsatzData,
AssignPersonnelData,
AssignVehicleData,
IncidentFilters,
} from '../models/incident.model';
class IncidentService {
// -------------------------------------------------------------------------
// LIST
// -------------------------------------------------------------------------
/**
* Get a paginated list of incidents with optional filters.
* Returns lightweight EinsatzListItem rows (no bericht_text, no sub-arrays).
*/
async getAllIncidents(
filters: IncidentFilters = { limit: 50, offset: 0 }
): Promise<{ items: EinsatzListItem[]; total: number }> {
try {
const conditions: string[] = ["e.status != 'archiviert'"];
const params: unknown[] = [];
let p = 1;
if (filters.dateFrom) {
conditions.push(`e.alarm_time >= $${p++}`);
params.push(filters.dateFrom);
}
if (filters.dateTo) {
conditions.push(`e.alarm_time <= $${p++}`);
params.push(filters.dateTo);
}
if (filters.einsatzArt) {
conditions.push(`e.einsatz_art = $${p++}`);
params.push(filters.einsatzArt);
}
if (filters.status) {
conditions.push(`e.status = $${p++}`);
params.push(filters.status);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Count query
const countResult = await pool.query(
`SELECT COUNT(*)::INTEGER AS total FROM einsaetze e ${where}`,
params
);
const total: number = countResult.rows[0].total;
// Data query — joined with users for einsatzleiter name and personal count
const dataQuery = `
SELECT
e.id,
e.einsatz_nr,
e.alarm_time,
e.einsatz_art,
e.einsatz_stichwort,
e.ort,
e.strasse,
e.status,
COALESCE(
TRIM(CONCAT(u.given_name, ' ', u.family_name)),
u.name,
u.preferred_username
) AS einsatzleiter_name,
ROUND(
EXTRACT(EPOCH FROM (e.ankunft_time - e.alarm_time)) / 60.0
)::INTEGER AS hilfsfrist_min,
ROUND(
EXTRACT(EPOCH FROM (e.einrueck_time - e.alarm_time)) / 60.0
)::INTEGER AS dauer_min,
COALESCE(ep.personal_count, 0)::INTEGER AS personal_count
FROM einsaetze e
LEFT JOIN users u ON u.id = e.einsatzleiter_id
LEFT JOIN (
SELECT einsatz_id, COUNT(*) AS personal_count
FROM einsatz_personal
GROUP BY einsatz_id
) ep ON ep.einsatz_id = e.id
${where}
ORDER BY e.alarm_time DESC
LIMIT $${p++} OFFSET $${p++}
`;
params.push(filters.limit ?? 50, filters.offset ?? 0);
const dataResult = await pool.query(dataQuery, params);
const items: EinsatzListItem[] = dataResult.rows.map((row) => ({
id: row.id,
einsatz_nr: row.einsatz_nr,
alarm_time: row.alarm_time,
einsatz_art: row.einsatz_art,
einsatz_stichwort: row.einsatz_stichwort,
ort: row.ort,
strasse: row.strasse,
status: row.status,
einsatzleiter_name: row.einsatzleiter_name ?? null,
hilfsfrist_min: row.hilfsfrist_min !== null ? Number(row.hilfsfrist_min) : null,
dauer_min: row.dauer_min !== null ? Number(row.dauer_min) : null,
personal_count: Number(row.personal_count),
}));
return { items, total };
} catch (error) {
logger.error('Error fetching incident list', { error, filters });
throw new Error('Failed to fetch incidents');
}
}
// -------------------------------------------------------------------------
// DETAIL
// -------------------------------------------------------------------------
/**
* Get a single incident with full details: personnel, vehicles, computed times.
* NOTE: bericht_text is included here; role-based redaction is applied in
* the controller based on req.user.role.
*/
async getIncidentById(id: string): Promise<EinsatzWithDetails | null> {
try {
const einsatzResult = await pool.query(
`
SELECT
e.*,
COALESCE(
TRIM(CONCAT(u.given_name, ' ', u.family_name)),
u.name,
u.preferred_username
) AS einsatzleiter_name,
ROUND(
EXTRACT(EPOCH FROM (e.ankunft_time - e.alarm_time)) / 60.0
)::INTEGER AS hilfsfrist_min,
ROUND(
EXTRACT(EPOCH FROM (e.einrueck_time - e.alarm_time)) / 60.0
)::INTEGER AS dauer_min
FROM einsaetze e
LEFT JOIN users u ON u.id = e.einsatzleiter_id
WHERE e.id = $1
`,
[id]
);
if (einsatzResult.rows.length === 0) {
return null;
}
const row = einsatzResult.rows[0];
// Fetch assigned personnel
const personalResult = await pool.query(
`
SELECT
ep.einsatz_id,
ep.user_id,
ep.funktion,
ep.alarm_time,
ep.ankunft_time,
ep.assigned_at,
u.name,
u.email,
u.given_name,
u.family_name
FROM einsatz_personal ep
JOIN users u ON u.id = ep.user_id
WHERE ep.einsatz_id = $1
ORDER BY ep.funktion, u.family_name
`,
[id]
);
// Fetch assigned vehicles
const fahrzeugeResult = await pool.query(
`
SELECT
ef.einsatz_id,
ef.fahrzeug_id,
ef.ausrueck_time,
ef.einrueck_time,
ef.assigned_at,
f.amtliches_kennzeichen AS kennzeichen,
f.bezeichnung,
f.typ_schluessel AS fahrzeug_typ
FROM einsatz_fahrzeuge ef
JOIN fahrzeuge f ON f.id = ef.fahrzeug_id
WHERE ef.einsatz_id = $1
ORDER BY f.bezeichnung
`,
[id]
);
const einsatz: EinsatzWithDetails = {
id: row.id,
einsatz_nr: row.einsatz_nr,
alarm_time: row.alarm_time,
ausrueck_time: row.ausrueck_time,
ankunft_time: row.ankunft_time,
einrueck_time: row.einrueck_time,
einsatz_art: row.einsatz_art,
einsatz_stichwort: row.einsatz_stichwort,
strasse: row.strasse,
hausnummer: row.hausnummer,
ort: row.ort,
koordinaten: row.koordinaten,
bericht_kurz: row.bericht_kurz,
bericht_text: row.bericht_text,
einsatzleiter_id: row.einsatzleiter_id,
alarmierung_art: row.alarmierung_art,
status: row.status,
created_by: row.created_by,
created_at: row.created_at,
updated_at: row.updated_at,
einsatzleiter_name: row.einsatzleiter_name ?? null,
hilfsfrist_min: row.hilfsfrist_min !== null ? Number(row.hilfsfrist_min) : null,
dauer_min: row.dauer_min !== null ? Number(row.dauer_min) : null,
fahrzeuge: fahrzeugeResult.rows as EinsatzFahrzeug[],
personal: personalResult.rows as EinsatzPersonal[],
};
return einsatz;
} catch (error) {
logger.error('Error fetching incident by ID', { error, id });
throw new Error('Failed to fetch incident');
}
}
// -------------------------------------------------------------------------
// CREATE
// -------------------------------------------------------------------------
/**
* Create a new incident.
* Einsatz-Nr is generated atomically by the PostgreSQL function
* generate_einsatz_nr() using a per-year sequence table with UPDATE ... RETURNING.
* This is safe under concurrent inserts.
*/
async createIncident(data: CreateEinsatzData, createdBy: string): Promise<Einsatz> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Generate the Einsatz-Nr atomically inside the transaction
const nrResult = await client.query(
`SELECT generate_einsatz_nr($1::TIMESTAMPTZ) AS einsatz_nr`,
[data.alarm_time]
);
const einsatz_nr: string = nrResult.rows[0].einsatz_nr;
const result = await client.query(
`
INSERT INTO einsaetze (
einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort,
strasse, hausnummer, ort,
bericht_kurz, bericht_text,
einsatzleiter_id, alarmierung_art, status,
created_by
) VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10,
$11, $12,
$13, $14, $15,
$16
)
RETURNING *
`,
[
einsatz_nr,
data.alarm_time,
data.ausrueck_time ?? null,
data.ankunft_time ?? null,
data.einrueck_time ?? null,
data.einsatz_art,
data.einsatz_stichwort ?? null,
data.strasse ?? null,
data.hausnummer ?? null,
data.ort ?? null,
data.bericht_kurz ?? null,
data.bericht_text ?? null,
data.einsatzleiter_id ?? null,
data.alarmierung_art ?? 'ILS',
data.status ?? 'aktiv',
createdBy,
]
);
await client.query('COMMIT');
const einsatz = result.rows[0] as Einsatz;
logger.info('Incident created', {
einsatzId: einsatz.id,
einsatz_nr: einsatz.einsatz_nr,
createdBy,
});
return einsatz;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error creating incident', { error, createdBy });
throw new Error('Failed to create incident');
} finally {
client.release();
}
}
// -------------------------------------------------------------------------
// UPDATE
// -------------------------------------------------------------------------
async updateIncident(
id: string,
data: UpdateEinsatzData,
updatedBy: string
): Promise<Einsatz> {
try {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const fieldMap: Array<[keyof UpdateEinsatzData, string]> = [
['alarm_time', 'alarm_time'],
['ausrueck_time', 'ausrueck_time'],
['ankunft_time', 'ankunft_time'],
['einrueck_time', 'einrueck_time'],
['einsatz_art', 'einsatz_art'],
['einsatz_stichwort', 'einsatz_stichwort'],
['strasse', 'strasse'],
['hausnummer', 'hausnummer'],
['ort', 'ort'],
['bericht_kurz', 'bericht_kurz'],
['bericht_text', 'bericht_text'],
['einsatzleiter_id', 'einsatzleiter_id'],
['alarmierung_art', 'alarmierung_art'],
['status', 'status'],
];
for (const [key, col] of fieldMap) {
if (key in data) {
fields.push(`${col} = $${p++}`);
values.push((data as Record<string, unknown>)[key] ?? null);
}
}
if (fields.length === 0) {
// Nothing to update — return current state
const current = await this.getIncidentById(id);
if (!current) throw new Error('Incident not found');
return current as Einsatz;
}
// updated_at is handled by the trigger, but we also set it explicitly
// to ensure immediate consistency within the same request cycle
fields.push(`updated_at = NOW()`);
values.push(id);
const result = await pool.query(
`
UPDATE einsaetze
SET ${fields.join(', ')}
WHERE id = $${p}
RETURNING *
`,
values
);
if (result.rows.length === 0) {
throw new Error('Incident not found');
}
const einsatz = result.rows[0] as Einsatz;
logger.info('Incident updated', {
einsatzId: einsatz.id,
einsatz_nr: einsatz.einsatz_nr,
updatedBy,
});
return einsatz;
} catch (error) {
logger.error('Error updating incident', { error, id, updatedBy });
if (error instanceof Error && error.message === 'Incident not found') throw error;
throw new Error('Failed to update incident');
}
}
// -------------------------------------------------------------------------
// SOFT DELETE
// -------------------------------------------------------------------------
/** Soft delete: sets status = 'archiviert'. Hard delete is not exposed. */
async deleteIncident(id: string, deletedBy: string): Promise<void> {
try {
const result = await pool.query(
`
UPDATE einsaetze
SET status = 'archiviert', updated_at = NOW()
WHERE id = $1 AND status != 'archiviert'
RETURNING id
`,
[id]
);
if (result.rows.length === 0) {
throw new Error('Incident not found or already archived');
}
logger.info('Incident archived (soft-deleted)', { einsatzId: id, deletedBy });
} catch (error) {
logger.error('Error archiving incident', { error, id });
if (error instanceof Error && error.message.includes('not found')) throw error;
throw new Error('Failed to archive incident');
}
}
// -------------------------------------------------------------------------
// PERSONNEL
// -------------------------------------------------------------------------
async assignPersonnel(einsatzId: string, data: AssignPersonnelData): Promise<void> {
try {
await pool.query(
`
INSERT INTO einsatz_personal (einsatz_id, user_id, funktion, alarm_time, ankunft_time)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (einsatz_id, user_id) DO UPDATE SET
funktion = EXCLUDED.funktion,
alarm_time = EXCLUDED.alarm_time,
ankunft_time = EXCLUDED.ankunft_time
`,
[
einsatzId,
data.user_id,
data.funktion ?? 'Mannschaft',
data.alarm_time ?? null,
data.ankunft_time ?? null,
]
);
logger.info('Personnel assigned to incident', {
einsatzId,
userId: data.user_id,
funktion: data.funktion,
});
} catch (error) {
logger.error('Error assigning personnel', { error, einsatzId, data });
throw new Error('Failed to assign personnel');
}
}
async removePersonnel(einsatzId: string, userId: string): Promise<void> {
try {
const result = await pool.query(
`DELETE FROM einsatz_personal WHERE einsatz_id = $1 AND user_id = $2 RETURNING user_id`,
[einsatzId, userId]
);
if (result.rows.length === 0) {
throw new Error('Personnel assignment not found');
}
logger.info('Personnel removed from incident', { einsatzId, userId });
} catch (error) {
logger.error('Error removing personnel', { error, einsatzId, userId });
if (error instanceof Error && error.message.includes('not found')) throw error;
throw new Error('Failed to remove personnel');
}
}
// -------------------------------------------------------------------------
// VEHICLES
// -------------------------------------------------------------------------
async assignVehicle(einsatzId: string, data: AssignVehicleData): Promise<void> {
try {
await pool.query(
`
INSERT INTO einsatz_fahrzeuge (einsatz_id, fahrzeug_id, ausrueck_time, einrueck_time)
VALUES ($1, $2, $3, $4)
ON CONFLICT (einsatz_id, fahrzeug_id) DO UPDATE SET
ausrueck_time = EXCLUDED.ausrueck_time,
einrueck_time = EXCLUDED.einrueck_time
`,
[
einsatzId,
data.fahrzeug_id,
data.ausrueck_time ?? null,
data.einrueck_time ?? null,
]
);
logger.info('Vehicle assigned to incident', { einsatzId, fahrzeugId: data.fahrzeug_id });
} catch (error) {
logger.error('Error assigning vehicle', { error, einsatzId, data });
throw new Error('Failed to assign vehicle');
}
}
async removeVehicle(einsatzId: string, fahrzeugId: string): Promise<void> {
try {
const result = await pool.query(
`DELETE FROM einsatz_fahrzeuge WHERE einsatz_id = $1 AND fahrzeug_id = $2 RETURNING fahrzeug_id`,
[einsatzId, fahrzeugId]
);
if (result.rows.length === 0) {
throw new Error('Vehicle assignment not found');
}
logger.info('Vehicle removed from incident', { einsatzId, fahrzeugId });
} catch (error) {
logger.error('Error removing vehicle', { error, einsatzId, fahrzeugId });
if (error instanceof Error && error.message.includes('not found')) throw error;
throw new Error('Failed to remove vehicle');
}
}
// -------------------------------------------------------------------------
// STATISTICS
// -------------------------------------------------------------------------
/**
* Returns aggregated statistics for a given year (defaults to current year).
* Queries live data directly rather than the materialized view so the stats
* page always reflects uncommitted-or-just-committed incidents.
* The materialized view is used for dashboard KPI cards via a separate endpoint.
*/
async getIncidentStats(year?: number): Promise<EinsatzStats> {
try {
const targetYear = year ?? new Date().getFullYear();
const prevYear = targetYear - 1;
// Overall totals for target year
const totalsResult = await pool.query(
`
SELECT
COUNT(*)::INTEGER AS gesamt,
COUNT(*) FILTER (WHERE status = 'abgeschlossen')::INTEGER AS abgeschlossen,
COUNT(*) FILTER (WHERE status = 'aktiv')::INTEGER AS aktiv,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
`,
[targetYear]
);
const totals = totalsResult.rows[0];
// Monthly breakdown — target year
const monthlyResult = await pool.query(
`
SELECT
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
COUNT(*)::INTEGER AS anzahl,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
GROUP BY EXTRACT(MONTH FROM alarm_time)
ORDER BY monat
`,
[targetYear]
);
// Monthly breakdown — previous year (for chart overlay)
const prevMonthlyResult = await pool.query(
`
SELECT
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
COUNT(*)::INTEGER AS anzahl,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
GROUP BY EXTRACT(MONTH FROM alarm_time)
ORDER BY monat
`,
[prevYear]
);
// By Einsatzart — target year
const byArtResult = await pool.query(
`
SELECT
einsatz_art,
COUNT(*)::INTEGER AS anzahl,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
GROUP BY einsatz_art
ORDER BY anzahl DESC
`,
[targetYear]
);
// Determine most common Einsatzart
const haeufigste_art: EinsatzArt | null =
byArtResult.rows.length > 0 ? (byArtResult.rows[0].einsatz_art as EinsatzArt) : null;
const monthly: MonthlyStatRow[] = monthlyResult.rows.map((r) => ({
monat: r.monat,
anzahl: r.anzahl,
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
avg_dauer_min: r.avg_dauer_min !== null ? Number(r.avg_dauer_min) : null,
}));
const prev_year_monthly: MonthlyStatRow[] = prevMonthlyResult.rows.map((r) => ({
monat: r.monat,
anzahl: r.anzahl,
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
avg_dauer_min: r.avg_dauer_min !== null ? Number(r.avg_dauer_min) : null,
}));
const by_art: EinsatzArtStatRow[] = byArtResult.rows.map((r) => ({
einsatz_art: r.einsatz_art as EinsatzArt,
anzahl: r.anzahl,
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
}));
return {
jahr: targetYear,
gesamt: totals.gesamt ?? 0,
abgeschlossen: totals.abgeschlossen ?? 0,
aktiv: totals.aktiv ?? 0,
avg_hilfsfrist_min:
totals.avg_hilfsfrist_min !== null ? Number(totals.avg_hilfsfrist_min) : null,
haeufigste_art,
monthly,
by_art,
prev_year_monthly,
};
} catch (error) {
logger.error('Error fetching incident statistics', { error, year });
throw new Error('Failed to fetch incident statistics');
}
}
// -------------------------------------------------------------------------
// MATERIALIZED VIEW REFRESH
// -------------------------------------------------------------------------
/** Refresh the einsatz_statistik materialized view. Call after bulk operations. */
async refreshStatistikView(): Promise<void> {
try {
await pool.query('REFRESH MATERIALIZED VIEW CONCURRENTLY einsatz_statistik');
logger.info('einsatz_statistik materialized view refreshed');
} catch (error) {
// CONCURRENTLY requires a unique index — fall back to non-concurrent refresh
try {
await pool.query('REFRESH MATERIALIZED VIEW einsatz_statistik');
logger.info('einsatz_statistik materialized view refreshed (non-concurrent)');
} catch (fallbackError) {
logger.error('Error refreshing einsatz_statistik view', { fallbackError });
throw new Error('Failed to refresh statistics view');
}
}
}
}
export default new IncidentService();

View File

@@ -0,0 +1,594 @@
import pool from '../config/database';
import logger from '../utils/logger';
import {
MitgliederProfile,
MemberWithProfile,
MemberListItem,
MemberFilters,
MemberStats,
CreateMemberProfileData,
UpdateMemberProfileData,
DienstgradVerlaufEntry,
} from '../models/member.model';
class MemberService {
// ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
/**
* Builds the SELECT columns and JOIN for a full MemberWithProfile query.
* Returns raw pg rows that map to MemberWithProfile.
*/
private buildMemberSelectQuery(): string {
return `
SELECT
u.id,
u.email,
u.name,
u.given_name,
u.family_name,
u.preferred_username,
u.profile_picture_url,
u.is_active,
u.last_login_at,
u.created_at,
-- profile columns (aliased with mp_ prefix to avoid collision)
mp.id AS mp_id,
mp.user_id AS mp_user_id,
mp.mitglieds_nr AS mp_mitglieds_nr,
mp.dienstgrad AS mp_dienstgrad,
mp.dienstgrad_seit AS mp_dienstgrad_seit,
mp.funktion AS mp_funktion,
mp.status AS mp_status,
mp.eintrittsdatum AS mp_eintrittsdatum,
mp.austrittsdatum AS mp_austrittsdatum,
mp.geburtsdatum AS mp_geburtsdatum,
mp.telefon_mobil AS mp_telefon_mobil,
mp.telefon_privat AS mp_telefon_privat,
mp.notfallkontakt_name AS mp_notfallkontakt_name,
mp.notfallkontakt_telefon AS mp_notfallkontakt_telefon,
mp.fuehrerscheinklassen AS mp_fuehrerscheinklassen,
mp.tshirt_groesse AS mp_tshirt_groesse,
mp.schuhgroesse AS mp_schuhgroesse,
mp.bemerkungen AS mp_bemerkungen,
mp.bild_url AS mp_bild_url,
mp.created_at AS mp_created_at,
mp.updated_at AS mp_updated_at
FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
`;
}
/**
* Maps a raw pg result row (with mp_ prefixed columns) into a
* MemberWithProfile object. Handles null profile gracefully.
*/
private mapRowToMemberWithProfile(row: any): MemberWithProfile {
const hasProfile = row.mp_id !== null;
return {
id: row.id,
email: row.email,
name: row.name,
given_name: row.given_name,
family_name: row.family_name,
preferred_username: row.preferred_username,
profile_picture_url: row.profile_picture_url,
is_active: row.is_active,
last_login_at: row.last_login_at,
created_at: row.created_at,
profile: hasProfile
? {
id: row.mp_id,
user_id: row.mp_user_id,
mitglieds_nr: row.mp_mitglieds_nr,
dienstgrad: row.mp_dienstgrad,
dienstgrad_seit: row.mp_dienstgrad_seit,
funktion: row.mp_funktion ?? [],
status: row.mp_status,
eintrittsdatum: row.mp_eintrittsdatum,
austrittsdatum: row.mp_austrittsdatum,
geburtsdatum: row.mp_geburtsdatum,
telefon_mobil: row.mp_telefon_mobil,
telefon_privat: row.mp_telefon_privat,
notfallkontakt_name: row.mp_notfallkontakt_name,
notfallkontakt_telefon: row.mp_notfallkontakt_telefon,
fuehrerscheinklassen: row.mp_fuehrerscheinklassen ?? [],
tshirt_groesse: row.mp_tshirt_groesse,
schuhgroesse: row.mp_schuhgroesse,
bemerkungen: row.mp_bemerkungen,
bild_url: row.mp_bild_url,
created_at: row.mp_created_at,
updated_at: row.mp_updated_at,
}
: null,
};
}
// ----------------------------------------------------------------
// Public API
// ----------------------------------------------------------------
/**
* Returns a paginated list of members with the minimal fields
* required by the list view. Supports free-text search and
* multi-value filter by status and dienstgrad.
*/
async getAllMembers(filters?: MemberFilters): Promise<{ items: MemberListItem[]; total: number }> {
try {
const {
search,
status,
dienstgrad,
page = 1,
pageSize = 25,
} = filters ?? {};
const conditions: string[] = ['u.is_active = TRUE'];
const values: any[] = [];
let paramIdx = 1;
if (search) {
conditions.push(`(
u.name ILIKE $${paramIdx}
OR u.email ILIKE $${paramIdx}
OR u.given_name ILIKE $${paramIdx}
OR u.family_name ILIKE $${paramIdx}
OR mp.mitglieds_nr ILIKE $${paramIdx}
)`);
values.push(`%${search}%`);
paramIdx++;
}
if (status && status.length > 0) {
conditions.push(`mp.status = ANY($${paramIdx}::VARCHAR[])`);
values.push(status);
paramIdx++;
}
if (dienstgrad && dienstgrad.length > 0) {
conditions.push(`mp.dienstgrad = ANY($${paramIdx}::VARCHAR[])`);
values.push(dienstgrad);
paramIdx++;
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const offset = (page - 1) * pageSize;
const dataQuery = `
SELECT
u.id,
u.name,
u.given_name,
u.family_name,
u.email,
u.profile_picture_url,
u.is_active,
mp.id AS profile_id,
mp.mitglieds_nr,
mp.dienstgrad,
mp.funktion,
mp.status,
mp.eintrittsdatum,
mp.telefon_mobil
FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
${whereClause}
ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`;
values.push(pageSize, offset);
const countQuery = `
SELECT COUNT(*)::INTEGER AS total
FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
${whereClause}
`;
const [dataResult, countResult] = await Promise.all([
pool.query(dataQuery, values),
pool.query(countQuery, values.slice(0, values.length - 2)), // exclude LIMIT/OFFSET
]);
const items: MemberListItem[] = dataResult.rows.map((row) => ({
id: row.id,
name: row.name,
given_name: row.given_name,
family_name: row.family_name,
email: row.email,
profile_picture_url: row.profile_picture_url,
is_active: row.is_active,
profile_id: row.profile_id ?? null,
mitglieds_nr: row.mitglieds_nr ?? null,
dienstgrad: row.dienstgrad ?? null,
funktion: row.funktion ?? [],
status: row.status ?? null,
eintrittsdatum: row.eintrittsdatum ?? null,
telefon_mobil: row.telefon_mobil ?? null,
}));
logger.debug('getAllMembers', { count: items.length, filters });
return { items, total: countResult.rows[0].total };
} catch (error) {
logger.error('Error fetching member list', { error, filters });
throw new Error('Failed to fetch members');
}
}
/**
* Returns a single member with their full profile (including rank history).
* Returns null when the user does not exist.
*/
async getMemberById(userId: string): Promise<MemberWithProfile | null> {
try {
const query = `${this.buildMemberSelectQuery()} WHERE u.id = $1`;
const result = await pool.query(query, [userId]);
if (result.rows.length === 0) {
logger.debug('getMemberById: not found', { userId });
return null;
}
const member = this.mapRowToMemberWithProfile(result.rows[0]);
// Attach rank history when the profile exists
if (member.profile) {
member.dienstgrad_verlauf = await this.getDienstgradVerlauf(userId);
}
return member;
} catch (error) {
logger.error('Error fetching member by id', { error, userId });
throw new Error('Failed to fetch member');
}
}
/**
* Looks up a member by their Authentik OIDC subject identifier.
* Useful for looking up the currently logged-in user's own profile.
*/
async getMemberByAuthentikSub(sub: string): Promise<MemberWithProfile | null> {
try {
const query = `${this.buildMemberSelectQuery()} WHERE u.authentik_sub = $1`;
const result = await pool.query(query, [sub]);
if (result.rows.length === 0) return null;
const member = this.mapRowToMemberWithProfile(result.rows[0]);
if (member.profile) {
member.dienstgrad_verlauf = await this.getDienstgradVerlauf(member.id);
}
return member;
} catch (error) {
logger.error('Error fetching member by authentik sub', { error, sub });
throw new Error('Failed to fetch member');
}
}
/**
* Creates the mitglieder_profile row for an existing auth user.
* Throws if a profile already exists for this user_id.
*/
async createMemberProfile(
userId: string,
data: CreateMemberProfileData
): Promise<MitgliederProfile> {
try {
const query = `
INSERT INTO mitglieder_profile (
user_id,
mitglieds_nr,
dienstgrad,
dienstgrad_seit,
funktion,
status,
eintrittsdatum,
austrittsdatum,
geburtsdatum,
telefon_mobil,
telefon_privat,
notfallkontakt_name,
notfallkontakt_telefon,
fuehrerscheinklassen,
tshirt_groesse,
schuhgroesse,
bemerkungen,
bild_url
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14, $15, $16, $17, $18
)
RETURNING *
`;
const values = [
userId,
data.mitglieds_nr ?? null,
data.dienstgrad ?? null,
data.dienstgrad_seit ?? null,
data.funktion ?? [],
data.status ?? 'aktiv',
data.eintrittsdatum ?? null,
data.austrittsdatum ?? null,
data.geburtsdatum ?? null,
data.telefon_mobil ?? null,
data.telefon_privat ?? null,
data.notfallkontakt_name ?? null,
data.notfallkontakt_telefon ?? null,
data.fuehrerscheinklassen ?? [],
data.tshirt_groesse ?? null,
data.schuhgroesse ?? null,
data.bemerkungen ?? null,
data.bild_url ?? null,
];
const result = await pool.query(query, values);
const profile = result.rows[0] as MitgliederProfile;
// If a dienstgrad was set at creation, record it in history
if (data.dienstgrad) {
await this.writeDienstgradVerlauf(
userId,
data.dienstgrad,
null,
userId, // created-by = the user being set up (or override with system user)
'Initialer Dienstgrad bei Profilerstellung'
);
}
logger.info('Created mitglieder_profile', { userId, profileId: profile.id });
return profile;
} catch (error: any) {
if (error?.code === '23505') {
// unique_violation on user_id
throw new Error('Ein Profil für dieses Mitglied existiert bereits.');
}
logger.error('Error creating mitglieder_profile', { error, userId });
throw new Error('Failed to create member profile');
}
}
/**
* Partially updates the mitglieder_profile for the given user.
* Only fields present in `data` are written (undefined = untouched).
*
* Rank changes are handled separately via updateDienstgrad() to ensure
* the change is always logged in dienstgrad_verlauf. If `data.dienstgrad`
* is present it will be delegated to that method automatically.
*/
async updateMemberProfile(
userId: string,
data: UpdateMemberProfileData,
updatedBy: string
): Promise<MitgliederProfile> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Handle rank change through dedicated method to ensure audit log
const { dienstgrad, dienstgrad_seit, ...rest } = data;
if (dienstgrad !== undefined) {
await this.updateDienstgrad(userId, dienstgrad, updatedBy, dienstgrad_seit, client);
} else if (dienstgrad_seit !== undefined) {
// dienstgrad_seit can be updated independently
rest.dienstgrad_seit = dienstgrad_seit;
}
// Build dynamic SET clause for remaining fields
const updateFields: string[] = [];
const values: any[] = [];
let paramIdx = 1;
const fieldMap: Record<string, any> = {
mitglieds_nr: rest.mitglieds_nr,
funktion: rest.funktion,
status: rest.status,
eintrittsdatum: rest.eintrittsdatum,
austrittsdatum: rest.austrittsdatum,
geburtsdatum: rest.geburtsdatum,
telefon_mobil: rest.telefon_mobil,
telefon_privat: rest.telefon_privat,
notfallkontakt_name: rest.notfallkontakt_name,
notfallkontakt_telefon: rest.notfallkontakt_telefon,
fuehrerscheinklassen: rest.fuehrerscheinklassen,
tshirt_groesse: rest.tshirt_groesse,
schuhgroesse: rest.schuhgroesse,
bemerkungen: rest.bemerkungen,
bild_url: rest.bild_url,
dienstgrad_seit: (rest as any).dienstgrad_seit,
};
for (const [column, value] of Object.entries(fieldMap)) {
if (value !== undefined) {
updateFields.push(`${column} = $${paramIdx++}`);
values.push(value);
}
}
let profile: MitgliederProfile;
if (updateFields.length > 0) {
values.push(userId);
const query = `
UPDATE mitglieder_profile
SET ${updateFields.join(', ')}
WHERE user_id = $${paramIdx}
RETURNING *
`;
const result = await client.query(query, values);
if (result.rows.length === 0) {
throw new Error('Mitgliedsprofil nicht gefunden.');
}
profile = result.rows[0] as MitgliederProfile;
} else {
// Nothing to update (rank change only) — fetch current state
const result = await client.query(
'SELECT * FROM mitglieder_profile WHERE user_id = $1',
[userId]
);
if (result.rows.length === 0) throw new Error('Mitgliedsprofil nicht gefunden.');
profile = result.rows[0] as MitgliederProfile;
}
await client.query('COMMIT');
logger.info('Updated mitglieder_profile', { userId, updatedBy });
return profile;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error updating mitglieder_profile', { error, userId });
throw error instanceof Error ? error : new Error('Failed to update member profile');
} finally {
client.release();
}
}
/**
* Sets a new Dienstgrad and writes an entry to dienstgrad_verlauf.
* Can accept an optional pg PoolClient to participate in an outer transaction.
*/
async updateDienstgrad(
userId: string,
newDienstgrad: string,
changedBy: string,
since?: Date,
existingClient?: any
): Promise<void> {
const executor = existingClient ?? pool;
try {
// Fetch current rank for the history entry
const currentResult = await executor.query(
'SELECT dienstgrad FROM mitglieder_profile WHERE user_id = $1',
[userId]
);
const oldDienstgrad: string | null =
currentResult.rows.length > 0 ? currentResult.rows[0].dienstgrad : null;
// Update the profile
await executor.query(
`UPDATE mitglieder_profile
SET dienstgrad = $1, dienstgrad_seit = $2
WHERE user_id = $3`,
[newDienstgrad, since ?? new Date(), userId]
);
// Write audit entry
await this.writeDienstgradVerlauf(
userId,
newDienstgrad,
oldDienstgrad,
changedBy,
null,
existingClient
);
logger.info('Updated Dienstgrad', { userId, oldDienstgrad, newDienstgrad, changedBy });
} catch (error) {
logger.error('Error updating Dienstgrad', { error, userId, newDienstgrad });
throw new Error('Failed to update Dienstgrad');
}
}
/**
* Internal helper: inserts one row into dienstgrad_verlauf.
*/
private async writeDienstgradVerlauf(
userId: string,
dienstgradNeu: string,
dienstgradAlt: string | null,
durchUserId: string | null,
bemerkung: string | null = null,
existingClient?: any
): Promise<void> {
const executor = existingClient ?? pool;
await executor.query(
`INSERT INTO dienstgrad_verlauf
(user_id, dienstgrad_neu, dienstgrad_alt, durch_user_id, bemerkung)
VALUES ($1, $2, $3, $4, $5)`,
[userId, dienstgradNeu, dienstgradAlt, durchUserId, bemerkung]
);
}
/**
* Fetches the rank change history for a member, newest first.
*/
async getDienstgradVerlauf(userId: string): Promise<DienstgradVerlaufEntry[]> {
try {
const result = await pool.query(
`SELECT
dv.*,
u.name AS durch_user_name
FROM dienstgrad_verlauf dv
LEFT JOIN users u ON u.id = dv.durch_user_id
WHERE dv.user_id = $1
ORDER BY dv.datum DESC, dv.created_at DESC`,
[userId]
);
return result.rows as DienstgradVerlaufEntry[];
} catch (error) {
logger.error('Error fetching Dienstgrad history', { error, userId });
return [];
}
}
/**
* Returns aggregate member counts, used by the dashboard KPI cards.
* Optionally scoped to a single status value.
*/
async getMemberCount(status?: string): Promise<number> {
try {
let query: string;
let values: any[];
if (status) {
query = `
SELECT COUNT(*)::INTEGER AS count
FROM mitglieder_profile
WHERE status = $1
`;
values = [status];
} else {
query = `SELECT COUNT(*)::INTEGER AS count FROM mitglieder_profile`;
values = [];
}
const result = await pool.query(query, values);
return result.rows[0].count;
} catch (error) {
logger.error('Error counting members', { error, status });
throw new Error('Failed to count members');
}
}
/**
* Returns a full stats breakdown for all statuses (dashboard KPI widget).
*/
async getMemberStats(): Promise<MemberStats> {
try {
const result = await pool.query(`
SELECT
COUNT(*)::INTEGER AS total,
COUNT(*) FILTER (WHERE status = 'aktiv')::INTEGER AS aktiv,
COUNT(*) FILTER (WHERE status = 'passiv')::INTEGER AS passiv,
COUNT(*) FILTER (WHERE status = 'ehrenmitglied')::INTEGER AS ehrenmitglied,
COUNT(*) FILTER (WHERE status = 'jugendfeuerwehr')::INTEGER AS jugendfeuerwehr,
COUNT(*) FILTER (WHERE status = 'anwärter')::INTEGER AS "anwärter",
COUNT(*) FILTER (WHERE status = 'ausgetreten')::INTEGER AS ausgetreten
FROM mitglieder_profile
`);
return result.rows[0] as MemberStats;
} catch (error) {
logger.error('Error fetching member stats', { error });
throw new Error('Failed to fetch member stats');
}
}
}
export default new MemberService();

View File

@@ -0,0 +1,614 @@
import { randomBytes } from 'crypto';
import pool from '../config/database';
import logger from '../utils/logger';
import {
Uebung,
UebungWithAttendance,
UebungListItem,
MemberParticipationStats,
CreateUebungData,
UpdateUebungData,
TeilnahmeStatus,
Teilnahme,
} from '../models/training.model';
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Columns used in all SELECT queries to hydrate a full Uebung row */
const UEBUNG_COLUMNS = `
u.id, u.titel, u.beschreibung, u.typ::text AS typ,
u.datum_von, u.datum_bis, u.ort, u.treffpunkt,
u.pflichtveranstaltung, u.mindest_teilnehmer, u.max_teilnehmer,
u.angelegt_von, u.erstellt_am, u.aktualisiert_am,
u.abgesagt, u.absage_grund
`;
const ATTENDANCE_COUNT_COLUMNS = `
COUNT(t.user_id) AS gesamt_eingeladen,
COUNT(t.user_id) FILTER (WHERE t.status = 'zugesagt') AS anzahl_zugesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'abgesagt') AS anzahl_abgesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'erschienen') AS anzahl_erschienen,
COUNT(t.user_id) FILTER (WHERE t.status = 'entschuldigt') AS anzahl_entschuldigt,
COUNT(t.user_id) FILTER (WHERE t.status = 'unbekannt') AS anzahl_unbekannt
`;
/** Map a raw DB row to UebungListItem, optionally including eigener_status */
function rowToListItem(row: any, eigenerStatus?: TeilnahmeStatus): UebungListItem {
return {
id: row.id,
titel: row.titel,
typ: row.typ,
datum_von: new Date(row.datum_von),
datum_bis: new Date(row.datum_bis),
ort: row.ort ?? null,
pflichtveranstaltung: row.pflichtveranstaltung,
abgesagt: row.abgesagt,
anzahl_zugesagt: Number(row.anzahl_zugesagt ?? 0),
anzahl_erschienen: Number(row.anzahl_erschienen ?? 0),
gesamt_eingeladen: Number(row.gesamt_eingeladen ?? 0),
eigener_status: eigenerStatus ?? row.eigener_status ?? undefined,
};
}
function rowToUebung(row: any): Uebung {
return {
id: row.id,
titel: row.titel,
beschreibung: row.beschreibung ?? null,
typ: row.typ,
datum_von: new Date(row.datum_von),
datum_bis: new Date(row.datum_bis),
ort: row.ort ?? null,
treffpunkt: row.treffpunkt ?? null,
pflichtveranstaltung: row.pflichtveranstaltung,
mindest_teilnehmer: row.mindest_teilnehmer ?? null,
max_teilnehmer: row.max_teilnehmer ?? null,
angelegt_von: row.angelegt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
abgesagt: row.abgesagt,
absage_grund: row.absage_grund ?? null,
};
}
// ---------------------------------------------------------------------------
// Training Service
// ---------------------------------------------------------------------------
class TrainingService {
/**
* Returns upcoming (future) events sorted ascending by datum_von.
* Used by the dashboard widget and the list view.
*/
async getUpcomingEvents(limit = 10, userId?: string): Promise<UebungListItem[]> {
const query = `
SELECT
${UEBUNG_COLUMNS},
${ATTENDANCE_COUNT_COLUMNS}
${userId ? `, own_t.status::text AS eigener_status` : ''}
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $2` : ''}
WHERE u.datum_von > NOW()
AND u.abgesagt = FALSE
GROUP BY u.id ${userId ? `, own_t.status` : ''}
ORDER BY u.datum_von ASC
LIMIT $1
`;
const values = userId ? [limit, userId] : [limit];
const result = await pool.query(query, values);
return result.rows.map((r) => rowToListItem(r));
}
/**
* Returns all events within a date range (inclusive) for the calendar view.
* Does NOT filter out cancelled events — the frontend shows them struck through.
*/
async getEventsByDateRange(from: Date, to: Date, userId?: string): Promise<UebungListItem[]> {
const query = `
SELECT
${UEBUNG_COLUMNS},
${ATTENDANCE_COUNT_COLUMNS}
${userId ? `, own_t.status::text AS eigener_status` : ''}
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $3` : ''}
WHERE u.datum_von >= $1
AND u.datum_von <= $2
GROUP BY u.id ${userId ? `, own_t.status` : ''}
ORDER BY u.datum_von ASC
`;
const values = userId ? [from, to, userId] : [from, to];
const result = await pool.query(query, values);
return result.rows.map((r) => rowToListItem(r));
}
/**
* Returns the full event detail including attendance counts and, for
* privileged users, the individual attendee list.
*/
async getEventById(
id: string,
userId?: string,
includeTeilnahmen = false
): Promise<UebungWithAttendance | null> {
const eventQuery = `
SELECT
${UEBUNG_COLUMNS},
${ATTENDANCE_COUNT_COLUMNS},
creator.name AS angelegt_von_name
${userId ? `, own_t.status::text AS eigener_status` : ''}
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
LEFT JOIN users creator ON creator.id = u.angelegt_von
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $2` : ''}
WHERE u.id = $1
GROUP BY u.id, creator.name ${userId ? `, own_t.status` : ''}
`;
const values = userId ? [id, userId] : [id];
const eventResult = await pool.query(eventQuery, values);
if (eventResult.rows.length === 0) return null;
const row = eventResult.rows[0];
const uebung = rowToUebung(row);
const result: UebungWithAttendance = {
...uebung,
gesamt_eingeladen: Number(row.gesamt_eingeladen ?? 0),
anzahl_zugesagt: Number(row.anzahl_zugesagt ?? 0),
anzahl_abgesagt: Number(row.anzahl_abgesagt ?? 0),
anzahl_erschienen: Number(row.anzahl_erschienen ?? 0),
anzahl_entschuldigt: Number(row.anzahl_entschuldigt ?? 0),
anzahl_unbekannt: Number(row.anzahl_unbekannt ?? 0),
angelegt_von_name: row.angelegt_von_name ?? null,
eigener_status: row.eigener_status ?? undefined,
};
if (includeTeilnahmen) {
const teilnahmenQuery = `
SELECT
t.uebung_id,
t.user_id,
t.status::text AS status,
t.antwort_am,
t.erschienen_erfasst_am,
t.erschienen_erfasst_von,
t.bemerkung,
COALESCE(u.name, u.preferred_username, u.email) AS user_name,
u.email AS user_email
FROM uebung_teilnahmen t
JOIN users u ON u.id = t.user_id
WHERE t.uebung_id = $1
ORDER BY u.name ASC NULLS LAST
`;
const teilnahmenResult = await pool.query(teilnahmenQuery, [id]);
result.teilnahmen = teilnahmenResult.rows as Teilnahme[];
}
return result;
}
/**
* Creates a new training event.
* The database trigger automatically creates 'unbekannt' teilnahmen
* rows for all active members.
*/
async createEvent(data: CreateUebungData, createdBy: string): Promise<Uebung> {
const query = `
INSERT INTO uebungen (
titel, beschreibung, typ, datum_von, datum_bis, ort, treffpunkt,
pflichtveranstaltung, mindest_teilnehmer, max_teilnehmer, angelegt_von
)
VALUES ($1, $2, $3::uebung_typ, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING
id, titel, beschreibung, typ::text AS typ,
datum_von, datum_bis, ort, treffpunkt,
pflichtveranstaltung, mindest_teilnehmer, max_teilnehmer,
angelegt_von, erstellt_am, aktualisiert_am,
abgesagt, absage_grund
`;
const values = [
data.titel,
data.beschreibung ?? null,
data.typ,
data.datum_von,
data.datum_bis,
data.ort ?? null,
data.treffpunkt ?? null,
data.pflichtveranstaltung,
data.mindest_teilnehmer ?? null,
data.max_teilnehmer ?? null,
createdBy,
];
const result = await pool.query(query, values);
const event = rowToUebung(result.rows[0]);
logger.info('Training event created', {
eventId: event.id,
titel: event.titel,
typ: event.typ,
datum_von: event.datum_von,
createdBy,
});
return event;
}
/**
* Updates mutable fields of an existing event.
* Only provided fields are updated (partial update semantics).
*/
async updateEvent(
id: string,
data: UpdateUebungData,
_updatedBy: string
): Promise<Uebung> {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const add = (col: string, val: unknown, cast = '') => {
fields.push(`${col} = $${p++}${cast}`);
values.push(val);
};
if (data.titel !== undefined) add('titel', data.titel);
if (data.beschreibung !== undefined) add('beschreibung', data.beschreibung);
if (data.typ !== undefined) add('typ', data.typ, '::uebung_typ');
if (data.datum_von !== undefined) add('datum_von', data.datum_von);
if (data.datum_bis !== undefined) add('datum_bis', data.datum_bis);
if (data.ort !== undefined) add('ort', data.ort);
if (data.treffpunkt !== undefined) add('treffpunkt', data.treffpunkt);
if (data.pflichtveranstaltung !== undefined) add('pflichtveranstaltung', data.pflichtveranstaltung);
if (data.mindest_teilnehmer !== undefined) add('mindest_teilnehmer', data.mindest_teilnehmer);
if (data.max_teilnehmer !== undefined) add('max_teilnehmer', data.max_teilnehmer);
if (fields.length === 0) {
// Nothing to update — return existing event
const existing = await this.getEventById(id);
if (!existing) throw new Error('Event not found');
return existing;
}
values.push(id);
const query = `
UPDATE uebungen
SET ${fields.join(', ')}
WHERE id = $${p}
RETURNING
id, titel, beschreibung, typ::text AS typ,
datum_von, datum_bis, ort, treffpunkt,
pflichtveranstaltung, mindest_teilnehmer, max_teilnehmer,
angelegt_von, erstellt_am, aktualisiert_am,
abgesagt, absage_grund
`;
const result = await pool.query(query, values);
if (result.rows.length === 0) throw new Error('Event not found');
logger.info('Training event updated', { eventId: id, updatedBy: _updatedBy });
return rowToUebung(result.rows[0]);
}
/**
* Soft-cancels an event. Sets abgesagt=true and records the reason.
* Does NOT delete the event or its attendance rows.
*/
async cancelEvent(id: string, reason: string, updatedBy: string): Promise<void> {
const result = await pool.query(
`UPDATE uebungen
SET abgesagt = TRUE, absage_grund = $2
WHERE id = $1
RETURNING id`,
[id, reason]
);
if (result.rows.length === 0) throw new Error('Event not found');
logger.info('Training event cancelled', { eventId: id, updatedBy, reason });
}
/**
* Member updates their own RSVP — only 'zugesagt' or 'abgesagt' allowed here.
* Sets antwort_am to now.
*/
async updateAttendanceRSVP(
uebungId: string,
userId: string,
status: 'zugesagt' | 'abgesagt',
bemerkung?: string | null
): Promise<void> {
const result = await pool.query(
`UPDATE uebung_teilnahmen
SET status = $3::teilnahme_status,
antwort_am = NOW(),
bemerkung = COALESCE($4, bemerkung)
WHERE uebung_id = $1 AND user_id = $2
RETURNING uebung_id`,
[uebungId, userId, status, bemerkung ?? null]
);
if (result.rows.length === 0) {
// Row might not exist if member joined after event was created — insert it
await pool.query(
`INSERT INTO uebung_teilnahmen (uebung_id, user_id, status, antwort_am, bemerkung)
VALUES ($1, $2, $3::teilnahme_status, NOW(), $4)
ON CONFLICT (uebung_id, user_id) DO UPDATE
SET status = EXCLUDED.status,
antwort_am = EXCLUDED.antwort_am,
bemerkung = COALESCE(EXCLUDED.bemerkung, uebung_teilnahmen.bemerkung)`,
[uebungId, userId, status, bemerkung ?? null]
);
}
logger.info('RSVP updated', { uebungId, userId, status });
}
/**
* Gruppenführer / Kommandant bulk-marks members as 'erschienen'.
* Marks erschienen_erfasst_am and erschienen_erfasst_von.
*/
async markAttendance(
uebungId: string,
userIds: string[],
markedBy: string
): Promise<void> {
if (userIds.length === 0) return;
// Build parameterized IN clause: $3, $4, $5, ...
const placeholders = userIds.map((_, i) => `$${i + 3}`).join(', ');
await pool.query(
`UPDATE uebung_teilnahmen
SET status = 'erschienen'::teilnahme_status,
erschienen_erfasst_am = NOW(),
erschienen_erfasst_von = $2
WHERE uebung_id = $1
AND user_id IN (${placeholders})`,
[uebungId, markedBy, ...userIds]
);
logger.info('Bulk attendance marked', {
uebungId,
count: userIds.length,
markedBy,
});
}
/**
* Annual participation statistics for all active members.
* Filters to events within the given calendar year.
* "unbekannt" responses are NOT treated as absent.
*/
async getMemberParticipationStats(year: number): Promise<MemberParticipationStats[]> {
const query = `
SELECT
usr.id AS user_id,
COALESCE(usr.name, usr.preferred_username, usr.email) AS name,
COUNT(t.uebung_id) AS total_uebungen,
COUNT(t.uebung_id) FILTER (WHERE t.status = 'erschienen') AS attended,
COUNT(t.uebung_id) FILTER (WHERE u.pflichtveranstaltung = TRUE) AS pflicht_gesamt,
COUNT(t.uebung_id) FILTER (
WHERE u.pflichtveranstaltung = TRUE AND t.status = 'erschienen'
) AS pflicht_erschienen,
ROUND(
CASE
WHEN COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') = 0 THEN 0
ELSE
COUNT(t.uebung_id) FILTER (
WHERE u.typ = 'Übungsabend' AND t.status = 'erschienen'
)::NUMERIC /
COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') * 100
END, 1
) AS uebungsabend_quote_pct
FROM users usr
JOIN uebung_teilnahmen t ON t.user_id = usr.id
JOIN uebungen u ON u.id = t.uebung_id
WHERE usr.is_active = TRUE
AND u.abgesagt = FALSE
AND EXTRACT(YEAR FROM u.datum_von) = $1
GROUP BY usr.id, usr.name, usr.preferred_username, usr.email
ORDER BY name ASC
`;
const result = await pool.query(query, [year]);
return result.rows.map((r) => ({
userId: r.user_id,
name: r.name,
totalUebungen: Number(r.total_uebungen),
attended: Number(r.attended),
attendancePercent:
Number(r.total_uebungen) === 0
? 0
: Math.round((Number(r.attended) / Number(r.total_uebungen)) * 1000) / 10,
pflichtGesamt: Number(r.pflicht_gesamt),
pflichtErschienen: Number(r.pflicht_erschienen),
uebungsabendQuotePct: Number(r.uebungsabend_quote_pct),
}));
}
// ---------------------------------------------------------------------------
// iCal token management
// ---------------------------------------------------------------------------
/**
* Returns the existing calendar token for a user, or creates a new one.
* Tokens are 32-byte hex strings (URL-safe).
*/
async getOrCreateCalendarToken(userId: string): Promise<string> {
const existing = await pool.query(
`SELECT token FROM calendar_tokens WHERE user_id = $1`,
[userId]
);
if (existing.rows.length > 0) return existing.rows[0].token;
const token = randomBytes(32).toString('hex');
await pool.query(
`INSERT INTO calendar_tokens (user_id, token) VALUES ($1, $2)`,
[userId, token]
);
return token;
}
/**
* Looks up the userId associated with a calendar token and
* touches last_used_at.
*/
async resolveCalendarToken(token: string): Promise<string | null> {
const result = await pool.query(
`UPDATE calendar_tokens
SET last_used_at = NOW()
WHERE token = $1
RETURNING user_id`,
[token]
);
return result.rows[0]?.user_id ?? null;
}
/**
* Generates iCal content for the given user (or public feed for all events
* when userId is undefined).
*/
async getCalendarExport(userId?: string): Promise<string> {
// Fetch events for the next 12 months + past 3 months
const from = new Date();
from.setMonth(from.getMonth() - 3);
const to = new Date();
to.setFullYear(to.getFullYear() + 1);
const events = await this.getEventsByDateRange(from, to, userId);
return generateICS(events, 'Feuerwehr Rems');
}
}
// ---------------------------------------------------------------------------
// iCal generation — zero-dependency RFC 5545 implementation
// ---------------------------------------------------------------------------
/**
* Formats a Date to the iCal DTSTART/DTEND format with UTC timezone.
* Output: 20260304T190000Z
*/
function formatIcsDate(date: Date): string {
return date
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
}
/**
* Folds long iCal lines at 75 octets (RFC 5545 §3.1).
* Continuation lines start with a single space.
*/
function foldLine(line: string): string {
const MAX = 75;
if (line.length <= MAX) return line;
let result = '';
while (line.length > MAX) {
result += line.substring(0, MAX) + '\r\n ';
line = line.substring(MAX);
}
result += line;
return result;
}
/**
* Escapes text field values per RFC 5545 §3.3.11.
*/
function escapeIcsText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n')
.replace(/\r/g, '');
}
/** Maps UebungTyp to a human-readable category string */
const TYP_CATEGORY: Record<string, string> = {
'Übungsabend': 'Training',
'Lehrgang': 'Course',
'Sonderdienst': 'Special Duty',
'Versammlung': 'Meeting',
'Gemeinschaftsübung': 'Joint Exercise',
'Sonstiges': 'Other',
};
export function generateICS(
events: Array<{
id: string;
titel: string;
beschreibung?: string | null;
typ: string;
datum_von: Date;
datum_bis: Date;
ort?: string | null;
pflichtveranstaltung: boolean;
abgesagt: boolean;
}>,
organizerName: string
): string {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
`PRODID:-//Feuerwehr Rems//Dashboard//DE`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${escapeIcsText(organizerName)} - Dienstkalender`,
'X-WR-TIMEZONE:Europe/Vienna',
'X-WR-CALDESC:Übungs- und Dienstkalender der Feuerwehr Rems',
];
const stampNow = formatIcsDate(new Date());
for (const event of events) {
const summary = event.abgesagt
? `[ABGESAGT] ${event.titel}`
: event.pflichtveranstaltung
? `* ${event.titel}`
: event.titel;
const descParts: string[] = [];
if (event.beschreibung) descParts.push(event.beschreibung);
if (event.pflichtveranstaltung) descParts.push('PFLICHTVERANSTALTUNG');
if (event.abgesagt) descParts.push('Diese Veranstaltung wurde abgesagt.');
descParts.push(`Typ: ${event.typ}`);
lines.push('BEGIN:VEVENT');
lines.push(foldLine(`UID:${event.id}@feuerwehr-rems.at`));
lines.push(`DTSTAMP:${stampNow}`);
lines.push(`DTSTART:${formatIcsDate(event.datum_von)}`);
lines.push(`DTEND:${formatIcsDate(event.datum_bis)}`);
lines.push(foldLine(`SUMMARY:${escapeIcsText(summary)}`));
if (descParts.length > 0) {
lines.push(foldLine(`DESCRIPTION:${escapeIcsText(descParts.join('\\n'))}`));
}
if (event.ort) {
lines.push(foldLine(`LOCATION:${escapeIcsText(event.ort)}`));
}
lines.push(`CATEGORIES:${TYP_CATEGORY[event.typ] ?? 'Other'}`);
if (event.abgesagt) {
lines.push('STATUS:CANCELLED');
}
lines.push('END:VEVENT');
}
lines.push('END:VCALENDAR');
return lines.join('\r\n') + '\r\n';
}
export default new TrainingService();

View File

@@ -0,0 +1,572 @@
import { Server as SocketIOServer } from 'socket.io';
import pool from '../config/database';
import logger from '../utils/logger';
import {
Fahrzeug,
FahrzeugListItem,
FahrzeugWithPruefstatus,
FahrzeugPruefung,
FahrzeugWartungslog,
CreateFahrzeugData,
UpdateFahrzeugData,
CreatePruefungData,
CreateWartungslogData,
FahrzeugStatus,
PruefungArt,
PruefungIntervalMonths,
VehicleStats,
InspectionAlert,
} from '../models/vehicle.model';
// ---------------------------------------------------------------------------
// Helper: add N months to a Date (handles month-end edge cases)
// ---------------------------------------------------------------------------
function addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
// ---------------------------------------------------------------------------
// Helper: map a flat view row to PruefungStatus sub-object
// ---------------------------------------------------------------------------
function mapPruefungStatus(row: any, prefix: string) {
return {
pruefung_id: row[`${prefix}_pruefung_id`] ?? null,
faellig_am: row[`${prefix}_faellig_am`] ?? null,
tage_bis_faelligkeit: row[`${prefix}_tage_bis_faelligkeit`] != null
? parseInt(row[`${prefix}_tage_bis_faelligkeit`], 10)
: null,
ergebnis: row[`${prefix}_ergebnis`] ?? null,
};
}
class VehicleService {
// =========================================================================
// FLEET OVERVIEW
// =========================================================================
/**
* Returns all vehicles with their next-due inspection dates per type.
* Used by the fleet overview grid (FahrzeugListItem[]).
*/
async getAllVehicles(): Promise<FahrzeugListItem[]> {
try {
const result = await pool.query(`
SELECT
id,
bezeichnung,
kurzname,
amtliches_kennzeichen,
baujahr,
hersteller,
besatzung_soll,
status,
status_bemerkung,
bild_url,
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
ORDER BY bezeichnung ASC
`);
return result.rows.map((row) => ({
...row,
hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null
? parseInt(row.hu_tage_bis_faelligkeit, 10) : null,
au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null
? parseInt(row.au_tage_bis_faelligkeit, 10) : null,
uvv_tage_bis_faelligkeit: row.uvv_tage_bis_faelligkeit != null
? parseInt(row.uvv_tage_bis_faelligkeit, 10) : null,
leiter_tage_bis_faelligkeit: row.leiter_tage_bis_faelligkeit != null
? parseInt(row.leiter_tage_bis_faelligkeit, 10) : null,
naechste_pruefung_tage: row.naechste_pruefung_tage != null
? parseInt(row.naechste_pruefung_tage, 10) : null,
})) as FahrzeugListItem[];
} catch (error) {
logger.error('VehicleService.getAllVehicles failed', { error });
throw new Error('Failed to fetch vehicles');
}
}
// =========================================================================
// VEHICLE DETAIL
// =========================================================================
/**
* Returns a single vehicle with full pruefstatus, inspection history,
* and maintenance log.
*/
async getVehicleById(id: string): Promise<FahrzeugWithPruefstatus | null> {
try {
// 1) Main record + inspection status from view
const vehicleResult = await pool.query(
`SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`,
[id]
);
if (vehicleResult.rows.length === 0) return null;
const row = vehicleResult.rows[0];
// 2) Full inspection history
const pruefungenResult = await pool.query(
`SELECT * FROM fahrzeug_pruefungen
WHERE fahrzeug_id = $1
ORDER BY faellig_am DESC, created_at DESC`,
[id]
);
// 3) Maintenance log
const wartungslogResult = await pool.query(
`SELECT * FROM fahrzeug_wartungslog
WHERE fahrzeug_id = $1
ORDER BY datum DESC, created_at DESC`,
[id]
);
const vehicle: FahrzeugWithPruefstatus = {
id: row.id,
bezeichnung: row.bezeichnung,
kurzname: row.kurzname,
amtliches_kennzeichen: row.amtliches_kennzeichen,
fahrgestellnummer: row.fahrgestellnummer,
baujahr: row.baujahr,
hersteller: row.hersteller,
typ_schluessel: row.typ_schluessel,
besatzung_soll: row.besatzung_soll,
status: row.status as FahrzeugStatus,
status_bemerkung: row.status_bemerkung,
standort: row.standort,
bild_url: row.bild_url,
created_at: row.created_at,
updated_at: row.updated_at,
pruefstatus: {
hu: mapPruefungStatus(row, 'hu'),
au: mapPruefungStatus(row, 'au'),
uvv: mapPruefungStatus(row, 'uvv'),
leiter: mapPruefungStatus(row, 'leiter'),
},
naechste_pruefung_tage: row.naechste_pruefung_tage != null
? parseInt(row.naechste_pruefung_tage, 10) : null,
pruefungen: pruefungenResult.rows as FahrzeugPruefung[],
wartungslog: wartungslogResult.rows as FahrzeugWartungslog[],
};
return vehicle;
} catch (error) {
logger.error('VehicleService.getVehicleById failed', { error, id });
throw new Error('Failed to fetch vehicle');
}
}
// =========================================================================
// CRUD
// =========================================================================
async createVehicle(
data: CreateFahrzeugData,
createdBy: string
): Promise<Fahrzeug> {
try {
const result = await pool.query(
`INSERT INTO fahrzeuge (
bezeichnung, kurzname, amtliches_kennzeichen, fahrgestellnummer,
baujahr, hersteller, typ_schluessel, besatzung_soll,
status, status_bemerkung, standort, bild_url
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`,
[
data.bezeichnung,
data.kurzname ?? null,
data.amtliches_kennzeichen ?? null,
data.fahrgestellnummer ?? null,
data.baujahr ?? null,
data.hersteller ?? null,
data.typ_schluessel ?? null,
data.besatzung_soll ?? null,
data.status ?? FahrzeugStatus.Einsatzbereit,
data.status_bemerkung ?? null,
data.standort ?? 'Feuerwehrhaus',
data.bild_url ?? null,
]
);
const vehicle = result.rows[0] as Fahrzeug;
logger.info('Vehicle created', { id: vehicle.id, by: createdBy });
return vehicle;
} catch (error) {
logger.error('VehicleService.createVehicle failed', { error, createdBy });
throw new Error('Failed to create vehicle');
}
}
async updateVehicle(
id: string,
data: UpdateFahrzeugData,
updatedBy: string
): Promise<Fahrzeug> {
try {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const addField = (col: string, value: unknown) => {
fields.push(`${col} = $${p++}`);
values.push(value);
};
if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung);
if (data.kurzname !== undefined) addField('kurzname', data.kurzname);
if (data.amtliches_kennzeichen !== undefined) addField('amtliches_kennzeichen', data.amtliches_kennzeichen);
if (data.fahrgestellnummer !== undefined) addField('fahrgestellnummer', data.fahrgestellnummer);
if (data.baujahr !== undefined) addField('baujahr', data.baujahr);
if (data.hersteller !== undefined) addField('hersteller', data.hersteller);
if (data.typ_schluessel !== undefined) addField('typ_schluessel', data.typ_schluessel);
if (data.besatzung_soll !== undefined) addField('besatzung_soll', data.besatzung_soll);
if (data.status !== undefined) addField('status', data.status);
if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung);
if (data.standort !== undefined) addField('standort', data.standort);
if (data.bild_url !== undefined) addField('bild_url', data.bild_url);
if (fields.length === 0) {
throw new Error('No fields to update');
}
values.push(id); // for WHERE clause
const result = await pool.query(
`UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`,
values
);
if (result.rows.length === 0) {
throw new Error('Vehicle not found');
}
const vehicle = result.rows[0] as Fahrzeug;
logger.info('Vehicle updated', { id, by: updatedBy });
return vehicle;
} catch (error) {
logger.error('VehicleService.updateVehicle failed', { error, id, updatedBy });
throw error;
}
}
// =========================================================================
// STATUS MANAGEMENT
// Socket.io-ready: accepts optional `io` parameter.
// In Tier 3, pass the real Socket.IO server instance here.
// The endpoint contract is: PATCH /api/vehicles/:id/status
// =========================================================================
/**
* Updates vehicle status and optionally broadcasts a Socket.IO event.
*
* Socket.IO integration (Tier 3):
* Pass the live `io` instance from server.ts. When provided, emits:
* event: 'vehicle:statusChanged'
* payload: { vehicleId, bezeichnung, oldStatus, newStatus, bemerkung, updatedBy, timestamp }
* All connected clients on the default namespace receive the update immediately.
*
* @param io - Optional Socket.IO server instance (injected from app layer in Tier 3)
*/
async updateVehicleStatus(
id: string,
status: FahrzeugStatus,
bemerkung: string,
updatedBy: string,
io?: SocketIOServer
): Promise<void> {
try {
// Fetch old status for Socket.IO payload and logging
const oldResult = await pool.query(
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1`,
[id]
);
if (oldResult.rows.length === 0) {
throw new Error('Vehicle not found');
}
const { bezeichnung, status: oldStatus } = oldResult.rows[0];
await pool.query(
`UPDATE fahrzeuge
SET status = $1, status_bemerkung = $2
WHERE id = $3`,
[status, bemerkung || null, id]
);
logger.info('Vehicle status updated', {
id,
from: oldStatus,
to: status,
by: updatedBy,
});
// ── Socket.IO broadcast (Tier 3 integration point) ──────────────────
// When `io` is provided (Tier 3), broadcast the status change to all
// connected dashboard clients so the live status board updates in real time.
if (io) {
const payload = {
vehicleId: id,
bezeichnung,
oldStatus,
newStatus: status,
bemerkung: bemerkung || null,
updatedBy,
timestamp: new Date().toISOString(),
};
io.emit('vehicle:statusChanged', payload);
logger.debug('Emitted vehicle:statusChanged via Socket.IO', { vehicleId: id });
}
} catch (error) {
logger.error('VehicleService.updateVehicleStatus failed', { error, id });
throw error;
}
}
// =========================================================================
// INSPECTIONS
// =========================================================================
/**
* Records a new inspection entry.
* Automatically calculates naechste_faelligkeit based on standard intervals
* when durchgefuehrt_am is provided and the art has a known interval.
*/
async addPruefung(
fahrzeugId: string,
data: CreatePruefungData,
createdBy: string
): Promise<FahrzeugPruefung> {
try {
// Auto-calculate naechste_faelligkeit
let naechsteFaelligkeit: string | null = null;
if (data.durchgefuehrt_am) {
const intervalMonths = PruefungIntervalMonths[data.pruefung_art];
if (intervalMonths !== undefined) {
const durchgefuehrt = new Date(data.durchgefuehrt_am);
naechsteFaelligkeit = addMonths(durchgefuehrt, intervalMonths)
.toISOString()
.split('T')[0];
}
}
const result = await pool.query(
`INSERT INTO fahrzeug_pruefungen (
fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am,
ergebnis, naechste_faelligkeit, pruefende_stelle,
kosten, dokument_url, bemerkung, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
[
fahrzeugId,
data.pruefung_art,
data.faellig_am,
data.durchgefuehrt_am ?? null,
data.ergebnis ?? 'ausstehend',
naechsteFaelligkeit,
data.pruefende_stelle ?? null,
data.kosten ?? null,
data.dokument_url ?? null,
data.bemerkung ?? null,
createdBy,
]
);
const pruefung = result.rows[0] as FahrzeugPruefung;
logger.info('Pruefung added', {
pruefungId: pruefung.id,
fahrzeugId,
art: data.pruefung_art,
by: createdBy,
});
return pruefung;
} catch (error) {
logger.error('VehicleService.addPruefung failed', { error, fahrzeugId });
throw new Error('Failed to add inspection record');
}
}
/**
* Returns the full inspection history for a specific vehicle,
* ordered newest-first.
*/
async getPruefungenForVehicle(fahrzeugId: string): Promise<FahrzeugPruefung[]> {
try {
const result = await pool.query(
`SELECT * FROM fahrzeug_pruefungen
WHERE fahrzeug_id = $1
ORDER BY faellig_am DESC, created_at DESC`,
[fahrzeugId]
);
return result.rows 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');
}
}
// =========================================================================
// MAINTENANCE LOG
// =========================================================================
async addWartungslog(
fahrzeugId: string,
data: CreateWartungslogData,
createdBy: string
): Promise<FahrzeugWartungslog> {
try {
const result = await pool.query(
`INSERT INTO fahrzeug_wartungslog (
fahrzeug_id, datum, art, beschreibung,
km_stand, kraftstoff_liter, kosten, externe_werkstatt, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`,
[
fahrzeugId,
data.datum,
data.art ?? null,
data.beschreibung,
data.km_stand ?? null,
data.kraftstoff_liter ?? null,
data.kosten ?? null,
data.externe_werkstatt ?? null,
createdBy,
]
);
const entry = result.rows[0] as FahrzeugWartungslog;
logger.info('Wartungslog entry added', {
entryId: entry.id,
fahrzeugId,
by: createdBy,
});
return entry;
} catch (error) {
logger.error('VehicleService.addWartungslog failed', { error, fahrzeugId });
throw new Error('Failed to add maintenance log entry');
}
}
async getWartungslogForVehicle(fahrzeugId: string): Promise<FahrzeugWartungslog[]> {
try {
const result = await pool.query(
`SELECT * FROM fahrzeug_wartungslog
WHERE fahrzeug_id = $1
ORDER BY datum DESC, created_at DESC`,
[fahrzeugId]
);
return result.rows as FahrzeugWartungslog[];
} catch (error) {
logger.error('VehicleService.getWartungslogForVehicle failed', { error, fahrzeugId });
throw new Error('Failed to fetch maintenance log');
}
}
// =========================================================================
// DASHBOARD KPI
// =========================================================================
/**
* Returns aggregate counts for the dashboard stats strip.
* inspectionsDue = vehicles with at least one inspection due within 30 days
* inspectionsOverdue = vehicles with at least one inspection already overdue
*/
async getVehicleStats(): Promise<VehicleStats> {
try {
const result = await pool.query(`
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
COUNT(*) FILTER (
WHERE status IN ('ausser_dienst_wartung','ausser_dienst_schaden')
) AS ausser_dienst,
COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang
FROM fahrzeuge
`);
const alertResult = await pool.query(`
SELECT
COUNT(DISTINCT fahrzeug_id) FILTER (
WHERE (faellig_am::date - CURRENT_DATE) BETWEEN 0 AND 30
) AS inspections_due,
COUNT(DISTINCT fahrzeug_id) FILTER (
WHERE faellig_am::date < CURRENT_DATE
) AS inspections_overdue
FROM fahrzeug_pruefungen
WHERE durchgefuehrt_am IS NULL
`);
const totals = result.rows[0];
const alerts = alertResult.rows[0];
return {
total: parseInt(totals.total, 10),
einsatzbereit: parseInt(totals.einsatzbereit, 10),
ausserDienst: parseInt(totals.ausser_dienst, 10),
inLehrgang: parseInt(totals.in_lehrgang, 10),
inspectionsDue: parseInt(alerts.inspections_due, 10),
inspectionsOverdue: parseInt(alerts.inspections_overdue, 10),
};
} catch (error) {
logger.error('VehicleService.getVehicleStats failed', { error });
throw new Error('Failed to fetch vehicle stats');
}
}
}
export default new VehicleService();

View File

@@ -0,0 +1,266 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Grid,
MenuItem,
CircularProgress,
Alert,
Typography,
} from '@mui/material';
import { incidentsApi, EINSATZ_ARTEN, EINSATZ_ART_LABELS, CreateEinsatzPayload } from '../../services/incidents';
import { useNotification } from '../../contexts/NotificationContext';
interface CreateEinsatzDialogProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
// Default alarm_time = now (rounded to minute)
function nowISO(): string {
const d = new Date();
d.setSeconds(0, 0);
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
}
const INITIAL_FORM: CreateEinsatzPayload & { alarm_time_local: string } = {
alarm_time: '',
alarm_time_local: nowISO(),
einsatz_art: 'Brand',
einsatz_stichwort: '',
strasse: '',
hausnummer: '',
ort: '',
bericht_kurz: '',
alarmierung_art: 'ILS',
status: 'aktiv',
};
const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
open,
onClose,
onSuccess,
}) => {
const notification = useNotification();
const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowISO() });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!form.alarm_time_local) {
setError('Alarmzeit ist pflicht.');
return;
}
if (!form.einsatz_art) {
setError('Einsatzart ist pflicht.');
return;
}
setLoading(true);
try {
// Convert local datetime string to UTC ISO string
const payload: CreateEinsatzPayload = {
alarm_time: new Date(form.alarm_time_local).toISOString(),
einsatz_art: form.einsatz_art,
einsatz_stichwort: form.einsatz_stichwort || null,
strasse: form.strasse || null,
hausnummer: form.hausnummer || null,
ort: form.ort || null,
bericht_kurz: form.bericht_kurz || null,
alarmierung_art: form.alarmierung_art || 'ILS',
status: form.status || 'aktiv',
};
await incidentsApi.create(payload);
notification.showSuccess('Einsatz erfolgreich angelegt');
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
onSuccess();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Fehler beim Anlegen des Einsatzes';
setError(msg);
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (loading) return;
setError(null);
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
onClose();
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
PaperProps={{ component: 'form', onSubmit: handleSubmit }}
>
<DialogTitle>
<Typography variant="h6" component="div">
Neuen Einsatz anlegen
</Typography>
</DialogTitle>
<DialogContent dividers>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Grid container spacing={2}>
{/* Alarmzeit — most important field */}
<Grid item xs={12} sm={7}>
<TextField
label="Alarmzeit *"
name="alarm_time_local"
type="datetime-local"
value={form.alarm_time_local}
onChange={handleChange}
InputLabelProps={{ shrink: true }}
fullWidth
required
helperText="DD.MM.YYYY HH:mm"
inputProps={{
'aria-label': 'Alarmzeit',
// HTML datetime-local uses YYYY-MM-DDTHH:mm format
}}
/>
</Grid>
{/* Einsatzart */}
<Grid item xs={12} sm={5}>
<TextField
label="Einsatzart *"
name="einsatz_art"
select
value={form.einsatz_art}
onChange={handleChange}
fullWidth
required
>
{EINSATZ_ARTEN.map((art) => (
<MenuItem key={art} value={art}>
{EINSATZ_ART_LABELS[art]}
</MenuItem>
))}
</TextField>
</Grid>
{/* Stichwort */}
<Grid item xs={12} sm={5}>
<TextField
label="Einsatzstichwort"
name="einsatz_stichwort"
value={form.einsatz_stichwort ?? ''}
onChange={handleChange}
fullWidth
placeholder="z.B. B2, THL 1"
inputProps={{ maxLength: 30 }}
/>
</Grid>
{/* Alarmierungsart */}
<Grid item xs={12} sm={7}>
<TextField
label="Alarmierungsart"
name="alarmierung_art"
select
value={form.alarmierung_art}
onChange={handleChange}
fullWidth
>
{['ILS', 'DME', 'Telefon', 'Vor_Ort', 'Sonstiges'].map((a) => (
<MenuItem key={a} value={a}>
{a === 'Vor_Ort' ? 'Vor Ort' : a}
</MenuItem>
))}
</TextField>
</Grid>
{/* Location */}
<Grid item xs={12} sm={8}>
<TextField
label="Straße"
name="strasse"
value={form.strasse ?? ''}
onChange={handleChange}
fullWidth
inputProps={{ maxLength: 150 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Hausnr."
name="hausnummer"
value={form.hausnummer ?? ''}
onChange={handleChange}
fullWidth
inputProps={{ maxLength: 20 }}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Ort"
name="ort"
value={form.ort ?? ''}
onChange={handleChange}
fullWidth
inputProps={{ maxLength: 100 }}
/>
</Grid>
{/* Short description */}
<Grid item xs={12}>
<TextField
label="Kurzbeschreibung"
name="bericht_kurz"
value={form.bericht_kurz ?? ''}
onChange={handleChange}
fullWidth
multiline
rows={2}
inputProps={{ maxLength: 255 }}
helperText={`${(form.bericht_kurz ?? '').length}/255`}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={handleClose} disabled={loading}>
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={loading}
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{loading ? 'Speichere...' : 'Einsatz anlegen'}
</Button>
</DialogActions>
</Dialog>
);
};
export default CreateEinsatzDialog;

View File

@@ -0,0 +1,258 @@
import React from 'react';
import {
Box,
Card,
CardContent,
Typography,
useTheme,
Skeleton,
Grid,
} from '@mui/material';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
PieChart,
Pie,
Cell,
ResponsiveContainer,
} from 'recharts';
import { EinsatzStats, EINSATZ_ART_LABELS, EinsatzArt } from '../../services/incidents';
// ---------------------------------------------------------------------------
// MONTH LABELS (German locale — avoids date-fns dependency for short labels)
// ---------------------------------------------------------------------------
const MONAT_KURZ = [
'', // 1-indexed
'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez',
];
// ---------------------------------------------------------------------------
// PIE CHART COLORS — keyed by EinsatzArt
// Uses MUI theme palette where possible, fallback to a curated palette
// ---------------------------------------------------------------------------
const ART_COLORS: Record<EinsatzArt, string> = {
Brand: '#d32f2f',
THL: '#1976d2',
ABC: '#7b1fa2',
BMA: '#f57c00',
Hilfeleistung: '#388e3c',
Fehlalarm: '#757575',
Brandsicherheitswache: '#0288d1',
};
// ---------------------------------------------------------------------------
// HELPERS
// ---------------------------------------------------------------------------
/** Build a full 12-month array from sparse MonthlyStatRow data */
function buildMonthlyData(
thisYear: { monat: number; anzahl: number }[],
prevYear: { monat: number; anzahl: number }[],
currentYear: number
) {
const map = new Map<number, { thisYear: number; prevYear: number }>();
for (let m = 1; m <= 12; m++) {
map.set(m, { thisYear: 0, prevYear: 0 });
}
for (const row of thisYear) {
const existing = map.get(row.monat)!;
map.set(row.monat, { ...existing, thisYear: row.anzahl });
}
for (const row of prevYear) {
const existing = map.get(row.monat)!;
map.set(row.monat, { ...existing, prevYear: row.anzahl });
}
return Array.from(map.entries()).map(([monat, counts]) => ({
monat: MONAT_KURZ[monat],
[String(currentYear)]: counts.thisYear,
[String(currentYear - 1)]: counts.prevYear,
}));
}
// ---------------------------------------------------------------------------
// COMPONENT
// ---------------------------------------------------------------------------
interface IncidentStatsChartProps {
stats: EinsatzStats | null;
loading?: boolean;
}
const IncidentStatsChart: React.FC<IncidentStatsChartProps> = ({ stats, loading = false }) => {
const theme = useTheme();
if (loading || !stats) {
return (
<Grid container spacing={3}>
<Grid item xs={12} md={7}>
<Card>
<CardContent>
<Skeleton variant="text" width={200} height={28} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" height={260} />
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={5}>
<Card>
<CardContent>
<Skeleton variant="text" width={160} height={28} sx={{ mb: 2 }} />
<Skeleton variant="circular" width={220} height={220} sx={{ mx: 'auto' }} />
</CardContent>
</Card>
</Grid>
</Grid>
);
}
const monthlyData = buildMonthlyData(
stats.monthly,
stats.prev_year_monthly,
stats.jahr
);
const pieData = stats.by_art.map((row) => ({
name: EINSATZ_ART_LABELS[row.einsatz_art],
value: row.anzahl,
art: row.einsatz_art,
}));
const thisYearKey = String(stats.jahr);
const prevYearKey = String(stats.jahr - 1);
return (
<Grid container spacing={3}>
{/* BAR CHART: Incidents per month (year-over-year) */}
<Grid item xs={12} md={7}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Einsätze pro Monat {stats.jahr} vs. {stats.jahr - 1}
</Typography>
<Box sx={{ width: '100%', height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={monthlyData}
margin={{ top: 4, right: 16, left: -8, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke={theme.palette.divider}
/>
<XAxis
dataKey="monat"
tick={{ fontSize: 12, fill: theme.palette.text.secondary }}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 12, fill: theme.palette.text.secondary }}
/>
<Tooltip
contentStyle={{
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend
wrapperStyle={{ fontSize: 13 }}
/>
<Bar
dataKey={thisYearKey}
name={thisYearKey}
fill={theme.palette.primary.main}
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey={prevYearKey}
name={prevYearKey}
fill={theme.palette.secondary.light}
radius={[4, 4, 0, 0]}
opacity={0.7}
/>
</BarChart>
</ResponsiveContainer>
</Box>
</CardContent>
</Card>
</Grid>
{/* PIE CHART: Incidents by Einsatzart */}
<Grid item xs={12} md={5}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Einsatzarten {stats.jahr}
</Typography>
{pieData.length === 0 ? (
<Box
sx={{
height: 260,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="body2" color="text.secondary">
Keine Daten für dieses Jahr
</Typography>
</Box>
) : (
<Box sx={{ width: '100%', height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="45%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
label={({ name, percent }) =>
percent > 0.05 ? `${(percent * 100).toFixed(0)}%` : ''
}
labelLine={false}
>
{pieData.map((entry) => (
<Cell
key={entry.art}
fill={ART_COLORS[entry.art as EinsatzArt] ?? theme.palette.grey[500]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [
`${value} Einsätze`,
name,
]}
contentStyle={{
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend
iconType="circle"
iconSize={10}
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
/>
</PieChart>
</ResponsiveContainer>
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
);
};
export default IncidentStatsChart;

View File

@@ -0,0 +1,247 @@
import { useMemo } from 'react';
import {
Box,
List,
ListItem,
ListItemText,
Chip,
Typography,
Button,
Skeleton,
Tooltip,
} from '@mui/material';
import {
CheckCircle as CheckIcon,
Cancel as CancelIcon,
HelpOutline as UnknownIcon,
Star as StarIcon,
CalendarMonth as CalendarIcon,
ArrowForward as ArrowIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { trainingApi } from '../../services/training';
import type { UebungListItem, TeilnahmeStatus, UebungTyp } from '../../types/training.types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TYP_COLORS: Record<UebungTyp, 'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success'> = {
'Übungsabend': 'primary',
'Lehrgang': 'secondary',
'Sonderdienst': 'warning',
'Versammlung': 'default',
'Gemeinschaftsübung': 'info',
'Sonstiges': 'default',
};
const WEEKDAY_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
function formatEventDate(isoString: string): string {
const d = new Date(isoString);
return `${WEEKDAY_SHORT[d.getDay()]}, ${String(d.getDate()).padStart(2, '0')}. ${MONTH_SHORT[d.getMonth()]}`;
}
function formatEventTime(vonIso: string, bisIso: string): string {
const von = new Date(vonIso);
const bis = new Date(bisIso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(von.getHours())}:${pad(von.getMinutes())} ${pad(bis.getHours())}:${pad(bis.getMinutes())} Uhr`;
}
// ---------------------------------------------------------------------------
// RSVP Status badge
// ---------------------------------------------------------------------------
function RsvpBadge({ status }: { status: TeilnahmeStatus | undefined }) {
if (!status || status === 'unbekannt') {
return (
<Tooltip title="Noch keine Rückmeldung">
<UnknownIcon sx={{ color: 'text.disabled', fontSize: 20 }} />
</Tooltip>
);
}
if (status === 'zugesagt') {
return (
<Tooltip title="Zugesagt">
<CheckIcon sx={{ color: 'success.main', fontSize: 20 }} />
</Tooltip>
);
}
if (status === 'erschienen') {
return (
<Tooltip title="Erschienen">
<CheckIcon sx={{ color: 'success.dark', fontSize: 20 }} />
</Tooltip>
);
}
if (status === 'abgesagt' || status === 'entschuldigt') {
return (
<Tooltip title={status === 'entschuldigt' ? 'Entschuldigt' : 'Abgesagt'}>
<CancelIcon sx={{ color: 'error.main', fontSize: 20 }} />
</Tooltip>
);
}
return null;
}
// ---------------------------------------------------------------------------
// Single event row
// ---------------------------------------------------------------------------
function EventRow({ event }: { event: UebungListItem }) {
const navigate = useNavigate();
return (
<ListItem
disablePadding
onClick={() => navigate(`/training/${event.id}`)}
sx={{
cursor: 'pointer',
borderRadius: 1,
mb: 0.5,
px: 1,
py: 0.75,
transition: 'background 0.15s',
'&:hover': { backgroundColor: 'action.hover' },
opacity: event.abgesagt ? 0.55 : 1,
}}
>
{/* Date column */}
<Box
sx={{
minWidth: 72,
mr: 1.5,
textAlign: 'center',
flexShrink: 0,
}}
>
<Typography
variant="caption"
sx={{
display: 'block',
color: 'text.secondary',
lineHeight: 1.2,
fontSize: '0.7rem',
}}
>
{formatEventDate(event.datum_von)}
</Typography>
<Typography
variant="caption"
sx={{
display: 'block',
color: 'text.disabled',
fontSize: '0.65rem',
}}
>
{formatEventTime(event.datum_von, event.datum_bis)}
</Typography>
</Box>
{/* Title + chip */}
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
{event.pflichtveranstaltung && (
<StarIcon sx={{ fontSize: 14, color: 'warning.main', flexShrink: 0 }} />
)}
<Typography
variant="body2"
sx={{
fontWeight: event.pflichtveranstaltung ? 700 : 400,
textDecoration: event.abgesagt ? 'line-through' : 'none',
lineHeight: 1.3,
}}
>
{event.titel}
</Typography>
</Box>
}
secondary={
<Chip
label={event.typ}
size="small"
color={TYP_COLORS[event.typ]}
variant="outlined"
sx={{ fontSize: '0.65rem', height: 18, mt: 0.25 }}
/>
}
sx={{ my: 0 }}
/>
{/* RSVP status */}
<Box sx={{ ml: 1, flexShrink: 0 }}>
<RsvpBadge status={event.eigener_status} />
</Box>
</ListItem>
);
}
// ---------------------------------------------------------------------------
// Main widget component
// ---------------------------------------------------------------------------
export default function UpcomingEvents() {
const navigate = useNavigate();
const { data, isLoading, isError } = useQuery({
queryKey: ['training', 'upcoming', 3],
queryFn: () => trainingApi.getUpcoming(3),
staleTime: 5 * 60 * 1000, // 5 min
});
const events = useMemo(() => data ?? [], [data]);
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1, gap: 1 }}>
<CalendarIcon color="primary" fontSize="small" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Nächste Dienste
</Typography>
</Box>
{isLoading && (
<Box>
{[1, 2, 3].map((n) => (
<Skeleton key={n} variant="rectangular" height={56} sx={{ borderRadius: 1, mb: 0.5 }} />
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Dienste konnten nicht geladen werden.
</Typography>
)}
{!isLoading && !isError && events.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine bevorstehenden Veranstaltungen.
</Typography>
)}
{!isLoading && !isError && events.length > 0 && (
<List dense disablePadding>
{events.map((event) => (
<EventRow key={event.id} event={event} />
))}
</List>
)}
<Box sx={{ mt: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button
size="small"
endIcon={<ArrowIcon />}
onClick={() => navigate('/kalender')}
sx={{ textTransform: 'none' }}
>
Zum Kalender
</Button>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react';
import {
Alert,
AlertTitle,
Box,
CircularProgress,
Collapse,
Link,
Typography,
} from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { vehiclesApi } from '../../services/vehicles';
import { InspectionAlert, PruefungArtLabel, PruefungArt } from '../../types/vehicle.types';
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
type Urgency = 'overdue' | 'urgent' | 'warning';
function getUrgency(tage: number): Urgency {
if (tage < 0) return 'overdue';
if (tage <= 14) return 'urgent';
return 'warning';
}
const URGENCY_CONFIG: Record<Urgency, { severity: 'error' | 'warning'; label: string }> = {
overdue: { severity: 'error', label: 'Überfällig' },
urgent: { severity: 'error', label: 'Dringend (≤ 14 Tage)' },
warning: { severity: 'warning', label: 'Fällig in Kürze (≤ 30 Tage)' },
};
// ── Component ─────────────────────────────────────────────────────────────────
interface InspectionAlertsProps {
/** How many days ahead to fetch — default 30 */
daysAhead?: number;
/** Collapse into a single banner if no alerts */
hideWhenEmpty?: boolean;
}
const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
daysAhead = 30,
hideWhenEmpty = true,
}) => {
const [alerts, setAlerts] = useState<InspectionAlert[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const fetchAlerts = async () => {
try {
setLoading(true);
setError(null);
const data = await vehiclesApi.getAlerts(daysAhead);
if (mounted) setAlerts(data);
} catch {
if (mounted) setError('Prüfungshinweise konnten nicht geladen werden.');
} finally {
if (mounted) setLoading(false);
}
};
fetchAlerts();
return () => { mounted = false; };
}, [daysAhead]);
if (loading) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Prüfungsfristen werden geprüft
</Typography>
</Box>
);
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
if (alerts.length === 0) {
if (hideWhenEmpty) return null;
return (
<Alert severity="success">
Alle Prüfungsfristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen.
</Alert>
);
}
// Group by urgency
const overdue = alerts.filter((a) => a.tage < 0);
const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14);
const warning = alerts.filter((a) => a.tage > 14);
const groups: Array<{ urgency: Urgency; items: InspectionAlert[] }> = [
{ urgency: 'overdue', items: overdue },
{ urgency: 'urgent', items: urgent },
{ urgency: 'warning', items: warning },
].filter((g) => g.items.length > 0);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{groups.map(({ urgency, items }) => {
const { severity, label } = URGENCY_CONFIG[urgency];
return (
<Alert key={urgency} severity={severity} variant="outlined">
<AlertTitle sx={{ fontWeight: 600 }}>{label}</AlertTitle>
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{items.map((alert) => {
const artLabel = PruefungArtLabel[alert.pruefungArt as PruefungArt] ?? alert.pruefungArt;
const dateStr = formatDate(alert.faelligAm);
const tageText = alert.tage < 0
? `seit ${Math.abs(alert.tage)} Tag${Math.abs(alert.tage) === 1 ? '' : 'en'} überfällig`
: alert.tage === 0
? 'heute fällig'
: `fällig in ${alert.tage} Tag${alert.tage === 1 ? '' : 'en'}`;
return (
<Collapse key={alert.pruefungId} in timeout="auto">
<Box component="li" sx={{ mb: 0.5 }}>
<Link
component={RouterLink}
to={`/fahrzeuge/${alert.fahrzeugId}`}
color="inherit"
underline="hover"
sx={{ fontWeight: 500 }}
>
{alert.bezeichnung}
{alert.kurzname ? ` (${alert.kurzname})` : ''}
</Link>
{' — '}
<strong>{artLabel}</strong>
{' '}
<Typography component="span" variant="body2">
{tageText} ({dateStr})
</Typography>
</Box>
</Collapse>
);
})}
</Box>
</Alert>
);
})}
</Box>
);
};
export default InspectionAlerts;

View File

@@ -0,0 +1,643 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
Container,
Typography,
Button,
Chip,
Card,
CardContent,
Grid,
Divider,
Avatar,
Skeleton,
Alert,
Stack,
Tooltip,
Paper,
TextField,
IconButton,
} from '@mui/material';
import {
ArrowBack,
Edit,
Save,
Cancel,
LocalFireDepartment,
AccessTime,
DirectionsCar,
People,
LocationOn,
Description,
PictureAsPdf,
} from '@mui/icons-material';
import { format, parseISO, differenceInMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import {
incidentsApi,
EinsatzDetail,
EinsatzStatus,
EINSATZ_ART_LABELS,
EINSATZ_STATUS_LABELS,
EinsatzArt,
} from '../services/incidents';
import { useNotification } from '../contexts/NotificationContext';
// ---------------------------------------------------------------------------
// COLOUR MAPS
// ---------------------------------------------------------------------------
const ART_CHIP_COLOR: Record<
EinsatzArt,
'error' | 'primary' | 'secondary' | 'warning' | 'success' | 'default' | 'info'
> = {
Brand: 'error',
THL: 'primary',
ABC: 'secondary',
BMA: 'warning',
Hilfeleistung: 'success',
Fehlalarm: 'default',
Brandsicherheitswache: 'info',
};
const STATUS_CHIP_COLOR: Record<EinsatzStatus, 'warning' | 'success' | 'default'> = {
aktiv: 'warning',
abgeschlossen: 'success',
archiviert: 'default',
};
// ---------------------------------------------------------------------------
// HELPERS
// ---------------------------------------------------------------------------
function formatDE(iso: string | null | undefined, fmt = 'dd.MM.yyyy HH:mm'): string {
if (!iso) return '—';
try {
return format(parseISO(iso), fmt, { locale: de });
} catch {
return iso;
}
}
function minuteDiff(start: string | null, end: string | null): string {
if (!start || !end) return '—';
try {
const mins = differenceInMinutes(parseISO(end), parseISO(start));
if (mins < 0) return '—';
if (mins < 60) return `${mins} min`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return m === 0 ? `${h} h` : `${h} h ${m} min`;
} catch {
return '—';
}
}
function initials(givenName: string | null, familyName: string | null, name: string | null): string {
if (givenName && familyName) return `${givenName[0]}${familyName[0]}`.toUpperCase();
if (name) return name.slice(0, 2).toUpperCase();
return '??';
}
function displayName(p: EinsatzDetail['personal'][0]): string {
if (p.given_name && p.family_name) return `${p.given_name} ${p.family_name}`;
if (p.name) return p.name;
return p.email;
}
// ---------------------------------------------------------------------------
// TIMELINE STEP
// ---------------------------------------------------------------------------
interface TimelineStepProps {
label: string;
time: string | null;
duration?: string;
isFirst?: boolean;
}
const TimelineStep: React.FC<TimelineStepProps> = ({ label, time, duration, isFirst }) => (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 24 }}>
<Box
sx={{
width: 20,
height: 20,
borderRadius: '50%',
bgcolor: time ? 'primary.main' : 'action.disabled',
border: '2px solid',
borderColor: time ? 'primary.main' : 'action.disabled',
flexShrink: 0,
mt: 0.5,
}}
/>
{!isFirst && (
<Box
sx={{
width: 2,
flexGrow: 1,
minHeight: 32,
bgcolor: time ? 'primary.light' : 'action.disabled',
my: 0.25,
}}
/>
)}
</Box>
<Box sx={{ pb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
{label}
</Typography>
<Typography variant="body1" fontWeight={time ? 600 : 400} color={time ? 'text.primary' : 'text.disabled'}>
{time ? formatDE(time) : 'Nicht erfasst'}
</Typography>
{duration && (
<Typography variant="caption" color="primary.main" sx={{ fontWeight: 500 }}>
+{duration}
</Typography>
)}
</Box>
</Box>
);
// ---------------------------------------------------------------------------
// MAIN PAGE
// ---------------------------------------------------------------------------
function EinsatzDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const notification = useNotification();
const [einsatz, setEinsatz] = useState<EinsatzDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Edit mode for bericht fields
const [editing, setEditing] = useState(false);
const [berichtKurz, setBerichtKurz] = useState('');
const [berichtText, setBerichtText] = useState('');
const [saving, setSaving] = useState(false);
// -------------------------------------------------------------------------
// FETCH
// -------------------------------------------------------------------------
const fetchEinsatz = useCallback(async () => {
if (!id) return;
setLoading(true);
setError(null);
try {
const data = await incidentsApi.getById(id);
setEinsatz(data);
setBerichtKurz(data.bericht_kurz ?? '');
setBerichtText(data.bericht_text ?? '');
} catch (err) {
setError('Einsatz konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
fetchEinsatz();
}, [fetchEinsatz]);
// -------------------------------------------------------------------------
// SAVE BERICHT
// -------------------------------------------------------------------------
const handleSaveBericht = async () => {
if (!id) return;
setSaving(true);
try {
await incidentsApi.update(id, {
bericht_kurz: berichtKurz || null,
bericht_text: berichtText || null,
});
notification.showSuccess('Einsatzbericht gespeichert');
setEditing(false);
fetchEinsatz();
} catch (err) {
notification.showError('Fehler beim Speichern des Berichts');
} finally {
setSaving(false);
}
};
const handleCancelEdit = () => {
setEditing(false);
setBerichtKurz(einsatz?.bericht_kurz ?? '');
setBerichtText(einsatz?.bericht_text ?? '');
};
// -------------------------------------------------------------------------
// PDF EXPORT (placeholder)
// -------------------------------------------------------------------------
const handleExportPdf = () => {
notification.showInfo('PDF-Export wird in Kürze verfügbar sein.');
};
// -------------------------------------------------------------------------
// LOADING STATE
// -------------------------------------------------------------------------
if (loading) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Skeleton width={120} height={36} sx={{ mb: 2 }} />
<Skeleton width={300} height={48} sx={{ mb: 3 }} />
<Grid container spacing={3}>
{[0, 1, 2].map((i) => (
<Grid item xs={12} md={4} key={i}>
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2 }} />
</Grid>
))}
</Grid>
</Container>
</DashboardLayout>
);
}
if (error || !einsatz) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/einsaetze')}
sx={{ mb: 3 }}
>
Zurück zur Übersicht
</Button>
<Alert severity="error">{error ?? 'Einsatz nicht gefunden.'}</Alert>
</Container>
</DashboardLayout>
);
}
const address = [einsatz.strasse, einsatz.hausnummer, einsatz.ort]
.filter(Boolean)
.join(' ');
return (
<DashboardLayout>
<Container maxWidth="lg">
{/* Back + Actions */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/einsaetze')}
variant="text"
>
Zurück
</Button>
<Stack direction="row" spacing={1}>
<Tooltip title="PDF exportieren (Vorschau)">
<Button
variant="outlined"
startIcon={<PictureAsPdf />}
onClick={handleExportPdf}
size="small"
>
PDF Export
</Button>
</Tooltip>
{!editing ? (
<Button
variant="contained"
startIcon={<Edit />}
onClick={() => setEditing(true)}
size="small"
>
Bearbeiten
</Button>
) : (
<>
<Button
variant="outlined"
startIcon={<Cancel />}
onClick={handleCancelEdit}
size="small"
disabled={saving}
>
Abbrechen
</Button>
<Button
variant="contained"
color="success"
startIcon={<Save />}
onClick={handleSaveBericht}
size="small"
disabled={saving}
>
{saving ? 'Speichere...' : 'Speichern'}
</Button>
</>
)}
</Stack>
</Box>
{/* HEADER */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 1 }}>
<Chip
icon={<LocalFireDepartment />}
label={EINSATZ_ART_LABELS[einsatz.einsatz_art]}
color={ART_CHIP_COLOR[einsatz.einsatz_art]}
sx={{ fontWeight: 600 }}
/>
<Chip
label={EINSATZ_STATUS_LABELS[einsatz.status]}
color={STATUS_CHIP_COLOR[einsatz.status]}
variant="outlined"
size="small"
/>
{einsatz.einsatz_stichwort && (
<Typography variant="h6" color="text.secondary">
{einsatz.einsatz_stichwort}
</Typography>
)}
</Box>
<Typography variant="h4" fontWeight={700}>
Einsatz {einsatz.einsatz_nr}
</Typography>
{address && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<LocationOn fontSize="small" color="action" />
<Typography variant="body1" color="text.secondary">
{address}
</Typography>
</Box>
)}
</Box>
<Grid container spacing={3}>
{/* LEFT COLUMN: Timeline + Vehicles */}
<Grid item xs={12} md={4}>
{/* Timeline card */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AccessTime color="primary" />
<Typography variant="h6">Zeitlinie</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{/* Reversed order: last step first (top = Alarm) */}
<TimelineStep
label="Alarmzeit"
time={einsatz.alarm_time}
/>
<TimelineStep
label="Ausrückzeit"
time={einsatz.ausrueck_time}
duration={minuteDiff(einsatz.alarm_time, einsatz.ausrueck_time)}
/>
<TimelineStep
label="Ankunftszeit (Hilfsfrist)"
time={einsatz.ankunft_time}
duration={minuteDiff(einsatz.alarm_time, einsatz.ankunft_time)}
/>
<TimelineStep
isFirst
label="Einrückzeit"
time={einsatz.einrueck_time}
duration={minuteDiff(einsatz.alarm_time, einsatz.einrueck_time)}
/>
</Box>
{(einsatz.hilfsfrist_min !== null || einsatz.dauer_min !== null) && (
<>
<Divider sx={{ my: 2 }} />
<Grid container spacing={1}>
{einsatz.hilfsfrist_min !== null && (
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary" display="block">
Hilfsfrist
</Typography>
<Typography variant="body1" fontWeight={700} color={einsatz.hilfsfrist_min > 10 ? 'error.main' : 'success.main'}>
{einsatz.hilfsfrist_min} min
</Typography>
</Grid>
)}
{einsatz.dauer_min !== null && (
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary" display="block">
Gesamtdauer
</Typography>
<Typography variant="body1" fontWeight={700}>
{einsatz.dauer_min < 60
? `${einsatz.dauer_min} min`
: `${Math.floor(einsatz.dauer_min / 60)} h ${einsatz.dauer_min % 60} min`}
</Typography>
</Grid>
)}
</Grid>
</>
)}
</CardContent>
</Card>
{/* Vehicles card */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<DirectionsCar color="primary" />
<Typography variant="h6">Fahrzeuge</Typography>
<Chip
label={einsatz.fahrzeuge.length}
size="small"
color="primary"
sx={{ ml: 'auto' }}
/>
</Box>
{einsatz.fahrzeuge.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Keine Fahrzeuge zugewiesen
</Typography>
) : (
<Stack spacing={1}>
{einsatz.fahrzeuge.map((f) => (
<Paper
key={f.fahrzeug_id}
variant="outlined"
sx={{ p: 1.25, borderRadius: 2 }}
>
<Typography variant="subtitle2" fontWeight={600}>
{f.bezeichnung}
</Typography>
<Typography variant="caption" color="text.secondary">
{f.kennzeichen}
{f.fahrzeug_typ ? ` · ${f.fahrzeug_typ}` : ''}
</Typography>
{(f.ausrueck_time || f.einrueck_time) && (
<Typography variant="caption" color="text.secondary" display="block">
{formatDE(f.ausrueck_time, 'HH:mm')} {formatDE(f.einrueck_time, 'HH:mm')}
</Typography>
)}
</Paper>
))}
</Stack>
)}
</CardContent>
</Card>
</Grid>
{/* RIGHT COLUMN: Personnel + Bericht */}
<Grid item xs={12} md={8}>
{/* Personnel card */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<People color="primary" />
<Typography variant="h6">Einsatzkräfte</Typography>
<Chip
label={einsatz.personal.length}
size="small"
color="primary"
sx={{ ml: 'auto' }}
/>
</Box>
{einsatz.einsatzleiter_name && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
Einsatzleiter
</Typography>
<Typography variant="body1" fontWeight={600}>
{einsatz.einsatzleiter_name}
</Typography>
</Box>
)}
{einsatz.personal.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Keine Einsatzkräfte zugewiesen
</Typography>
) : (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
{einsatz.personal.map((p) => (
<Box
key={p.user_id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
minWidth: 200,
maxWidth: 260,
flex: '1 1 200px',
}}
>
<Avatar
sx={{ width: 36, height: 36, bgcolor: 'primary.main', fontSize: '0.8rem' }}
>
{initials(p.given_name, p.family_name, p.name)}
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="subtitle2"
noWrap
title={displayName(p)}
>
{displayName(p)}
</Typography>
<Chip
label={p.funktion}
size="small"
variant="outlined"
sx={{ fontSize: '0.68rem', height: 18 }}
/>
</Box>
</Box>
))}
</Box>
)}
</CardContent>
</Card>
{/* Bericht card */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Description color="primary" />
<Typography variant="h6">Einsatzbericht</Typography>
</Box>
{editing ? (
<Stack spacing={2}>
<TextField
label="Kurzbeschreibung"
value={berichtKurz}
onChange={(e) => setBerichtKurz(e.target.value)}
fullWidth
multiline
rows={2}
inputProps={{ maxLength: 255 }}
helperText={`${berichtKurz.length}/255`}
/>
<TextField
label="Ausführlicher Bericht"
value={berichtText}
onChange={(e) => setBerichtText(e.target.value)}
fullWidth
multiline
rows={8}
placeholder="Detaillierter Einsatzbericht..."
helperText="Nur für Kommandant und Admin sichtbar"
/>
</Stack>
) : (
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
Kurzbeschreibung
</Typography>
<Typography variant="body1">
{einsatz.bericht_kurz ?? (
<Typography component="span" color="text.disabled" fontStyle="italic">
Keine Kurzbeschreibung erfasst
</Typography>
)}
</Typography>
</Box>
{einsatz.bericht_text !== undefined && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
Ausführlicher Bericht
</Typography>
{einsatz.bericht_text ? (
<Typography
variant="body2"
sx={{ whiteSpace: 'pre-wrap', mt: 0.5, lineHeight: 1.7 }}
>
{einsatz.bericht_text}
</Typography>
) : (
<Typography variant="body2" color="text.disabled" fontStyle="italic">
Kein Bericht erfasst
</Typography>
)}
</Box>
)}
</Stack>
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Footer meta */}
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="caption" color="text.disabled">
Angelegt: {formatDE(einsatz.created_at, 'dd.MM.yyyy HH:mm')}
{' · '}
Zuletzt geändert: {formatDE(einsatz.updated_at, 'dd.MM.yyyy HH:mm')}
{' · '}
Alarmierung via {einsatz.alarmierung_art}
</Typography>
</Box>
</Container>
</DashboardLayout>
);
}
export default EinsatzDetail;

View File

@@ -0,0 +1,898 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Fab,
FormControl,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Tab,
Tabs,
TextField,
Timeline,
TimelineConnector,
TimelineContent,
TimelineDot,
TimelineItem,
TimelineOppositeContent,
TimelineSeparator,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
ArrowBack,
Assignment,
Build,
CheckCircle,
DirectionsCar,
Error as ErrorIcon,
LocalFireDepartment,
PauseCircle,
ReportProblem,
School,
Warning,
} from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import {
FahrzeugDetail,
FahrzeugPruefung,
FahrzeugWartungslog,
FahrzeugStatus,
FahrzeugStatusLabel,
PruefungArt,
PruefungArtLabel,
CreatePruefungPayload,
CreateWartungslogPayload,
WartungslogArt,
PruefungErgebnis,
} from '../types/vehicle.types';
// ── Tab Panel ─────────────────────────────────────────────────────────────────
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
<div role="tabpanel" hidden={value !== index}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
// ── Status config ─────────────────────────────────────────────────────────────
const STATUS_ICONS: Record<FahrzeugStatus, React.ReactElement> = {
[FahrzeugStatus.Einsatzbereit]: <CheckCircle color="success" />,
[FahrzeugStatus.AusserDienstWartung]: <PauseCircle color="warning" />,
[FahrzeugStatus.AusserDienstSchaden]: <ErrorIcon color="error" />,
[FahrzeugStatus.InLehrgang]: <School color="info" />,
};
const STATUS_CHIP_COLOR: Record<FahrzeugStatus, 'success' | 'warning' | 'error' | 'info'> = {
[FahrzeugStatus.Einsatzbereit]: 'success',
[FahrzeugStatus.AusserDienstWartung]: 'warning',
[FahrzeugStatus.AusserDienstSchaden]: 'error',
[FahrzeugStatus.InLehrgang]: 'info',
};
// ── Date helpers ──────────────────────────────────────────────────────────────
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
});
}
function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'error' | 'default' {
if (tage === null) return 'default';
if (tage < 0) return 'error';
if (tage <= 30) return 'warning';
return 'success';
}
// ── Übersicht Tab ─────────────────────────────────────────────────────────────
interface UebersichtTabProps {
vehicle: FahrzeugDetail;
onStatusUpdated: () => void;
}
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const handleSaveStatus = async () => {
try {
setSaving(true);
setSaveError(null);
await vehiclesApi.updateStatus(vehicle.id, { status: newStatus, bemerkung });
setStatusDialogOpen(false);
onStatusUpdated();
} catch {
setSaveError('Status konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden;
return (
<Box>
{isSchaden && (
<Alert severity="error" icon={<ReportProblem />} sx={{ mb: 2 }}>
<strong>Schaden gemeldet</strong> dieses Fahrzeug ist nicht einsatzbereit.
{vehicle.status_bemerkung && ` Bemerkung: ${vehicle.status_bemerkung}`}
</Alert>
)}
{/* Status panel */}
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{STATUS_ICONS[vehicle.status]}
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Aktueller Status
</Typography>
<Chip
label={FahrzeugStatusLabel[vehicle.status]}
color={STATUS_CHIP_COLOR[vehicle.status]}
size="small"
/>
{vehicle.status_bemerkung && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{vehicle.status_bemerkung}
</Typography>
)}
</Box>
</Box>
<Button
variant="outlined"
size="small"
onClick={() => {
setNewStatus(vehicle.status);
setBemerkung(vehicle.status_bemerkung ?? '');
setStatusDialogOpen(true);
}}
>
Status ändern
</Button>
</Box>
</Paper>
{/* Vehicle data grid */}
<Grid container spacing={2}>
{[
{ label: 'Bezeichnung', value: vehicle.bezeichnung },
{ label: 'Kurzname', value: vehicle.kurzname },
{ label: 'Kennzeichen', value: vehicle.amtliches_kennzeichen },
{ label: 'Fahrgestellnr.', value: vehicle.fahrgestellnummer },
{ label: 'Baujahr', value: vehicle.baujahr?.toString() },
{ label: 'Hersteller', value: vehicle.hersteller },
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
{ label: 'Standort', value: vehicle.standort },
].map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
{label}
</Typography>
<Typography variant="body1">{value ?? '—'}</Typography>
</Grid>
))}
</Grid>
{/* Inspection status quick view */}
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
Prüffristen Übersicht
</Typography>
<Grid container spacing={1.5}>
{Object.entries(vehicle.pruefstatus).map(([key, ps]) => {
const art = key.toUpperCase() as PruefungArt;
const label = PruefungArtLabel[art] ?? key;
const color = inspectionBadgeColor(ps.tage_bis_faelligkeit);
return (
<Grid item xs={12} sm={6} md={3} key={key}>
<Paper variant="outlined" sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
{label}
</Typography>
{ps.faellig_am ? (
<>
<Chip
size="small"
color={color}
label={
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
? `ÜBERFÄLLIG (${fmtDate(ps.faellig_am)})`
: `Fällig: ${fmtDate(ps.faellig_am)}`
}
icon={
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
? <Warning fontSize="small" />
: undefined
}
sx={{ mt: 0.5 }}
/>
{ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit >= 0 && (
<Typography variant="caption" display="block" color="text.secondary">
in {ps.tage_bis_faelligkeit} Tagen
</Typography>
)}
</>
) : (
<Typography variant="body2" color="text.disabled">
Keine Daten
</Typography>
)}
</Paper>
</Grid>
);
})}
</Grid>
{/* Status change dialog */}
<Dialog
open={statusDialogOpen}
onClose={() => setStatusDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Fahrzeugstatus ändern</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<FormControl fullWidth sx={{ mb: 2, mt: 1 }}>
<InputLabel id="status-select-label">Neuer Status</InputLabel>
<Select
labelId="status-select-label"
label="Neuer Status"
value={newStatus}
onChange={(e) => setNewStatus(e.target.value as FahrzeugStatus)}
>
{Object.values(FahrzeugStatus).map((s) => (
<MenuItem key={s} value={s}>
{FahrzeugStatusLabel[s]}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Bemerkung (optional)"
fullWidth
multiline
rows={3}
value={bemerkung}
onChange={(e) => setBemerkung(e.target.value)}
placeholder="z.B. Fahrzeug in Werkstatt, voraussichtlich ab 01.03. wieder einsatzbereit"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setStatusDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSaveStatus}
disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}
>
Speichern
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// ── Prüfungen Tab ─────────────────────────────────────────────────────────────
interface PruefungenTabProps {
fahrzeugId: string;
pruefungen: FahrzeugPruefung[];
onAdded: () => void;
}
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 }) => {
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 */}
<Fab
color="primary"
size="small"
aria-label="Prüfung hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
>
<Add />
</Fab>
{/* Add inspection dialog */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Prüfung erfassen</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Prüfungsart</InputLabel>
<Select
label="Prüfungsart"
value={form.pruefung_art}
onChange={(e) => setForm((f) => ({ ...f, pruefung_art: e.target.value as PruefungArt }))}
>
{Object.values(PruefungArt).map((art) => (
<MenuItem key={art} value={art}>{PruefungArtLabel[art]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Ergebnis</InputLabel>
<Select
label="Ergebnis"
value={form.ergebnis ?? 'ausstehend'}
onChange={(e) => setForm((f) => ({ ...f, ergebnis: e.target.value as PruefungErgebnis }))}
>
{(Object.keys(ERGEBNIS_LABELS) as PruefungErgebnis[]).map((e) => (
<MenuItem key={e} value={e}>{ERGEBNIS_LABELS[e]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fällig am *"
type="date"
fullWidth
value={form.faellig_am}
onChange={(e) => setForm((f) => ({ ...f, faellig_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Durchgeführt am"
type="date"
fullWidth
value={form.durchgefuehrt_am ?? ''}
onChange={(e) => setForm((f) => ({ ...f, durchgefuehrt_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Prüfende Stelle"
fullWidth
value={form.pruefende_stelle ?? ''}
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
placeholder="z.B. TÜV Süd Stuttgart"
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Kosten (€)"
type="number"
fullWidth
value={form.kosten ?? ''}
onChange={(e) => setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))}
inputProps={{ min: 0, step: 0.01 }}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Bemerkung"
fullWidth
multiline
rows={2}
value={form.bemerkung ?? ''}
onChange={(e) => setForm((f) => ({ ...f, bemerkung: e.target.value }))}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}
>
Speichern
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// ── Wartung Tab ───────────────────────────────────────────────────────────────
interface WartungTabProps {
fahrzeugId: string;
wartungslog: FahrzeugWartungslog[];
onAdded: () => void;
}
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
Kraftstoff: <LocalFireDepartment color="action" />,
Reparatur: <Build color="warning" />,
Inspektion: <Assignment color="primary" />,
Hauptuntersuchung:<CheckCircle color="success" />,
default: <Build color="action" />,
};
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const emptyForm: CreateWartungslogPayload = {
datum: '',
art: undefined,
beschreibung: '',
km_stand: undefined,
kraftstoff_liter: undefined,
kosten: undefined,
externe_werkstatt: '',
};
const [form, setForm] = useState<CreateWartungslogPayload>(emptyForm);
const handleSubmit = async () => {
if (!form.datum || !form.beschreibung.trim()) {
setSaveError('Datum und Beschreibung sind erforderlich.');
return;
}
try {
setSaving(true);
setSaveError(null);
await vehiclesApi.addWartungslog(fahrzeugId, {
...form,
externe_werkstatt: form.externe_werkstatt || undefined,
});
setDialogOpen(false);
setForm(emptyForm);
onAdded();
} catch {
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
return (
<Box>
{wartungslog.length === 0 ? (
<Typography color="text.secondary">Noch keine Wartungseinträge erfasst.</Typography>
) : (
// MUI Timeline is available via @mui/lab — using Paper list as fallback
// since @mui/lab is not in current package.json
<Stack divider={<Divider />} spacing={0}>
{wartungslog.map((entry) => {
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
return (
<Box key={entry.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Box sx={{ mt: 0.25 }}>{artIcon}</Box>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
{entry.art && (
<Chip label={entry.art} size="small" variant="outlined" />
)}
</Box>
<Typography variant="body2">{entry.beschreibung}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{[
entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`,
entry.kraftstoff_liter != null && `${entry.kraftstoff_liter.toFixed(1)} L`,
entry.kosten != null && `${entry.kosten.toFixed(2)}`,
entry.externe_werkstatt && entry.externe_werkstatt,
].filter(Boolean).join(' · ')}
</Typography>
</Box>
</Box>
);
})}
</Stack>
)}
<Fab
color="primary"
size="small"
aria-label="Wartung eintragen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
>
<Add />
</Fab>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Wartung / Service eintragen</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}>
<TextField
label="Datum *"
type="date"
fullWidth
value={form.datum}
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Art</InputLabel>
<Select
label="Art"
value={form.art ?? ''}
onChange={(e) => setForm((f) => ({ ...f, art: (e.target.value || undefined) as WartungslogArt | undefined }))}
>
<MenuItem value=""> Bitte wählen </MenuItem>
{(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges'] as WartungslogArt[]).map((a) => (
<MenuItem key={a} value={a}>{a}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<TextField
label="Beschreibung *"
fullWidth
multiline
rows={3}
value={form.beschreibung}
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="km-Stand"
type="number"
fullWidth
value={form.km_stand ?? ''}
onChange={(e) => setForm((f) => ({ ...f, km_stand: e.target.value ? Number(e.target.value) : undefined }))}
inputProps={{ min: 0 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Kraftstoff (L)"
type="number"
fullWidth
value={form.kraftstoff_liter ?? ''}
onChange={(e) => setForm((f) => ({ ...f, kraftstoff_liter: e.target.value ? Number(e.target.value) : undefined }))}
inputProps={{ min: 0, step: 0.1 }}
/>
</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="Externe Werkstatt"
fullWidth
value={form.externe_werkstatt ?? ''}
onChange={(e) => setForm((f) => ({ ...f, externe_werkstatt: e.target.value }))}
placeholder="Name der Werkstatt (wenn extern)"
/>
</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>
);
};
// ── Main Page ─────────────────────────────────────────────────────────────────
function FahrzeugDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const fetchVehicle = useCallback(async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const data = await vehiclesApi.getById(id);
setVehicle(data);
} catch {
setError('Fahrzeug konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { fetchVehicle(); }, [fetchVehicle]);
if (loading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (error || !vehicle) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Alert severity="error">{error ?? 'Fahrzeug nicht gefunden.'}</Alert>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/fahrzeuge')}
sx={{ mt: 2 }}
>
Zurück zur Übersicht
</Button>
</Container>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Container maxWidth="lg">
{/* Breadcrumb / back */}
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/fahrzeuge')}
sx={{ mb: 2 }}
size="small"
>
Fahrzeugübersicht
</Button>
{/* Page title */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
<Box>
<Typography variant="h4" component="h1">
{vehicle.bezeichnung}
{vehicle.kurzname && (
<Typography component="span" variant="h5" color="text.secondary" sx={{ ml: 1 }}>
{vehicle.kurzname}
</Typography>
)}
</Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="subtitle1" color="text.secondary">
{vehicle.amtliches_kennzeichen}
{vehicle.hersteller && ` · ${vehicle.hersteller}`}
</Typography>
)}
</Box>
<Box sx={{ ml: 'auto' }}>
<Chip
icon={STATUS_ICONS[vehicle.status]}
label={FahrzeugStatusLabel[vehicle.status]}
color={STATUS_CHIP_COLOR[vehicle.status]}
/>
</Box>
</Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
aria-label="Fahrzeug Detailansicht"
>
<Tab label="Übersicht" />
<Tab
label={
vehicle.naechste_pruefung_tage !== null && vehicle.naechste_pruefung_tage < 0
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Prüfungen <Warning color="error" fontSize="small" />
</Box>
: 'Prüfungen'
}
/>
<Tab label="Wartung" />
<Tab label="Einsätze" />
</Tabs>
</Box>
{/* Tab content */}
<TabPanel value={activeTab} index={0}>
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} />
</TabPanel>
<TabPanel value={activeTab} index={1}>
<PruefungenTab
fahrzeugId={vehicle.id}
pruefungen={vehicle.pruefungen}
onAdded={fetchVehicle}
/>
</TabPanel>
<TabPanel value={activeTab} index={2}>
<WartungTab
fahrzeugId={vehicle.id}
wartungslog={vehicle.wartungslog}
onAdded={fetchVehicle}
/>
</TabPanel>
<TabPanel value={activeTab} index={3}>
<Box sx={{ textAlign: 'center', py: 8 }}>
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Einsatzhistorie
</Typography>
<Typography variant="body2" color="text.disabled">
Die Einsatzverknüpfung wird in Tier 2 (Einsatzverwaltung) implementiert.
</Typography>
</Box>
</TabPanel>
</Container>
</DashboardLayout>
);
}
export default FahrzeugDetail;

View File

@@ -0,0 +1,804 @@
import { useState, useMemo, useCallback } from 'react';
import {
Box,
Typography,
IconButton,
ButtonGroup,
Button,
Popover,
List,
ListItem,
ListItemText,
Chip,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Snackbar,
Alert,
Skeleton,
Divider,
useTheme,
useMediaQuery,
} from '@mui/material';
import {
ChevronLeft,
ChevronRight,
Today as TodayIcon,
CalendarViewMonth as CalendarIcon,
ViewList as ListViewIcon,
Star as StarIcon,
ContentCopy as CopyIcon,
CheckCircle as CheckIcon,
Cancel as CancelIcon,
HelpOutline as UnknownIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { trainingApi } from '../services/training';
import type { UebungListItem, UebungTyp, TeilnahmeStatus } from '../types/training.types';
// ---------------------------------------------------------------------------
// Constants & helpers
// ---------------------------------------------------------------------------
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const MONTH_LABELS = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
const TYP_DOT_COLOR: Record<UebungTyp, string> = {
'Übungsabend': '#1976d2', // blue
'Lehrgang': '#7b1fa2', // purple
'Sonderdienst': '#e65100', // orange
'Versammlung': '#616161', // gray
'Gemeinschaftsübung': '#00796b', // teal
'Sonstiges': '#9e9e9e', // light gray
};
const TYP_CHIP_COLOR: Record<
UebungTyp,
'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success'
> = {
'Übungsabend': 'primary',
'Lehrgang': 'secondary',
'Sonderdienst': 'warning',
'Versammlung': 'default',
'Gemeinschaftsübung': 'info',
'Sonstiges': 'default',
};
function startOfDay(d: Date): Date {
const c = new Date(d);
c.setHours(0, 0, 0, 0);
return c;
}
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/** Returns calendar grid cells for the month view — always 6×7 (42 cells) */
function buildMonthGrid(year: number, month: number): Date[] {
// month is 0-indexed
const firstDay = new Date(year, month, 1);
// ISO week starts Monday; getDay() returns 0=Sun → convert to Mon=0
const dayOfWeek = (firstDay.getDay() + 6) % 7;
const start = new Date(firstDay);
start.setDate(start.getDate() - dayOfWeek);
const cells: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
cells.push(d);
}
return cells;
}
function formatTime(isoString: string): string {
const d = new Date(isoString);
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${h}:${m}`;
}
function formatDateLong(isoString: string): string {
const d = new Date(isoString);
const days = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`;
}
// ---------------------------------------------------------------------------
// RSVP indicator
// ---------------------------------------------------------------------------
function RsvpDot({ status }: { status: TeilnahmeStatus | undefined }) {
if (!status || status === 'unbekannt') return <UnknownIcon sx={{ fontSize: 14, color: 'text.disabled' }} />;
if (status === 'zugesagt' || status === 'erschienen') return <CheckIcon sx={{ fontSize: 14, color: 'success.main' }} />;
return <CancelIcon sx={{ fontSize: 14, color: 'error.main' }} />;
}
// ---------------------------------------------------------------------------
// iCal Subscribe Dialog
// ---------------------------------------------------------------------------
interface IcalDialogProps {
open: boolean;
onClose: () => void;
}
function IcalDialog({ open, onClose }: IcalDialogProps) {
const [snackOpen, setSnackOpen] = useState(false);
const [subscribeUrl, setSubscribeUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleOpen = async () => {
if (subscribeUrl) return;
setLoading(true);
try {
const { subscribeUrl: url } = await trainingApi.getCalendarToken();
setSubscribeUrl(url);
} catch (_) {
setSubscribeUrl(null);
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
if (!subscribeUrl) return;
await navigator.clipboard.writeText(subscribeUrl);
setSnackOpen(true);
};
return (
<>
<Dialog
open={open}
onClose={onClose}
TransitionProps={{ onEnter: handleOpen }}
maxWidth="sm"
fullWidth
>
<DialogTitle>Kalender abonnieren</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
Kopiere die URL und füge sie in deiner Kalender-App unter
"Kalender abonnieren" ein. Der Kalender wird automatisch
aktualisiert, sobald neue Dienste eingetragen werden.
</DialogContentText>
{loading && <Skeleton variant="rectangular" height={48} sx={{ borderRadius: 1 }} />}
{!loading && subscribeUrl && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1.5,
borderRadius: 1,
bgcolor: 'action.hover',
fontFamily: 'monospace',
fontSize: '0.75rem',
wordBreak: 'break-all',
}}
>
<Box sx={{ flexGrow: 1, userSelect: 'all' }}>{subscribeUrl}</Box>
<Tooltip title="URL kopieren">
<IconButton size="small" onClick={handleCopy}>
<CopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
<strong>Apple Kalender:</strong> Ablage Neues Kalenderabonnement<br />
<strong>Google Kalender:</strong> Andere Kalender Per URL<br />
<strong>Thunderbird:</strong> Neu Kalender Im Netzwerk
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Schließen</Button>
{subscribeUrl && (
<Button variant="contained" onClick={handleCopy} startIcon={<CopyIcon />}>
URL kopieren
</Button>
)}
</DialogActions>
</Dialog>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" onClose={() => setSnackOpen(false)}>
URL kopiert!
</Alert>
</Snackbar>
</>
);
}
// ---------------------------------------------------------------------------
// Month Calendar Grid
// ---------------------------------------------------------------------------
interface MonthCalendarProps {
year: number;
month: number;
events: UebungListItem[];
onDayClick: (day: Date, anchor: Element) => void;
}
function MonthCalendar({ year, month, events, onDayClick }: MonthCalendarProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const today = startOfDay(new Date());
const cells = useMemo(() => buildMonthGrid(year, month), [year, month]);
// Build a map: "YYYY-MM-DD" → events
const eventsByDay = useMemo(() => {
const map = new Map<string, UebungListItem[]>();
for (const ev of events) {
const d = startOfDay(new Date(ev.datum_von));
const key = d.toISOString().slice(0, 10);
const arr = map.get(key) ?? [];
arr.push(ev);
map.set(key, arr);
}
return map;
}, [events]);
return (
<Box>
{/* Weekday headers */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
mb: 0.5,
}}
>
{WEEKDAY_LABELS.map((wd) => (
<Typography
key={wd}
variant="caption"
sx={{
textAlign: 'center',
fontWeight: 600,
color: wd === 'Sa' || wd === 'So' ? 'error.main' : 'text.secondary',
py: 0.5,
}}
>
{wd}
</Typography>
))}
</Box>
{/* Day cells — 6 rows × 7 cols */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '2px',
}}
>
{cells.map((cell, idx) => {
const isCurrentMonth = cell.getMonth() === month;
const isToday = sameDay(cell, today);
const key = cell.toISOString().slice(0, 10);
const dayEvents = eventsByDay.get(key) ?? [];
const hasEvents = dayEvents.length > 0;
return (
<Box
key={idx}
onClick={(e) => hasEvents && onDayClick(cell, e.currentTarget)}
sx={{
minHeight: isMobile ? 44 : 72,
borderRadius: 1,
p: '4px',
cursor: hasEvents ? 'pointer' : 'default',
bgcolor: isToday
? 'primary.main'
: isCurrentMonth
? 'background.paper'
: 'action.disabledBackground',
border: '1px solid',
borderColor: isToday ? 'primary.dark' : 'divider',
transition: 'background 0.1s',
'&:hover': hasEvents
? { bgcolor: isToday ? 'primary.dark' : 'action.hover' }
: {},
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
overflow: 'hidden',
}}
>
<Typography
variant="caption"
sx={{
fontWeight: isToday ? 700 : 400,
color: isToday
? 'primary.contrastText'
: isCurrentMonth
? 'text.primary'
: 'text.disabled',
lineHeight: 1.4,
fontSize: isMobile ? '0.7rem' : '0.75rem',
}}
>
{cell.getDate()}
</Typography>
{/* Event dots — max 3 visible on mobile */}
{hasEvents && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: '2px',
justifyContent: 'center',
mt: 0.25,
}}
>
{dayEvents.slice(0, isMobile ? 3 : 5).map((ev, i) => (
<Box
key={i}
sx={{
width: isMobile ? 5 : 7,
height: isMobile ? 5 : 7,
borderRadius: '50%',
bgcolor: ev.abgesagt
? 'text.disabled'
: TYP_DOT_COLOR[ev.typ],
border: ev.pflichtveranstaltung
? '1.5px solid'
: 'none',
borderColor: 'warning.main',
flexShrink: 0,
}}
/>
))}
{dayEvents.length > (isMobile ? 3 : 5) && (
<Typography
sx={{
fontSize: '0.55rem',
color: isToday ? 'primary.contrastText' : 'text.secondary',
lineHeight: 1,
}}
>
+{dayEvents.length - (isMobile ? 3 : 5)}
</Typography>
)}
</Box>
)}
{/* On desktop: show short event titles */}
{!isMobile && hasEvents && (
<Box sx={{ width: '100%', mt: 0.25 }}>
{dayEvents.slice(0, 2).map((ev, i) => (
<Typography
key={i}
variant="caption"
noWrap
sx={{
display: 'block',
fontSize: '0.6rem',
lineHeight: 1.3,
color: ev.abgesagt ? 'text.disabled' : TYP_DOT_COLOR[ev.typ],
textDecoration: ev.abgesagt ? 'line-through' : 'none',
px: 0.25,
}}
>
{ev.pflichtveranstaltung && '* '}{ev.titel}
</Typography>
))}
</Box>
)}
</Box>
);
})}
</Box>
{/* Legend */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mt: 2 }}>
{Object.entries(TYP_DOT_COLOR).map(([typ, color]) => (
<Box key={typ} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color }} />
<Typography variant="caption" color="text.secondary">{typ}</Typography>
</Box>
))}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'warning.main', border: '1.5px solid', borderColor: 'warning.dark' }} />
<Typography variant="caption" color="text.secondary">Pflichtveranstaltung</Typography>
</Box>
</Box>
</Box>
);
}
// ---------------------------------------------------------------------------
// List View
// ---------------------------------------------------------------------------
function ListView({
events,
onEventClick,
}: {
events: UebungListItem[];
onEventClick: (id: string) => void;
}) {
return (
<List disablePadding>
{events.map((ev, idx) => (
<Box key={ev.id}>
{idx > 0 && <Divider />}
<ListItem
onClick={() => onEventClick(ev.id)}
sx={{
cursor: 'pointer',
px: 1,
py: 1,
borderRadius: 1,
opacity: ev.abgesagt ? 0.55 : 1,
'&:hover': { bgcolor: 'action.hover' },
}}
>
{/* Date badge */}
<Box
sx={{
minWidth: 52,
textAlign: 'center',
mr: 2,
flexShrink: 0,
}}
>
<Typography variant="caption" sx={{ display: 'block', color: 'text.disabled', fontSize: '0.65rem' }}>
{new Date(ev.datum_von).getDate()}.
{new Date(ev.datum_von).getMonth() + 1}.
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary', fontSize: '0.7rem' }}>
{formatTime(ev.datum_von)}
</Typography>
</Box>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
{ev.pflichtveranstaltung && (
<StarIcon sx={{ fontSize: 14, color: 'warning.main' }} />
)}
<Typography
variant="body2"
sx={{
fontWeight: ev.pflichtveranstaltung ? 700 : 400,
textDecoration: ev.abgesagt ? 'line-through' : 'none',
}}
>
{ev.titel}
</Typography>
{ev.abgesagt && (
<Chip label="Abgesagt" size="small" color="error" variant="outlined" sx={{ fontSize: '0.6rem', height: 16 }} />
)}
</Box>
}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25, flexWrap: 'wrap' }}>
<Chip
label={ev.typ}
size="small"
color={TYP_CHIP_COLOR[ev.typ]}
variant="outlined"
sx={{ fontSize: '0.6rem', height: 16 }}
/>
{ev.ort && (
<Typography variant="caption" color="text.disabled" noWrap>
{ev.ort}
</Typography>
)}
</Box>
}
sx={{ my: 0 }}
/>
{/* RSVP badge */}
<Box sx={{ ml: 1 }}>
<RsvpDot status={ev.eigener_status} />
</Box>
</ListItem>
</Box>
))}
{events.length === 0 && (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', py: 4 }}
>
Keine Veranstaltungen in diesem Monat.
</Typography>
)}
</List>
);
}
// ---------------------------------------------------------------------------
// Day Popover
// ---------------------------------------------------------------------------
interface DayPopoverProps {
anchorEl: Element | null;
day: Date | null;
events: UebungListItem[];
onClose: () => void;
onEventClick: (id: string) => void;
}
function DayPopover({ anchorEl, day, events, onClose, onEventClick }: DayPopoverProps) {
if (!day) return null;
return (
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
PaperProps={{ sx: { p: 1, maxWidth: 300, width: '90vw' } }}
>
<Typography variant="subtitle2" sx={{ mb: 1, px: 0.5 }}>
{formatDateLong(day.toISOString())}
</Typography>
<List dense disablePadding>
{events.map((ev) => (
<ListItem
key={ev.id}
onClick={() => { onEventClick(ev.id); onClose(); }}
sx={{
cursor: 'pointer',
borderRadius: 1,
px: 0.75,
'&:hover': { bgcolor: 'action.hover' },
opacity: ev.abgesagt ? 0.6 : 1,
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: TYP_DOT_COLOR[ev.typ],
mr: 1,
flexShrink: 0,
}}
/>
<ListItemText
primary={
<Typography
variant="body2"
sx={{
fontWeight: ev.pflichtveranstaltung ? 700 : 400,
textDecoration: ev.abgesagt ? 'line-through' : 'none',
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{ev.pflichtveranstaltung && <StarIcon sx={{ fontSize: 12, color: 'warning.main' }} />}
{ev.titel}
</Typography>
}
secondary={`${formatTime(ev.datum_von)} ${formatTime(ev.datum_bis)} Uhr`}
/>
<RsvpDot status={ev.eigener_status} />
</ListItem>
))}
</List>
</Popover>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
export default function Kalender() {
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const today = new Date();
const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() });
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar');
const [icalOpen, setIcalOpen] = useState(false);
// Popover state
const [popoverAnchor, setPopoverAnchor] = useState<Element | null>(null);
const [popoverDay, setPopoverDay] = useState<Date | null>(null);
const [popoverEvents, setPopoverEvents] = useState<UebungListItem[]>([]);
// Compute fetch range: whole month ± 1 week buffer for grid
const { from, to } = useMemo(() => {
const firstCell = new Date(viewMonth.year, viewMonth.month, 1);
const dayOfWeek = (firstCell.getDay() + 6) % 7;
const f = new Date(firstCell);
f.setDate(f.getDate() - dayOfWeek);
const lastCell = new Date(f);
lastCell.setDate(lastCell.getDate() + 41);
return { from: f, to: lastCell };
}, [viewMonth]);
const { data, isLoading } = useQuery({
queryKey: ['training', 'calendar', from.toISOString(), to.toISOString()],
queryFn: () => trainingApi.getCalendarRange(from, to),
staleTime: 5 * 60 * 1000,
});
const events = useMemo(() => data ?? [], [data]);
const handlePrev = () => {
setViewMonth((prev) => {
const m = prev.month === 0 ? 11 : prev.month - 1;
const y = prev.month === 0 ? prev.year - 1 : prev.year;
return { year: y, month: m };
});
};
const handleNext = () => {
setViewMonth((prev) => {
const m = prev.month === 11 ? 0 : prev.month + 1;
const y = prev.month === 11 ? prev.year + 1 : prev.year;
return { year: y, month: m };
});
};
const handleToday = () => {
setViewMonth({ year: today.getFullYear(), month: today.getMonth() });
};
const handleDayClick = useCallback((day: Date, anchor: Element) => {
const key = day.toISOString().slice(0, 10);
const dayEvs = events.filter(
(ev) => startOfDay(new Date(ev.datum_von)).toISOString().slice(0, 10) === key
);
setPopoverDay(day);
setPopoverAnchor(anchor);
setPopoverEvents(dayEvs);
}, [events]);
return (
<DashboardLayout>
<Box sx={{ maxWidth: 900, mx: 'auto' }}>
{/* Page header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
mb: 3,
}}
>
<CalendarIcon color="primary" />
<Typography variant="h5" sx={{ flexGrow: 1, fontWeight: 700 }}>
Dienstkalender
</Typography>
{/* View toggle */}
<ButtonGroup size="small" variant="outlined">
<Tooltip title="Monatsansicht">
<Button
onClick={() => setViewMode('calendar')}
variant={viewMode === 'calendar' ? 'contained' : 'outlined'}
>
<CalendarIcon fontSize="small" />
{!isMobile && <Box sx={{ ml: 0.5 }}>Monat</Box>}
</Button>
</Tooltip>
<Tooltip title="Listenansicht">
<Button
onClick={() => setViewMode('list')}
variant={viewMode === 'list' ? 'contained' : 'outlined'}
>
<ListViewIcon fontSize="small" />
{!isMobile && <Box sx={{ ml: 0.5 }}>Liste</Box>}
</Button>
</Tooltip>
</ButtonGroup>
<Button
size="small"
variant="outlined"
startIcon={<CopyIcon fontSize="small" />}
onClick={() => setIcalOpen(true)}
sx={{ whiteSpace: 'nowrap' }}
>
{isMobile ? 'iCal' : 'Kalender abonnieren'}
</Button>
</Box>
{/* Month navigation */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
mb: 2,
gap: 1,
}}
>
<IconButton onClick={handlePrev} size="small" aria-label="Vorheriger Monat">
<ChevronLeft />
</IconButton>
<Typography
variant="h6"
sx={{ flexGrow: 1, textAlign: 'center', fontWeight: 600 }}
>
{MONTH_LABELS[viewMonth.month]} {viewMonth.year}
</Typography>
<Button
size="small"
startIcon={<TodayIcon fontSize="small" />}
onClick={handleToday}
sx={{ minWidth: 'auto' }}
>
{!isMobile && 'Heute'}
</Button>
<IconButton onClick={handleNext} size="small" aria-label="Nächster Monat">
<ChevronRight />
</IconButton>
</Box>
{/* Calendar / List body */}
{isLoading ? (
<Skeleton variant="rectangular" height={isMobile ? 320 : 480} sx={{ borderRadius: 2 }} />
) : viewMode === 'calendar' ? (
<MonthCalendar
year={viewMonth.year}
month={viewMonth.month}
events={events}
onDayClick={handleDayClick}
/>
) : (
<ListView
events={events.filter((ev) => {
const d = new Date(ev.datum_von);
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
})}
onEventClick={(id) => navigate(`/training/${id}`)}
/>
)}
</Box>
{/* Day Popover */}
<DayPopover
anchorEl={popoverAnchor}
day={popoverDay}
events={popoverEvents}
onClose={() => setPopoverAnchor(null)}
onEventClick={(id) => navigate(`/training/${id}`)}
/>
{/* iCal Subscribe Dialog */}
<IcalDialog open={icalOpen} onClose={() => setIcalOpen(false)} />
</DashboardLayout>
);
}

View File

@@ -0,0 +1,792 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Container,
Box,
Typography,
Card,
CardContent,
CardHeader,
Avatar,
Button,
Chip,
Tabs,
Tab,
Grid,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Alert,
Divider,
Tooltip,
IconButton,
Stack,
} from '@mui/material';
import {
Edit as EditIcon,
Save as SaveIcon,
Cancel as CancelIcon,
Person as PersonIcon,
Phone as PhoneIcon,
Badge as BadgeIcon,
Security as SecurityIcon,
History as HistoryIcon,
DriveEta as DriveEtaIcon,
} from '@mui/icons-material';
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { membersService } from '../services/members';
import {
MemberWithProfile,
StatusEnum,
DienstgradEnum,
FunktionEnum,
TshirtGroesseEnum,
DIENSTGRAD_VALUES,
STATUS_VALUES,
FUNKTION_VALUES,
TSHIRT_GROESSE_VALUES,
STATUS_LABELS,
STATUS_COLORS,
getMemberDisplayName,
formatPhone,
UpdateMemberProfileData,
} from '../types/member.types';
// ----------------------------------------------------------------
// Role helpers
// ----------------------------------------------------------------
function useCanWrite(): boolean {
const { user } = useAuth();
const groups: string[] = (user as any)?.groups ?? [];
return groups.includes('feuerwehr-admin') || groups.includes('feuerwehr-kommandant');
}
function useCurrentUserId(): string | undefined {
const { user } = useAuth();
return (user as any)?.id;
}
// ----------------------------------------------------------------
// Tab panel helper
// ----------------------------------------------------------------
interface TabPanelProps {
children?: React.ReactNode;
value: number;
index: number;
}
function TabPanel({ children, value, index }: TabPanelProps) {
return (
<div role="tabpanel" hidden={value !== index} aria-labelledby={`tab-${index}`}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
// ----------------------------------------------------------------
// Rank history timeline component
// ----------------------------------------------------------------
interface RankTimelineProps {
entries: NonNullable<MemberWithProfile['dienstgrad_verlauf']>;
}
function RankTimeline({ entries }: RankTimelineProps) {
if (entries.length === 0) {
return (
<Typography color="text.secondary" variant="body2">
Keine Dienstgradänderungen eingetragen.
</Typography>
);
}
return (
<Stack spacing={0}>
{entries.map((entry, idx) => (
<Box
key={entry.id}
sx={{
display: 'flex',
gap: 2,
position: 'relative',
pb: 2,
'&::before': idx < entries.length - 1 ? {
content: '""',
position: 'absolute',
left: 11,
top: 24,
bottom: 0,
width: 2,
bgcolor: 'divider',
} : {},
}}
>
{/* Timeline dot */}
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: 'primary.main',
flexShrink: 0,
mt: 0.25,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<HistoryIcon sx={{ fontSize: 14, color: 'white' }} />
</Box>
{/* Content */}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={500}>
{entry.dienstgrad_neu}
</Typography>
{entry.dienstgrad_alt && (
<Typography variant="caption" color="text.secondary">
vorher: {entry.dienstgrad_alt}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
<Typography variant="caption" color="text.secondary">
{new Date(entry.datum).toLocaleDateString('de-AT')}
</Typography>
{entry.durch_user_name && (
<Typography variant="caption" color="text.secondary">
· durch {entry.durch_user_name}
</Typography>
)}
</Box>
{entry.bemerkung && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{entry.bemerkung}
</Typography>
)}
</Box>
</Box>
))}
</Stack>
);
}
// ----------------------------------------------------------------
// Read-only field row
// ----------------------------------------------------------------
function FieldRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Box sx={{ display: 'flex', gap: 1, py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 180, flexShrink: 0 }}>
{label}
</Typography>
<Typography variant="body2" sx={{ flex: 1 }}>
{value ?? '—'}
</Typography>
</Box>
);
}
// ----------------------------------------------------------------
// Main component
// ----------------------------------------------------------------
function MitgliedDetail() {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const canWrite = useCanWrite();
const currentUserId = useCurrentUserId();
const isOwnProfile = currentUserId === userId;
const canEdit = canWrite || isOwnProfile;
// --- state ---
const [member, setMember] = useState<MemberWithProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState(0);
// Edit form state — only the fields the user is allowed to change
const [formData, setFormData] = useState<UpdateMemberProfileData>({});
// ----------------------------------------------------------------
// Data loading
// ----------------------------------------------------------------
const loadMember = useCallback(async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const data = await membersService.getMember(userId);
setMember(data);
} catch {
setError('Mitglied konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
loadMember();
}, [loadMember]);
// Populate form from current profile
useEffect(() => {
if (member?.profile) {
setFormData({
mitglieds_nr: member.profile.mitglieds_nr ?? undefined,
dienstgrad: member.profile.dienstgrad ?? undefined,
funktion: member.profile.funktion,
status: member.profile.status,
eintrittsdatum: member.profile.eintrittsdatum ?? undefined,
geburtsdatum: member.profile.geburtsdatum ?? undefined,
telefon_mobil: member.profile.telefon_mobil ?? undefined,
telefon_privat: member.profile.telefon_privat ?? undefined,
notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined,
notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined,
fuehrerscheinklassen: member.profile.fuehrerscheinklassen,
tshirt_groesse: member.profile.tshirt_groesse ?? undefined,
schuhgroesse: member.profile.schuhgroesse ?? undefined,
bemerkungen: member.profile.bemerkungen ?? undefined,
});
}
}, [member]);
// ----------------------------------------------------------------
// Save
// ----------------------------------------------------------------
const handleSave = async () => {
if (!userId) return;
setSaving(true);
setSaveError(null);
try {
const updated = await membersService.updateMember(userId, formData);
setMember(updated);
setEditMode(false);
} catch {
setSaveError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.');
} finally {
setSaving(false);
}
};
const handleCancelEdit = () => {
setEditMode(false);
setSaveError(null);
// Reset form to current profile values
if (member?.profile) {
setFormData({
telefon_mobil: member.profile.telefon_mobil ?? undefined,
telefon_privat: member.profile.telefon_privat ?? undefined,
notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined,
notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined,
tshirt_groesse: member.profile.tshirt_groesse ?? undefined,
schuhgroesse: member.profile.schuhgroesse ?? undefined,
});
}
};
const handleFieldChange = (field: keyof UpdateMemberProfileData, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// ----------------------------------------------------------------
// Render helpers
// ----------------------------------------------------------------
if (loading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (error || !member) {
return (
<DashboardLayout>
<Container maxWidth="md">
<Alert severity="error" sx={{ mt: 4 }}>
{error ?? 'Mitglied nicht gefunden.'}
</Alert>
<Button sx={{ mt: 2 }} onClick={() => navigate('/mitglieder')}>
Zurück zur Liste
</Button>
</Container>
</DashboardLayout>
);
}
const displayName = getMemberDisplayName(member);
const profile = member.profile;
const initials = [member.given_name?.[0], member.family_name?.[0]]
.filter(Boolean)
.join('')
.toUpperCase() || member.email[0].toUpperCase();
return (
<DashboardLayout>
<Container maxWidth="lg">
{/* Back button */}
<Button
variant="text"
onClick={() => navigate('/mitglieder')}
sx={{ mb: 2 }}
>
Mitgliederliste
</Button>
{/* Header card */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<Avatar
src={profile?.bild_url ?? member.profile_picture_url ?? undefined}
alt={displayName}
sx={{ width: 80, height: 80, fontSize: '1.75rem' }}
>
{initials}
</Avatar>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h5" fontWeight={600}>
{displayName}
</Typography>
{profile?.mitglieds_nr && (
<Chip
icon={<BadgeIcon />}
label={`Nr. ${profile.mitglieds_nr}`}
size="small"
variant="outlined"
/>
)}
{profile?.status && (
<Chip
label={STATUS_LABELS[profile.status]}
size="small"
color={STATUS_COLORS[profile.status]}
/>
)}
</Box>
<Typography color="text.secondary" variant="body2" sx={{ mt: 0.5 }}>
{member.email}
</Typography>
{profile?.dienstgrad && (
<Typography variant="body2" sx={{ mt: 0.5 }}>
<strong>Dienstgrad:</strong> {profile.dienstgrad}
{profile.dienstgrad_seit
? ` (seit ${new Date(profile.dienstgrad_seit).toLocaleDateString('de-AT')})`
: ''}
</Typography>
)}
{profile && profile.funktion.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, mt: 1, flexWrap: 'wrap' }}>
{profile.funktion.map((f) => (
<Chip key={f} label={f} size="small" color="secondary" variant="outlined" />
))}
</Box>
)}
</Box>
{/* Edit controls */}
{canEdit && (
<Box>
{editMode ? (
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Änderungen speichern">
<span>
<IconButton
color="primary"
onClick={handleSave}
disabled={saving}
aria-label="Speichern"
>
{saving ? <CircularProgress size={20} /> : <SaveIcon />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Abbrechen">
<IconButton
onClick={handleCancelEdit}
disabled={saving}
aria-label="Abbrechen"
>
<CancelIcon />
</IconButton>
</Tooltip>
</Box>
) : (
<Tooltip title="Bearbeiten">
<IconButton onClick={() => setEditMode(true)} aria-label="Bearbeiten">
<EditIcon />
</IconButton>
</Tooltip>
)}
</Box>
)}
</Box>
{!profile && (
<Alert severity="info" sx={{ mt: 2 }}>
Für dieses Mitglied wurde noch kein Profil angelegt.
{canWrite && ' Ein Kommandant kann das Profil unter "Stammdaten" erstellen.'}
</Alert>
)}
{saveError && (
<Alert severity="error" sx={{ mt: 2 }} onClose={() => setSaveError(null)}>
{saveError}
</Alert>
)}
</CardContent>
</Card>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={activeTab}
onChange={(_e, v) => setActiveTab(v)}
aria-label="Mitglied Details"
>
<Tab label="Stammdaten" id="tab-0" aria-controls="tabpanel-0" />
<Tab label="Qualifikationen" id="tab-1" aria-controls="tabpanel-1" />
<Tab label="Einsätze" id="tab-2" aria-controls="tabpanel-2" />
</Tabs>
</Box>
{/* ---- Tab 0: Stammdaten ---- */}
<TabPanel value={activeTab} index={0}>
<Grid container spacing={3}>
{/* Personal data */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<PersonIcon color="primary" />}
title="Persönliche Daten"
/>
<CardContent>
{editMode && canWrite ? (
<Stack spacing={2}>
<TextField
label="Dienstgrad"
select
fullWidth
size="small"
value={formData.dienstgrad ?? ''}
onChange={(e) => handleFieldChange('dienstgrad', e.target.value as DienstgradEnum || undefined)}
>
<MenuItem value=""></MenuItem>
{DIENSTGRAD_VALUES.map((dg) => (
<MenuItem key={dg} value={dg}>{dg}</MenuItem>
))}
</TextField>
<TextField
label="Dienstgrad seit"
type="date"
fullWidth
size="small"
value={formData.dienstgrad_seit ?? ''}
onChange={(e) => handleFieldChange('dienstgrad_seit', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Status"
select
fullWidth
size="small"
value={formData.status ?? 'aktiv'}
onChange={(e) => handleFieldChange('status', e.target.value as StatusEnum)}
>
{STATUS_VALUES.map((s) => (
<MenuItem key={s} value={s}>{STATUS_LABELS[s]}</MenuItem>
))}
</TextField>
<TextField
label="Mitgliedsnummer"
fullWidth
size="small"
value={formData.mitglieds_nr ?? ''}
onChange={(e) => handleFieldChange('mitglieds_nr', e.target.value || undefined)}
/>
<TextField
label="Eintrittsdatum"
type="date"
fullWidth
size="small"
value={formData.eintrittsdatum ?? ''}
onChange={(e) => handleFieldChange('eintrittsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Geburtsdatum"
type="date"
fullWidth
size="small"
value={formData.geburtsdatum ?? ''}
onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
/>
</Stack>
) : (
<>
<FieldRow label="Dienstgrad" value={profile?.dienstgrad ?? null} />
<FieldRow
label="Dienstgrad seit"
value={profile?.dienstgrad_seit
? new Date(profile.dienstgrad_seit).toLocaleDateString('de-AT')
: null}
/>
<FieldRow label="Status" value={
profile?.status
? <Chip label={STATUS_LABELS[profile.status]} size="small" color={STATUS_COLORS[profile.status]} />
: null
} />
<FieldRow label="Mitgliedsnummer" value={profile?.mitglieds_nr ?? null} />
<FieldRow
label="Eintrittsdatum"
value={profile?.eintrittsdatum
? new Date(profile.eintrittsdatum).toLocaleDateString('de-AT')
: null}
/>
<FieldRow
label="Geburtsdatum"
value={
profile?.geburtsdatum
? new Date(profile.geburtsdatum).toLocaleDateString('de-AT')
: profile?._age
? `(${profile._age} Jahre)`
: null
}
/>
</>
)}
</CardContent>
</Card>
</Grid>
{/* Contact */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<PhoneIcon color="primary" />}
title="Kontaktdaten"
/>
<CardContent>
{editMode ? (
<Stack spacing={2}>
<TextField
label="Mobil"
fullWidth
size="small"
value={formData.telefon_mobil ?? ''}
onChange={(e) => handleFieldChange('telefon_mobil', e.target.value || undefined)}
placeholder="+436641234567"
/>
<TextField
label="Privat"
fullWidth
size="small"
value={formData.telefon_privat ?? ''}
onChange={(e) => handleFieldChange('telefon_privat', e.target.value || undefined)}
placeholder="+4371234567"
/>
<Divider />
<Typography variant="caption" color="text.secondary">
Notfallkontakt
</Typography>
<TextField
label="Name"
fullWidth
size="small"
value={formData.notfallkontakt_name ?? ''}
onChange={(e) => handleFieldChange('notfallkontakt_name', e.target.value || undefined)}
/>
<TextField
label="Telefon"
fullWidth
size="small"
value={formData.notfallkontakt_telefon ?? ''}
onChange={(e) => handleFieldChange('notfallkontakt_telefon', e.target.value || undefined)}
placeholder="+436641234567"
/>
</Stack>
) : (
<>
<FieldRow label="Mobil" value={formatPhone(profile?.telefon_mobil)} />
<FieldRow label="Privat" value={formatPhone(profile?.telefon_privat)} />
<FieldRow
label="E-Mail"
value={
<a href={`mailto:${member.email}`} style={{ color: 'inherit' }}>
{member.email}
</a>
}
/>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Notfallkontakt
</Typography>
<FieldRow label="Name" value={profile?.notfallkontakt_name ?? null} />
<FieldRow label="Telefon" value={formatPhone(profile?.notfallkontakt_telefon)} />
</>
)}
</CardContent>
</Card>
</Grid>
{/* Uniform sizing */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<SecurityIcon color="primary" />}
title="Ausrüstung & Uniform"
/>
<CardContent>
{editMode ? (
<Stack spacing={2}>
<TextField
label="T-Shirt Größe"
select
fullWidth
size="small"
value={formData.tshirt_groesse ?? ''}
onChange={(e) => handleFieldChange('tshirt_groesse', e.target.value as TshirtGroesseEnum || undefined)}
>
<MenuItem value=""></MenuItem>
{TSHIRT_GROESSE_VALUES.map((g) => (
<MenuItem key={g} value={g}>{g}</MenuItem>
))}
</TextField>
<TextField
label="Schuhgröße"
fullWidth
size="small"
value={formData.schuhgroesse ?? ''}
onChange={(e) => handleFieldChange('schuhgroesse', e.target.value || undefined)}
placeholder="z.B. 43"
/>
</Stack>
) : (
<>
<FieldRow label="T-Shirt Größe" value={profile?.tshirt_groesse ?? null} />
<FieldRow label="Schuhgröße" value={profile?.schuhgroesse ?? null} />
</>
)}
</CardContent>
</Card>
</Grid>
{/* Driving licenses */}
<Grid item xs={12} md={6}>
<Card>
<CardHeader
avatar={<DriveEtaIcon color="primary" />}
title="Führerscheinklassen"
/>
<CardContent>
{profile?.fuehrerscheinklassen && profile.fuehrerscheinklassen.length > 0 ? (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{profile.fuehrerscheinklassen.map((k) => (
<Chip key={k} label={k} size="small" variant="outlined" />
))}
</Box>
) : (
<Typography color="text.secondary" variant="body2"></Typography>
)}
</CardContent>
</Card>
</Grid>
{/* Rank history */}
<Grid item xs={12}>
<Card>
<CardHeader
avatar={<HistoryIcon color="primary" />}
title="Dienstgrad-Verlauf"
/>
<CardContent>
<RankTimeline entries={member.dienstgrad_verlauf ?? []} />
</CardContent>
</Card>
</Grid>
{/* Remarks — Kommandant/Admin only */}
{canWrite && (
<Grid item xs={12}>
<Card>
<CardHeader title="Interne Bemerkungen" />
<CardContent>
{editMode ? (
<TextField
fullWidth
multiline
rows={4}
label="Bemerkungen"
value={formData.bemerkungen ?? ''}
onChange={(e) => handleFieldChange('bemerkungen', e.target.value || undefined)}
/>
) : (
<Typography variant="body2" color={profile?.bemerkungen ? 'text.primary' : 'text.secondary'}>
{profile?.bemerkungen ?? 'Keine Bemerkungen eingetragen.'}
</Typography>
)}
</CardContent>
</Card>
</Grid>
)}
</Grid>
</TabPanel>
{/* ---- Tab 1: Qualifikationen (placeholder) ---- */}
<TabPanel value={activeTab} index={1}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 2 }}>
<SecurityIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
<Typography variant="h6" color="text.secondary">
Qualifikationen & Lehrgänge
</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center" maxWidth={480}>
Diese Funktion wird in einer zukünftigen Version verfügbar sein.
Geplant: Atemschutz, G26-Untersuchungen, Absolvierte Kurse, Gültigkeitsdaten.
</Typography>
</Box>
</CardContent>
</Card>
</TabPanel>
{/* ---- Tab 2: Einsätze (placeholder) ---- */}
<TabPanel value={activeTab} index={2}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 2 }}>
<PersonIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
<Typography variant="h6" color="text.secondary">
Einsätze dieses Mitglieds
</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center" maxWidth={480}>
Diese Funktion wird verfügbar sobald das Einsatz-Modul implementiert ist.
</Typography>
</Box>
</CardContent>
</Card>
</TabPanel>
</Container>
</DashboardLayout>
);
}
export default MitgliedDetail;

View File

@@ -0,0 +1,551 @@
import { useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
Typography,
Chip,
Button,
Divider,
Accordion,
AccordionSummary,
AccordionDetails,
List,
ListItem,
ListItemText,
ListItemIcon,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Checkbox,
Skeleton,
Alert,
Paper,
Stack,
Tooltip,
CircularProgress,
useTheme,
useMediaQuery,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
CheckCircle as CheckIcon,
Cancel as CancelIcon,
HelpOutline as UnknownIcon,
Star as StarIcon,
LocationOn as LocationIcon,
AccessTime as TimeIcon,
Group as GroupIcon,
ArrowBack as BackIcon,
Edit as EditIcon,
Info as InfoIcon,
HowToReg as AttendanceIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { trainingApi } from '../services/training';
import type { TeilnahmeStatus, UebungTyp, Teilnahme } from '../types/training.types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TYP_CHIP_COLOR: Record<
UebungTyp,
'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success'
> = {
'Übungsabend': 'primary',
'Lehrgang': 'secondary',
'Sonderdienst': 'warning',
'Versammlung': 'default',
'Gemeinschaftsübung': 'info',
'Sonstiges': 'default',
};
const WEEKDAY = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const MONTH = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
function formatDateFull(iso: string): string {
const d = new Date(iso);
return `${WEEKDAY[d.getDay()]}, ${d.getDate()}. ${MONTH[d.getMonth()]} ${d.getFullYear()}`;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')} Uhr`;
}
// ---------------------------------------------------------------------------
// Role helper — reads `role` from the user object (added by Tier 1)
// ---------------------------------------------------------------------------
const ROLE_ORDER: Record<string, number> = {
mitglied: 0, gruppenfuehrer: 1, kommandant: 2, admin: 3,
};
function hasRole(userRole: string | undefined, minRole: string): boolean {
return (ROLE_ORDER[userRole ?? 'mitglied'] ?? 0) >= (ROLE_ORDER[minRole] ?? 0);
}
// ---------------------------------------------------------------------------
// RSVP Status icon
// ---------------------------------------------------------------------------
function StatusIcon({ status }: { status: TeilnahmeStatus | undefined }) {
if (!status || status === 'unbekannt') return <UnknownIcon sx={{ color: 'text.disabled' }} />;
if (status === 'zugesagt') return <CheckIcon sx={{ color: 'success.main' }} />;
if (status === 'erschienen') return <CheckIcon sx={{ color: 'success.dark' }} />;
if (status === 'entschuldigt') return <CancelIcon sx={{ color: 'warning.main' }} />;
return <CancelIcon sx={{ color: 'error.main' }} />;
}
const STATUS_LABEL: Record<TeilnahmeStatus, string> = {
zugesagt: 'Zugesagt',
abgesagt: 'Abgesagt',
erschienen: 'Erschienen',
entschuldigt:'Entschuldigt',
unbekannt: 'Ausstehend',
};
// ---------------------------------------------------------------------------
// Mark Attendance Modal
// ---------------------------------------------------------------------------
interface MarkAttendanceDialogProps {
open: boolean;
onClose: () => void;
uebungId: string;
teilnahmen: Teilnahme[];
}
function MarkAttendanceDialog({
open, onClose, uebungId, teilnahmen,
}: MarkAttendanceDialogProps) {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(
// Pre-select anyone already marked zugesagt
new Set(teilnahmen.filter((t) => t.status === 'zugesagt' || t.status === 'erschienen').map((t) => t.user_id))
);
const mutation = useMutation({
mutationFn: () => trainingApi.markAttendance(uebungId, Array.from(selected)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['training', 'event', uebungId] });
onClose();
},
});
const toggle = (userId: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AttendanceIcon color="primary" />
Anwesenheit erfassen
</DialogTitle>
<DialogContent dividers sx={{ p: 0 }}>
<List dense>
{teilnahmen.map((t) => (
<ListItem
key={t.user_id}
onClick={() => toggle(t.user_id)}
sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Checkbox
checked={selected.has(t.user_id)}
size="small"
tabIndex={-1}
disableRipple
/>
</ListItemIcon>
<ListItemText
primary={t.user_name ?? t.user_email ?? t.user_id}
secondary={STATUS_LABEL[t.status]}
/>
</ListItem>
))}
</List>
</DialogContent>
<DialogActions sx={{ px: 2, pb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ flexGrow: 1 }}>
{selected.size} von {teilnahmen.length} ausgewählt
</Typography>
<Button onClick={onClose} disabled={mutation.isPending}>Abbrechen</Button>
<Button
variant="contained"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
startIcon={mutation.isPending ? <CircularProgress size={16} /> : <CheckIcon />}
>
Speichern
</Button>
</DialogActions>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Attendee Accordion
// ---------------------------------------------------------------------------
function AttendeeAccordion({
teilnahmen,
counts,
userRole,
}: {
teilnahmen?: Teilnahme[];
counts: {
anzahl_zugesagt: number;
anzahl_abgesagt: number;
anzahl_unbekannt: number;
anzahl_entschuldigt: number;
anzahl_erschienen: number;
gesamt_eingeladen: number;
};
userRole?: string;
}) {
const canSeeList = hasRole(userRole, 'gruppenfuehrer');
return (
<Accordion disableGutters elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<GroupIcon fontSize="small" color="action" />
<Typography variant="subtitle2">Rückmeldungen</Typography>
<Chip label={`${counts.anzahl_zugesagt} zugesagt`} size="small" color="success" variant="outlined" sx={{ fontSize: '0.65rem', height: 18 }} />
<Chip label={`${counts.anzahl_abgesagt} abgesagt`} size="small" color="error" variant="outlined" sx={{ fontSize: '0.65rem', height: 18 }} />
<Chip label={`${counts.anzahl_unbekannt} ausstehend`} size="small" color="default" variant="outlined" sx={{ fontSize: '0.65rem', height: 18 }} />
{counts.anzahl_erschienen > 0 && (
<Chip label={`${counts.anzahl_erschienen} erschienen`} size="small" color="primary" variant="outlined" sx={{ fontSize: '0.65rem', height: 18 }} />
)}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
{!canSeeList && (
<Alert severity="info" icon={<InfoIcon fontSize="small" />} sx={{ mb: 1 }}>
Nur Gruppenführer und Kommandanten sehen die individuelle Rückmeldungsliste.
</Alert>
)}
{canSeeList && teilnahmen && (
<List dense disablePadding>
{teilnahmen.map((t) => (
<ListItem key={t.user_id} disablePadding sx={{ py: 0.25 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<StatusIcon status={t.status} />
</ListItemIcon>
<ListItemText
primary={t.user_name ?? t.user_email ?? t.user_id}
secondary={
<Box component="span" sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<span>{STATUS_LABEL[t.status]}</span>
{t.bemerkung && (
<Typography component="span" variant="caption" color="text.disabled">
{t.bemerkung}
</Typography>
)}
</Box>
}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItem>
))}
</List>
)}
{canSeeList && !teilnahmen && (
<Typography variant="body2" color="text.secondary">
Keine Teilnehmer gefunden.
</Typography>
)}
</AccordionDetails>
</Accordion>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
export default function UebungDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const queryClient = useQueryClient();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// We cast user to include `role` (added by Tier 1)
const userRole = (user as any)?.role as string | undefined;
const canWrite = hasRole(userRole, 'gruppenfuehrer');
const [markAttendanceOpen, setMarkAttendanceOpen] = useState(false);
const [rsvpLoading, setRsvpLoading] = useState<'zugesagt' | 'abgesagt' | null>(null);
const { data: event, isLoading, isError } = useQuery({
queryKey: ['training', 'event', id],
queryFn: () => trainingApi.getById(id!),
enabled: Boolean(id),
});
const rsvpMutation = useMutation({
mutationFn: (status: 'zugesagt' | 'abgesagt') =>
trainingApi.updateRsvp(id!, status),
onSuccess: (_data, status) => {
queryClient.invalidateQueries({ queryKey: ['training', 'event', id] });
queryClient.invalidateQueries({ queryKey: ['training', 'upcoming'] });
setRsvpLoading(null);
},
onError: () => setRsvpLoading(null),
});
const handleRsvp = useCallback((status: 'zugesagt' | 'abgesagt') => {
setRsvpLoading(status);
rsvpMutation.mutate(status);
}, [rsvpMutation]);
// -------------------------------------------------------------------------
// Loading / error states
// -------------------------------------------------------------------------
if (isLoading) {
return (
<DashboardLayout>
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
<Skeleton variant="text" width={200} height={32} sx={{ mb: 1 }} />
<Skeleton variant="rectangular" height={160} sx={{ borderRadius: 2, mb: 2 }} />
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2, mb: 2 }} />
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2 }} />
</Box>
</DashboardLayout>
);
}
if (isError || !event) {
return (
<DashboardLayout>
<Alert severity="error" sx={{ maxWidth: 720, mx: 'auto' }}>
Veranstaltung konnte nicht geladen werden.
</Alert>
</DashboardLayout>
);
}
const isPast = new Date(event.datum_von) < new Date();
const isAlreadyRsvp = event.eigener_status === 'zugesagt' || event.eigener_status === 'abgesagt';
return (
<DashboardLayout>
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
{/* Back button */}
<Button
startIcon={<BackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 2, textTransform: 'none' }}
size="small"
>
Zurück zum Kalender
</Button>
{/* Cancelled banner */}
{event.abgesagt && (
<Alert severity="error" sx={{ mb: 2 }}>
<strong>Abgesagt:</strong> {event.absage_grund ?? 'Kein Grund angegeben.'}
</Alert>
)}
{/* Header card */}
<Paper elevation={1} sx={{ p: { xs: 2, sm: 3 }, mb: 2, borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1, flexWrap: 'wrap' }}>
<Chip
label={event.typ}
color={TYP_CHIP_COLOR[event.typ]}
size="small"
/>
{event.pflichtveranstaltung && (
<Chip
icon={<StarIcon sx={{ fontSize: 14 }} />}
label="Pflichtveranstaltung"
size="small"
color="warning"
variant="outlined"
/>
)}
{canWrite && (
<Tooltip title="Bearbeiten">
<Button
size="small"
startIcon={<EditIcon fontSize="small" />}
sx={{ ml: 'auto', textTransform: 'none' }}
onClick={() => navigate(`/training/${id}/bearbeiten`)}
>
{!isMobile && 'Bearbeiten'}
</Button>
</Tooltip>
)}
</Box>
<Typography
variant="h5"
sx={{
fontWeight: 700,
textDecoration: event.abgesagt ? 'line-through' : 'none',
mb: 1.5,
}}
>
{event.titel}
</Typography>
{/* Meta info */}
<Stack spacing={0.75}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TimeIcon fontSize="small" color="action" />
<Typography variant="body2">
{formatDateFull(event.datum_von)},{' '}
{formatTime(event.datum_von)} {formatTime(event.datum_bis)}
</Typography>
</Box>
{event.ort && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LocationIcon fontSize="small" color="action" />
<Typography variant="body2">{event.ort}</Typography>
</Box>
)}
{event.treffpunkt && event.treffpunkt !== event.ort && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LocationIcon fontSize="small" color="action" sx={{ opacity: 0.5 }} />
<Typography variant="body2" color="text.secondary">
Treffpunkt: {event.treffpunkt}
</Typography>
</Box>
)}
{event.angelegt_von_name && (
<Typography variant="caption" color="text.disabled">
Erstellt von {event.angelegt_von_name}
</Typography>
)}
</Stack>
</Paper>
{/* Description */}
{event.beschreibung && (
<Paper elevation={1} sx={{ p: { xs: 2, sm: 3 }, mb: 2, borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom>Beschreibung</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{event.beschreibung}
</Typography>
</Paper>
)}
{/* RSVP section */}
{!event.abgesagt && !isPast && (
<Paper elevation={1} sx={{ p: { xs: 2, sm: 3 }, mb: 2, borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Meine Rückmeldung
</Typography>
{event.eigener_status && event.eigener_status !== 'unbekannt' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<StatusIcon status={event.eigener_status} />
<Typography variant="body2">
Aktuelle Rückmeldung: <strong>{STATUS_LABEL[event.eigener_status]}</strong>
</Typography>
</Box>
)}
<Stack
direction={isMobile ? 'column' : 'row'}
spacing={1.5}
>
<Button
variant={event.eigener_status === 'zugesagt' ? 'contained' : 'outlined'}
color="success"
size="large"
startIcon={
rsvpLoading === 'zugesagt'
? <CircularProgress size={18} color="inherit" />
: <CheckIcon />
}
onClick={() => handleRsvp('zugesagt')}
disabled={rsvpMutation.isPending}
fullWidth={isMobile}
sx={{
minHeight: 56, // Large tap target per mobile requirement
fontWeight: 600,
fontSize: '1rem',
}}
>
Zusagen
</Button>
<Button
variant={event.eigener_status === 'abgesagt' ? 'contained' : 'outlined'}
color="error"
size="large"
startIcon={
rsvpLoading === 'abgesagt'
? <CircularProgress size={18} color="inherit" />
: <CancelIcon />
}
onClick={() => handleRsvp('abgesagt')}
disabled={rsvpMutation.isPending}
fullWidth={isMobile}
sx={{
minHeight: 56, // Large tap target per mobile requirement
fontWeight: 600,
fontSize: '1rem',
}}
>
Absagen
</Button>
</Stack>
</Paper>
)}
{/* Attendee summary + list */}
<Paper elevation={1} sx={{ p: { xs: 2, sm: 3 }, mb: 2, borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Typography variant="subtitle2">Teilnehmer</Typography>
{canWrite && !event.abgesagt && (
<Button
size="small"
variant="outlined"
startIcon={<AttendanceIcon fontSize="small" />}
onClick={() => setMarkAttendanceOpen(true)}
sx={{ textTransform: 'none' }}
>
Anwesenheit erfassen
</Button>
)}
</Box>
<AttendeeAccordion
teilnahmen={event.teilnahmen}
counts={event}
userRole={userRole}
/>
</Paper>
</Box>
{/* Mark Attendance Dialog */}
{event.teilnahmen && (
<MarkAttendanceDialog
open={markAttendanceOpen}
onClose={() => setMarkAttendanceOpen(false)}
uebungId={id!}
teilnahmen={event.teilnahmen}
/>
)}
</DashboardLayout>
);
}

View File

@@ -0,0 +1,733 @@
/**
* AuditLog — Admin page
*
* Displays the immutable audit trail with filtering, pagination, and CSV export.
* Uses server-side pagination via the DataGrid's paginationMode="server" prop.
*
* Required packages (add to frontend/package.json dependencies):
* "@mui/x-data-grid": "^6.x || ^7.x"
* "@mui/x-date-pickers": "^6.x || ^7.x"
* "date-fns": "^3.x"
* "@date-io/date-fns": "^3.x" (adapter for MUI date pickers)
*
* Install:
* npm install @mui/x-data-grid @mui/x-date-pickers date-fns @date-io/date-fns
*
* Route registration in App.tsx:
* import AuditLog from './pages/admin/AuditLog';
* // Inside <Routes>:
* <Route
* path="/admin/audit-log"
* element={
* <ProtectedRoute requireRole="admin">
* <AuditLog />
* </ProtectedRoute>
* }
* />
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Autocomplete,
Box,
Button,
Chip,
CircularProgress,
Container,
Dialog,
DialogContent,
DialogTitle,
Divider,
IconButton,
MenuItem,
Paper,
Select,
SelectChangeEvent,
Skeleton,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
DataGrid,
GridColDef,
GridPaginationModel,
GridRenderCellParams,
GridRowParams,
} from '@mui/x-data-grid';
import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { de } from 'date-fns/locale';
import { format, parseISO } from 'date-fns';
import CloseIcon from '@mui/icons-material/Close';
import DownloadIcon from '@mui/icons-material/Download';
import FilterAltIcon from '@mui/icons-material/FilterAlt';
import DashboardLayout from '../../components/dashboard/DashboardLayout';
import { api } from '../../services/api';
// ---------------------------------------------------------------------------
// Types — mirror the backend AuditLogEntry interface
// ---------------------------------------------------------------------------
type AuditAction =
| 'CREATE' | 'UPDATE' | 'DELETE'
| 'LOGIN' | 'LOGOUT' | 'EXPORT'
| 'PERMISSION_DENIED' | 'PASSWORD_CHANGE' | 'ROLE_CHANGE';
type AuditResourceType =
| 'MEMBER' | 'INCIDENT' | 'VEHICLE' | 'EQUIPMENT'
| 'QUALIFICATION' | 'USER' | 'SYSTEM';
interface AuditLogEntry {
id: string;
user_id: string | null;
user_email: string | null;
action: AuditAction;
resource_type: AuditResourceType;
resource_id: string | null;
old_value: Record<string, unknown> | null;
new_value: Record<string, unknown> | null;
ip_address: string | null;
user_agent: string | null;
metadata: Record<string, unknown>;
created_at: string; // ISO string from JSON
}
interface AuditLogPage {
entries: AuditLogEntry[];
total: number;
page: number;
pages: number;
}
interface AuditFilters {
userId?: string;
action?: AuditAction[];
resourceType?: AuditResourceType[];
dateFrom?: Date | null;
dateTo?: Date | null;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ALL_ACTIONS: AuditAction[] = [
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT',
'EXPORT', 'PERMISSION_DENIED', 'PASSWORD_CHANGE', 'ROLE_CHANGE',
];
const ALL_RESOURCE_TYPES: AuditResourceType[] = [
'MEMBER', 'INCIDENT', 'VEHICLE', 'EQUIPMENT',
'QUALIFICATION', 'USER', 'SYSTEM',
];
// ---------------------------------------------------------------------------
// Action chip colour map
// ---------------------------------------------------------------------------
const ACTION_COLORS: Record<AuditAction, 'success' | 'primary' | 'error' | 'warning' | 'default' | 'info'> = {
CREATE: 'success',
UPDATE: 'primary',
DELETE: 'error',
LOGIN: 'info',
LOGOUT: 'default',
EXPORT: 'warning',
PERMISSION_DENIED:'error',
PASSWORD_CHANGE: 'warning',
ROLE_CHANGE: 'warning',
};
// ---------------------------------------------------------------------------
// Utility helpers
// ---------------------------------------------------------------------------
function formatTimestamp(isoString: string): string {
try {
return format(parseISO(isoString), 'dd.MM.yyyy HH:mm:ss', { locale: de });
} catch {
return isoString;
}
}
function truncate(value: string | null | undefined, maxLength = 24): string {
if (!value) return '—';
return value.length > maxLength ? value.substring(0, maxLength) + '…' : value;
}
// ---------------------------------------------------------------------------
// JSON diff viewer (simple before / after display)
// ---------------------------------------------------------------------------
interface JsonDiffViewerProps {
oldValue: Record<string, unknown> | null;
newValue: Record<string, unknown> | null;
}
const JsonDiffViewer: React.FC<JsonDiffViewerProps> = ({ oldValue, newValue }) => {
if (!oldValue && !newValue) {
return <Typography variant="body2" color="text.secondary">Keine Datenaenderung aufgezeichnet.</Typography>;
}
const allKeys = Array.from(
new Set([
...Object.keys(oldValue ?? {}),
...Object.keys(newValue ?? {}),
])
).sort();
return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{oldValue && (
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography variant="overline" color="error.main">Vorher</Typography>
<Paper
variant="outlined"
sx={{
p: 1.5, mt: 0.5,
backgroundColor: 'error.50',
fontFamily: 'monospace',
fontSize: '0.75rem',
overflowX: 'auto',
maxHeight: 320,
}}
>
<pre style={{ margin: 0 }}>
{JSON.stringify(oldValue, null, 2)}
</pre>
</Paper>
</Box>
)}
{newValue && (
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography variant="overline" color="success.main">Nachher</Typography>
<Paper
variant="outlined"
sx={{
p: 1.5, mt: 0.5,
backgroundColor: 'success.50',
fontFamily: 'monospace',
fontSize: '0.75rem',
overflowX: 'auto',
maxHeight: 320,
}}
>
<pre style={{ margin: 0 }}>
{JSON.stringify(newValue, null, 2)}
</pre>
</Paper>
</Box>
)}
{/* Highlight changed fields */}
{oldValue && newValue && allKeys.length > 0 && (
<Box sx={{ width: '100%', mt: 1 }}>
<Typography variant="overline">Geaenderte Felder</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
{allKeys.map((key) => {
const changed =
JSON.stringify((oldValue as Record<string, unknown>)[key]) !==
JSON.stringify((newValue as Record<string, unknown>)[key]);
if (!changed) return null;
return (
<Chip
key={key}
label={key}
size="small"
color="warning"
variant="outlined"
/>
);
})}
</Box>
</Box>
)}
</Box>
);
};
// ---------------------------------------------------------------------------
// Entry detail dialog
// ---------------------------------------------------------------------------
interface EntryDialogProps {
entry: AuditLogEntry | null;
onClose: () => void;
showIp: boolean;
}
const EntryDialog: React.FC<EntryDialogProps> = ({ entry, onClose, showIp }) => {
if (!entry) return null;
return (
<Dialog open={!!entry} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="h6">
Audit-Eintrag {entry.action} / {entry.resource_type}
</Typography>
<IconButton onClick={onClose} size="small" aria-label="Schliessen">
<CloseIcon />
</IconButton>
</Stack>
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<Box>
<Typography variant="overline">Metadaten</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '4px 16px',
mt: 0.5,
}}
>
{[
['Zeitpunkt', formatTimestamp(entry.created_at)],
['Benutzer', entry.user_email ?? '—'],
['Benutzer-ID', entry.user_id ?? '—'],
['Aktion', entry.action],
['Ressourcentyp', entry.resource_type],
['Ressourcen-ID', entry.resource_id ?? '—'],
...(showIp ? [['IP-Adresse', entry.ip_address ?? '—']] : []),
].map(([label, value]) => (
<React.Fragment key={label}>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{label}:
</Typography>
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
{value}
</Typography>
</React.Fragment>
))}
</Box>
</Box>
{Object.keys(entry.metadata ?? {}).length > 0 && (
<Box>
<Typography variant="overline">Zusatzdaten</Typography>
<Paper variant="outlined" sx={{ p: 1, mt: 0.5, fontFamily: 'monospace', fontSize: '0.75rem' }}>
<pre style={{ margin: 0 }}>{JSON.stringify(entry.metadata, null, 2)}</pre>
</Paper>
</Box>
)}
<Divider />
<Box>
<Typography variant="overline">Datenaenderung</Typography>
<Box sx={{ mt: 1 }}>
<JsonDiffViewer oldValue={entry.old_value} newValue={entry.new_value} />
</Box>
</Box>
</Stack>
</DialogContent>
</Dialog>
);
};
// ---------------------------------------------------------------------------
// Filter panel
// ---------------------------------------------------------------------------
interface FilterPanelProps {
filters: AuditFilters;
onChange: (f: AuditFilters) => void;
onReset: () => void;
}
const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, onReset }) => {
return (
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterAltIcon fontSize="small" />
Filter
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{/* Date range */}
<DatePicker
label="Von"
value={filters.dateFrom ?? null}
onChange={(date) => onChange({ ...filters, dateFrom: date })}
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }}
/>
<DatePicker
label="Bis"
value={filters.dateTo ?? null}
onChange={(date) => onChange({ ...filters, dateTo: date })}
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }}
/>
{/* Action multi-select */}
<Autocomplete
multiple
options={ALL_ACTIONS}
value={filters.action ?? []}
onChange={(_, value) => onChange({ ...filters, action: value as AuditAction[] })}
renderInput={(params) => (
<TextField {...params} label="Aktionen" size="small" sx={{ minWidth: 200 }} />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option}
label={option}
size="small"
color={ACTION_COLORS[option]}
/>
))
}
/>
{/* Resource type multi-select */}
<Autocomplete
multiple
options={ALL_RESOURCE_TYPES}
value={filters.resourceType ?? []}
onChange={(_, value) => onChange({ ...filters, resourceType: value as AuditResourceType[] })}
renderInput={(params) => (
<TextField {...params} label="Ressourcentypen" size="small" sx={{ minWidth: 200 }} />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip {...getTagProps({ index })} key={option} label={option} size="small" />
))
}
/>
<Button variant="outlined" size="small" onClick={onReset} sx={{ alignSelf: 'flex-end' }}>
Zuruecksetzen
</Button>
</Box>
</Stack>
</Paper>
);
};
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
const DEFAULT_FILTERS: AuditFilters = {
action: [],
resourceType: [],
dateFrom: null,
dateTo: null,
};
const AuditLog: React.FC = () => {
// Grid state
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0, // DataGrid is 0-based
pageSize: 25,
});
const [rowCount, setRowCount] = useState(0);
const [rows, setRows] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters
const [filters, setFilters] = useState<AuditFilters>(DEFAULT_FILTERS);
const [appliedFilters, setApplied]= useState<AuditFilters>(DEFAULT_FILTERS);
// Detail dialog
const [selectedEntry, setSelected]= useState<AuditLogEntry | null>(null);
// Export state
const [exporting, setExporting] = useState(false);
// The admin always sees IPs — toggle this based on role check if needed
const showIp = true;
// -------------------------------------------------------------------------
// Data fetching
// -------------------------------------------------------------------------
const fetchData = useCallback(async (
pagination: GridPaginationModel,
f: AuditFilters,
) => {
setLoading(true);
setError(null);
try {
const params: Record<string, string> = {
page: String(pagination.page + 1), // convert 0-based to 1-based
pageSize: String(pagination.pageSize),
};
if (f.dateFrom) params.dateFrom = f.dateFrom.toISOString();
if (f.dateTo) params.dateTo = f.dateTo.toISOString();
if (f.action && f.action.length > 0) {
params.action = f.action.join(',');
}
if (f.resourceType && f.resourceType.length > 0) {
params.resourceType = f.resourceType.join(',');
}
if (f.userId) params.userId = f.userId;
const queryString = new URLSearchParams(params).toString();
const response = await api.get<{ success: boolean; data: AuditLogPage }>(
`/admin/audit-log?${queryString}`
);
setRows(response.data.data.entries);
setRowCount(response.data.data.total);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unbekannter Fehler';
setError(`Audit-Log konnte nicht geladen werden: ${msg}`);
} finally {
setLoading(false);
}
}, []);
// Fetch when pagination or applied filters change
useEffect(() => {
fetchData(paginationModel, appliedFilters);
}, [paginationModel, appliedFilters, fetchData]);
// -------------------------------------------------------------------------
// Filter handlers
// -------------------------------------------------------------------------
const handleApplyFilters = () => {
setApplied(filters);
setPaginationModel((prev) => ({ ...prev, page: 0 }));
};
const handleResetFilters = () => {
setFilters(DEFAULT_FILTERS);
setApplied(DEFAULT_FILTERS);
setPaginationModel((prev) => ({ ...prev, page: 0 }));
};
// -------------------------------------------------------------------------
// CSV export
// -------------------------------------------------------------------------
const handleExport = async () => {
setExporting(true);
try {
const params: Record<string, string> = {};
if (appliedFilters.dateFrom) params.dateFrom = appliedFilters.dateFrom.toISOString();
if (appliedFilters.dateTo) params.dateTo = appliedFilters.dateTo.toISOString();
if (appliedFilters.action && appliedFilters.action.length > 0) {
params.action = appliedFilters.action.join(',');
}
if (appliedFilters.resourceType && appliedFilters.resourceType.length > 0) {
params.resourceType = appliedFilters.resourceType.join(',');
}
const queryString = new URLSearchParams(params).toString();
const response = await api.get<Blob>(
`/admin/audit-log/export?${queryString}`,
{ responseType: 'blob' }
);
const url = URL.createObjectURL(response.data);
const filename = `audit_log_${format(new Date(), 'yyyy-MM-dd_HH-mm')}.csv`;
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch {
setError('CSV-Export fehlgeschlagen. Bitte versuchen Sie es erneut.');
} finally {
setExporting(false);
}
};
// -------------------------------------------------------------------------
// Column definitions
// -------------------------------------------------------------------------
const columns: GridColDef<AuditLogEntry>[] = useMemo(() => [
{
field: 'created_at',
headerName: 'Zeitpunkt',
width: 160,
valueFormatter: (value: string) => formatTimestamp(value),
sortable: false,
},
{
field: 'user_email',
headerName: 'Benutzer',
flex: 1,
minWidth: 160,
renderCell: (params: GridRenderCellParams<AuditLogEntry>) =>
params.value ? (
<Tooltip title={params.row.user_id ?? ''}>
<Typography variant="body2" noWrap>{params.value}</Typography>
</Tooltip>
) : (
<Typography variant="body2" color="text.disabled"></Typography>
),
sortable: false,
},
{
field: 'action',
headerName: 'Aktion',
width: 160,
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
<Chip
label={params.value as string}
size="small"
color={ACTION_COLORS[params.value as AuditAction] ?? 'default'}
/>
),
sortable: false,
},
{
field: 'resource_type',
headerName: 'Ressourcentyp',
width: 140,
sortable: false,
},
{
field: 'resource_id',
headerName: 'Ressourcen-ID',
width: 130,
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
<Tooltip title={params.value ?? ''}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
{truncate(params.value, 12)}
</Typography>
</Tooltip>
),
sortable: false,
},
...(showIp ? [{
field: 'ip_address',
headerName: 'IP-Adresse',
width: 140,
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
{params.value ?? '—'}
</Typography>
),
sortable: false,
} as GridColDef<AuditLogEntry>] : []),
], [showIp]);
// -------------------------------------------------------------------------
// Loading skeleton
// -------------------------------------------------------------------------
if (loading && rows.length === 0) {
return (
<DashboardLayout>
<Container maxWidth="xl">
<Skeleton variant="text" width={300} height={48} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" height={80} sx={{ mb: 2, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 1 }} />
</Container>
</DashboardLayout>
);
}
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={de}>
<DashboardLayout>
<Container maxWidth="xl">
{/* Header */}
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ mb: 3 }}
>
<Box>
<Typography variant="h4">Audit-Protokoll</Typography>
<Typography variant="body2" color="text.secondary">
DSGVO Art. 5(2) Unveraenderliches Protokoll aller Datenzugriffe
</Typography>
</Box>
<Button
variant="contained"
startIcon={exporting ? <CircularProgress size={16} color="inherit" /> : <DownloadIcon />}
disabled={exporting}
onClick={handleExport}
>
CSV-Export
</Button>
</Stack>
{/* Error */}
{error && (
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* Filter panel */}
<FilterPanel
filters={filters}
onChange={setFilters}
onReset={handleResetFilters}
/>
{/* Apply filters button */}
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="outlined" size="small" onClick={handleApplyFilters}>
Filter anwenden
</Button>
</Box>
{/* Data grid */}
<Paper variant="outlined" sx={{ width: '100%' }}>
<DataGrid<AuditLogEntry>
rows={rows}
columns={columns}
rowCount={rowCount}
loading={loading}
paginationMode="server"
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[25, 50, 100]}
disableRowSelectionOnClick={false}
onRowClick={(params: GridRowParams<AuditLogEntry>) =>
setSelected(params.row)
}
sx={{
border: 'none',
'& .MuiDataGrid-row': { cursor: 'pointer' },
'& .MuiDataGrid-row:hover': {
backgroundColor: 'action.hover',
},
}}
localeText={{
noRowsLabel: 'Keine Eintraege gefunden',
MuiTablePagination: {
labelRowsPerPage: 'Eintraege pro Seite:',
labelDisplayedRows: ({ from, to, count }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`,
},
}}
autoHeight
/>
</Paper>
{/* Detail dialog */}
<EntryDialog
entry={selectedEntry}
onClose={() => setSelected(null)}
showIp={showIp}
/>
</Container>
</DashboardLayout>
</LocalizationProvider>
);
};
export default AuditLog;

View File

@@ -0,0 +1,299 @@
import { api } from './api';
// ---------------------------------------------------------------------------
// SHARED TYPES (mirrors backend models, kept lean for frontend consumption)
// ---------------------------------------------------------------------------
export const EINSATZ_ARTEN = [
'Brand',
'THL',
'ABC',
'BMA',
'Hilfeleistung',
'Fehlalarm',
'Brandsicherheitswache',
] as const;
export type EinsatzArt = (typeof EINSATZ_ARTEN)[number];
export const EINSATZ_ART_LABELS: Record<EinsatzArt, string> = {
Brand: 'Brand',
THL: 'Technische Hilfeleistung',
ABC: 'ABC / Gefahrgut',
BMA: 'Brandmeldeanlage',
Hilfeleistung: 'Hilfeleistung',
Fehlalarm: 'Fehlalarm',
Brandsicherheitswache: 'Brandsicherheitswache',
};
export const EINSATZ_STATUS = ['aktiv', 'abgeschlossen', 'archiviert'] as const;
export type EinsatzStatus = (typeof EINSATZ_STATUS)[number];
export const EINSATZ_STATUS_LABELS: Record<EinsatzStatus, string> = {
aktiv: 'Aktiv',
abgeschlossen: 'Abgeschlossen',
archiviert: 'Archiviert',
};
export const EINSATZ_FUNKTIONEN = [
'Einsatzleiter',
'Gruppenführer',
'Maschinist',
'Atemschutz',
'Sicherheitstrupp',
'Melder',
'Wassertrupp',
'Angriffstrupp',
'Mannschaft',
'Sonstiges',
] as const;
export type EinsatzFunktion = (typeof EINSATZ_FUNKTIONEN)[number];
// ---------------------------------------------------------------------------
// API RESPONSE SHAPES
// ---------------------------------------------------------------------------
export interface EinsatzListItem {
id: string;
einsatz_nr: string;
alarm_time: string; // ISO 8601 string from JSON
einsatz_art: EinsatzArt;
einsatz_stichwort: string | null;
ort: string | null;
strasse: string | null;
status: EinsatzStatus;
einsatzleiter_name: string | null;
hilfsfrist_min: number | null;
dauer_min: number | null;
personal_count: number;
}
export interface EinsatzPersonal {
einsatz_id: string;
user_id: string;
funktion: EinsatzFunktion;
alarm_time: string | null;
ankunft_time: string | null;
assigned_at: string;
name: string | null;
email: string;
given_name: string | null;
family_name: string | null;
}
export interface EinsatzFahrzeug {
einsatz_id: string;
fahrzeug_id: string;
ausrueck_time: string | null;
einrueck_time: string | null;
assigned_at: string;
kennzeichen: string;
bezeichnung: string;
fahrzeug_typ: string | null;
}
export interface EinsatzDetail {
id: string;
einsatz_nr: string;
alarm_time: string;
ausrueck_time: string | null;
ankunft_time: string | null;
einrueck_time: string | null;
einsatz_art: EinsatzArt;
einsatz_stichwort: string | null;
strasse: string | null;
hausnummer: string | null;
ort: string | null;
bericht_kurz: string | null;
bericht_text: string | null; // undefined/null for roles below Kommandant
einsatzleiter_id: string | null;
einsatzleiter_name: string | null;
alarmierung_art: string;
status: EinsatzStatus;
created_by: string | null;
created_at: string;
updated_at: string;
hilfsfrist_min: number | null;
dauer_min: number | null;
fahrzeuge: EinsatzFahrzeug[];
personal: EinsatzPersonal[];
}
export interface MonthlyStatRow {
monat: number;
anzahl: number;
avg_hilfsfrist_min: number | null;
avg_dauer_min: number | null;
}
export interface EinsatzArtStatRow {
einsatz_art: EinsatzArt;
anzahl: number;
avg_hilfsfrist_min: number | null;
}
export interface EinsatzStats {
jahr: number;
gesamt: number;
abgeschlossen: number;
aktiv: number;
avg_hilfsfrist_min: number | null;
haeufigste_art: EinsatzArt | null;
monthly: MonthlyStatRow[];
by_art: EinsatzArtStatRow[];
prev_year_monthly: MonthlyStatRow[];
}
export interface IncidentListResponse {
items: EinsatzListItem[];
total: number;
limit: number;
offset: number;
}
// ---------------------------------------------------------------------------
// REQUEST SHAPES
// ---------------------------------------------------------------------------
export interface IncidentFilters {
dateFrom?: string;
dateTo?: string;
einsatzArt?: EinsatzArt;
status?: EinsatzStatus;
limit?: number;
offset?: number;
}
export interface CreateEinsatzPayload {
alarm_time: string;
ausrueck_time?: string | null;
ankunft_time?: string | null;
einrueck_time?: string | null;
einsatz_art: EinsatzArt;
einsatz_stichwort?: string | null;
strasse?: string | null;
hausnummer?: string | null;
ort?: string | null;
bericht_kurz?: string | null;
bericht_text?: string | null;
einsatzleiter_id?: string | null;
alarmierung_art?: string;
status?: EinsatzStatus;
}
export type UpdateEinsatzPayload = Partial<CreateEinsatzPayload>;
export interface AssignPersonnelPayload {
user_id: string;
funktion?: EinsatzFunktion;
alarm_time?: string | null;
ankunft_time?: string | null;
}
export interface AssignVehiclePayload {
fahrzeug_id: string;
ausrueck_time?: string | null;
einrueck_time?: string | null;
}
// ---------------------------------------------------------------------------
// API CALLS
// ---------------------------------------------------------------------------
export const incidentsApi = {
/**
* Fetch paginated incident list with optional filters.
*/
async getAll(filters: IncidentFilters = {}): Promise<IncidentListResponse> {
const params = new URLSearchParams();
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom);
if (filters.dateTo) params.set('dateTo', filters.dateTo);
if (filters.einsatzArt) params.set('einsatzArt', filters.einsatzArt);
if (filters.status) params.set('status', filters.status);
if (filters.limit !== undefined) params.set('limit', String(filters.limit));
if (filters.offset !== undefined) params.set('offset', String(filters.offset));
const response = await api.get<{ success: boolean; data: IncidentListResponse }>(
`/api/incidents?${params.toString()}`
);
return response.data.data;
},
/**
* Fetch aggregated statistics for a given year.
*/
async getStats(year?: number): Promise<EinsatzStats> {
const params = year ? `?year=${year}` : '';
const response = await api.get<{ success: boolean; data: EinsatzStats }>(
`/api/incidents/stats${params}`
);
return response.data.data;
},
/**
* Fetch a single incident with full detail.
*/
async getById(id: string): Promise<EinsatzDetail> {
const response = await api.get<{ success: boolean; data: EinsatzDetail }>(
`/api/incidents/${id}`
);
return response.data.data;
},
/**
* Create a new incident. Returns the created Einsatz row.
*/
async create(payload: CreateEinsatzPayload): Promise<EinsatzDetail> {
const response = await api.post<{ success: boolean; data: EinsatzDetail }>(
'/api/incidents',
payload
);
return response.data.data;
},
/**
* Partially update an incident.
*/
async update(id: string, payload: UpdateEinsatzPayload): Promise<EinsatzDetail> {
const response = await api.patch<{ success: boolean; data: EinsatzDetail }>(
`/api/incidents/${id}`,
payload
);
return response.data.data;
},
/**
* Soft-delete (archive) an incident.
*/
async delete(id: string): Promise<void> {
await api.delete(`/api/incidents/${id}`);
},
/**
* Assign a member to an incident.
*/
async assignPersonnel(einsatzId: string, payload: AssignPersonnelPayload): Promise<void> {
await api.post(`/api/incidents/${einsatzId}/personnel`, payload);
},
/**
* Remove a member from an incident.
*/
async removePersonnel(einsatzId: string, userId: string): Promise<void> {
await api.delete(`/api/incidents/${einsatzId}/personnel/${userId}`);
},
/**
* Assign a vehicle to an incident.
*/
async assignVehicle(einsatzId: string, payload: AssignVehiclePayload): Promise<void> {
await api.post(`/api/incidents/${einsatzId}/vehicles`, payload);
},
/**
* Remove a vehicle from an incident.
*/
async removeVehicle(einsatzId: string, fahrzeugId: string): Promise<void> {
await api.delete(`/api/incidents/${einsatzId}/vehicles/${fahrzeugId}`);
},
};

View File

@@ -0,0 +1,113 @@
import { api } from './api';
import {
MemberListItem,
MemberWithProfile,
MemberFilters,
MemberStats,
CreateMemberProfileData,
UpdateMemberProfileData,
} from '../types/member.types';
// ----------------------------------------------------------------
// Response envelope shapes
// ----------------------------------------------------------------
interface ApiListResponse<T> {
success: boolean;
data: T[];
meta: { total: number; page: number };
}
interface ApiItemResponse<T> {
success: boolean;
data: T;
}
// ----------------------------------------------------------------
// Service
// ----------------------------------------------------------------
/**
* Builds a URLSearchParams object from the filter object so query
* strings like ?status[]=aktiv&status[]=passiv are sent correctly.
*/
function buildParams(filters?: MemberFilters): URLSearchParams {
const params = new URLSearchParams();
if (!filters) return params;
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.pageSize) params.append('pageSize', String(filters.pageSize));
filters.status?.forEach((s) => params.append('status[]', s));
filters.dienstgrad?.forEach((d) => params.append('dienstgrad[]', d));
return params;
}
export const membersService = {
/**
* Fetches a paginated, optionally filtered list of members.
*/
async getMembers(
filters?: MemberFilters
): Promise<{ items: MemberListItem[]; total: number; page: number }> {
const params = buildParams(filters);
const response = await api.get<ApiListResponse<MemberListItem>>(
`/api/members?${params.toString()}`
);
return {
items: response.data.data,
total: response.data.meta.total,
page: response.data.meta.page,
};
},
/**
* Fetches a single member with their full profile and rank history.
*/
async getMember(userId: string): Promise<MemberWithProfile> {
const response = await api.get<ApiItemResponse<MemberWithProfile>>(
`/api/members/${userId}`
);
return response.data.data;
},
/**
* Creates a new member profile for an existing auth user.
* Restricted to Kommandant/Admin (enforced server-side).
*/
async createMemberProfile(
userId: string,
data: CreateMemberProfileData
): Promise<MemberWithProfile> {
const response = await api.post<ApiItemResponse<MemberWithProfile>>(
`/api/members/${userId}/profile`,
data
);
return response.data.data;
},
/**
* Partially updates a member profile.
* Kommandant/Admin: full update.
* Own profile: limited fields only (enforced server-side).
*/
async updateMember(
userId: string,
data: UpdateMemberProfileData
): Promise<MemberWithProfile> {
const response = await api.patch<ApiItemResponse<MemberWithProfile>>(
`/api/members/${userId}`,
data
);
return response.data.data;
},
/**
* Fetches aggregate counts for the dashboard KPI widget.
*/
async getMemberStats(): Promise<MemberStats> {
const response = await api.get<ApiItemResponse<MemberStats>>('/api/members/stats');
return response.data.data;
},
};

View File

@@ -0,0 +1,131 @@
import { api } from './api';
import { API_URL } from '../utils/config';
import type {
Uebung,
UebungWithAttendance,
UebungListItem,
MemberParticipationStats,
CreateUebungData,
UpdateUebungData,
} from '../types/training.types';
// ---------------------------------------------------------------------------
// Response shapes from the backend
// ---------------------------------------------------------------------------
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// ---------------------------------------------------------------------------
// Helper: iCal subscribe URL
// ---------------------------------------------------------------------------
export function buildIcalUrl(token: string): string {
const base = API_URL.replace(/\/$/, '');
return `${base}/api/training/calendar.ics?token=${token}`;
}
// ---------------------------------------------------------------------------
// Training API service
// ---------------------------------------------------------------------------
export const trainingApi = {
// -------------------------------------------------------------------------
// Event listing
// -------------------------------------------------------------------------
/** Upcoming events (dashboard widget, list view) */
getUpcoming(limit = 10): Promise<UebungListItem[]> {
return api
.get<ApiResponse<UebungListItem[]>>('/api/training', { params: { limit } })
.then((r) => r.data.data);
},
/** Events in a date range for the month calendar view */
getCalendarRange(from: Date, to: Date): Promise<UebungListItem[]> {
return api
.get<ApiResponse<UebungListItem[]>>('/api/training/calendar', {
params: {
from: from.toISOString(),
to: to.toISOString(),
},
})
.then((r) => r.data.data);
},
/** Full event detail with attendance data */
getById(id: string): Promise<UebungWithAttendance> {
return api
.get<ApiResponse<UebungWithAttendance>>(`/api/training/${id}`)
.then((r) => r.data.data);
},
// -------------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------------
createEvent(data: CreateUebungData): Promise<Uebung> {
return api
.post<ApiResponse<Uebung>>('/api/training', data)
.then((r) => r.data.data);
},
updateEvent(id: string, data: Partial<UpdateUebungData>): Promise<Uebung> {
return api
.patch<ApiResponse<Uebung>>(`/api/training/${id}`, data)
.then((r) => r.data.data);
},
cancelEvent(id: string, absage_grund: string): Promise<void> {
return api
.delete(`/api/training/${id}`, { data: { absage_grund } })
.then(() => undefined);
},
// -------------------------------------------------------------------------
// Attendance / RSVP
// -------------------------------------------------------------------------
/** Member updates own RSVP */
updateRsvp(
uebungId: string,
status: 'zugesagt' | 'abgesagt',
bemerkung?: string
): Promise<void> {
return api
.patch(`/api/training/${uebungId}/attendance`, { status, bemerkung })
.then(() => undefined);
},
/** Gruppenführer bulk-marks attendance */
markAttendance(uebungId: string, userIds: string[]): Promise<void> {
return api
.post(`/api/training/${uebungId}/attendance/mark`, { userIds })
.then(() => undefined);
},
// -------------------------------------------------------------------------
// Stats
// -------------------------------------------------------------------------
getMemberStats(year?: number): Promise<MemberParticipationStats[]> {
return api
.get<ApiResponse<MemberParticipationStats[]>>('/api/training/stats', {
params: { year: year ?? new Date().getFullYear() },
})
.then((r) => r.data.data);
},
// -------------------------------------------------------------------------
// iCal
// -------------------------------------------------------------------------
/** Get the user's personal calendar subscribe URL */
getCalendarToken(): Promise<{ token: string; subscribeUrl: string; instructions: string }> {
return api
.get<ApiResponse<{ token: string; subscribeUrl: string; instructions: string }>>(
'/api/training/calendar-token'
)
.then((r) => r.data.data);
},
};

View File

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

View File

@@ -0,0 +1,194 @@
// ----------------------------------------------------------------
// Frontend mirror of backend/src/models/member.model.ts
// Keep in sync when the model changes.
// ----------------------------------------------------------------
export const DIENSTGRAD_VALUES = [
'Feuerwehranwärter',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann',
] as const;
export const STATUS_VALUES = [
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten',
] as const;
export const FUNKTION_VALUES = [
'Kommandant',
'Stellv. Kommandant',
'Gruppenführer',
'Truppführer',
'Gerätewart',
'Kassier',
'Schriftführer',
'Atemschutzwart',
'Ausbildungsbeauftragter',
] as const;
export const TSHIRT_GROESSE_VALUES = [
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL',
] as const;
export type DienstgradEnum = typeof DIENSTGRAD_VALUES[number];
export type StatusEnum = typeof STATUS_VALUES[number];
export type FunktionEnum = typeof FUNKTION_VALUES[number];
export type TshirtGroesseEnum = typeof TSHIRT_GROESSE_VALUES[number];
export interface MitgliederProfile {
id: string;
user_id: string;
mitglieds_nr: string | null;
dienstgrad: DienstgradEnum | null;
dienstgrad_seit: string | null; // ISO date string from API
funktion: FunktionEnum[];
status: StatusEnum;
eintrittsdatum: string | null;
austrittsdatum: string | null;
geburtsdatum: string | null; // null when redacted by server
_age?: number; // synthesised when geburtsdatum is redacted
telefon_mobil: string | null;
telefon_privat: string | null;
notfallkontakt_name: string | null;
notfallkontakt_telefon: string | null;
fuehrerscheinklassen: string[];
tshirt_groesse: TshirtGroesseEnum | null;
schuhgroesse: string | null;
bemerkungen: string | null;
bild_url: string | null;
created_at: string;
updated_at: string;
}
export interface DienstgradVerlaufEntry {
id: string;
user_id: string;
dienstgrad_neu: string;
dienstgrad_alt: string | null;
datum: string;
durch_user_id: string | null;
durch_user_name?: string | null;
bemerkung: string | null;
created_at: string;
}
export interface MemberWithProfile {
id: string;
email: string;
name: string | null;
given_name: string | null;
family_name: string | null;
preferred_username: string | null;
profile_picture_url: string | null;
is_active: boolean;
last_login_at: string | null;
created_at: string;
profile: MitgliederProfile | null;
dienstgrad_verlauf?: DienstgradVerlaufEntry[];
}
export interface MemberListItem {
id: string;
name: string | null;
given_name: string | null;
family_name: string | null;
email: string;
profile_picture_url: string | null;
is_active: boolean;
profile_id: string | null;
mitglieds_nr: string | null;
dienstgrad: DienstgradEnum | null;
funktion: FunktionEnum[];
status: StatusEnum | null;
eintrittsdatum: string | null;
telefon_mobil: string | null;
}
export interface MemberFilters {
search?: string;
status?: StatusEnum[];
dienstgrad?: DienstgradEnum[];
page?: number;
pageSize?: number;
}
export type CreateMemberProfileData = Partial<Omit<MitgliederProfile, 'id' | 'user_id' | 'created_at' | 'updated_at'>>;
export type UpdateMemberProfileData = CreateMemberProfileData;
export interface MemberStats {
total: number;
aktiv: number;
passiv: number;
ehrenmitglied: number;
jugendfeuerwehr: number;
'anwärter': number;
ausgetreten: number;
}
// ----------------------------------------------------------------
// Display helpers
// ----------------------------------------------------------------
/** Returns the display name built from given/family name or email fallback */
export function getMemberDisplayName(
member: Pick<MemberWithProfile | MemberListItem, 'given_name' | 'family_name' | 'name' | 'email'>
): string {
if (member.given_name || member.family_name) {
return [member.given_name, member.family_name].filter(Boolean).join(' ');
}
return member.name || member.email;
}
/** Format a German phone number for display.
* Stored raw; displayed with spaces for readability.
* e.g. "+436641234567" → "+43 664 123 4567"
* Falls back to raw value for unrecognised formats. */
export function formatPhone(raw: string | null | undefined): string {
if (!raw) return '—';
const digits = raw.replace(/\s/g, '');
// Austrian mobile: +43 6xx xxx xxxx
const atMobile = digits.match(/^(\+43)(6\d{2})(\d{3,4})(\d{4})$/);
if (atMobile) return `${atMobile[1]} ${atMobile[2]} ${atMobile[3]} ${atMobile[4]}`;
// German mobile: +49 1xx xxx xxxxx
const deMobile = digits.match(/^(\+49)(1\d{2})(\d{3,4})(\d{4,5})$/);
if (deMobile) return `${deMobile[1]} ${deMobile[2]} ${deMobile[3]} ${deMobile[4]}`;
return raw;
}
/** Returns a human-readable status label */
export const STATUS_LABELS: Record<StatusEnum, string> = {
aktiv: 'Aktiv',
passiv: 'Passiv',
ehrenmitglied: 'Ehrenmitglied',
jugendfeuerwehr: 'Jugendfeuerwehr',
anwärter: 'Anwärter',
ausgetreten: 'Ausgetreten',
};
/** MUI Chip color for each status */
export const STATUS_COLORS: Record<StatusEnum, 'success' | 'warning' | 'error' | 'info' | 'default'> = {
aktiv: 'success',
passiv: 'warning',
ehrenmitglied: 'info',
jugendfeuerwehr: 'info',
anwärter: 'default',
ausgetreten: 'error',
};

View File

@@ -0,0 +1,115 @@
// ---------------------------------------------------------------------------
// Frontend training types — mirrors backend/src/models/training.model.ts
// ---------------------------------------------------------------------------
export const UEBUNG_TYPEN = [
'Übungsabend',
'Lehrgang',
'Sonderdienst',
'Versammlung',
'Gemeinschaftsübung',
'Sonstiges',
] as const;
export type UebungTyp = (typeof UEBUNG_TYPEN)[number];
export const TEILNAHME_STATUSES = [
'zugesagt',
'abgesagt',
'erschienen',
'entschuldigt',
'unbekannt',
] as const;
export type TeilnahmeStatus = (typeof TEILNAHME_STATUSES)[number];
export interface Uebung {
id: string;
titel: string;
beschreibung?: string | null;
typ: UebungTyp;
datum_von: string; // ISO string from JSON
datum_bis: string;
ort?: string | null;
treffpunkt?: string | null;
pflichtveranstaltung: boolean;
mindest_teilnehmer?: number | null;
max_teilnehmer?: number | null;
angelegt_von?: string | null;
erstellt_am: string;
aktualisiert_am: string;
abgesagt: boolean;
absage_grund?: string | null;
}
export interface Teilnahme {
uebung_id: string;
user_id: string;
status: TeilnahmeStatus;
antwort_am?: string | null;
erschienen_erfasst_am?: string | null;
erschienen_erfasst_von?: string | null;
bemerkung?: string | null;
user_name?: string | null;
user_email?: string | null;
}
export interface AttendanceCounts {
gesamt_eingeladen: number;
anzahl_zugesagt: number;
anzahl_abgesagt: number;
anzahl_erschienen: number;
anzahl_entschuldigt: number;
anzahl_unbekannt: number;
}
export interface UebungWithAttendance extends Uebung, AttendanceCounts {
teilnahmen?: Teilnahme[];
eigener_status?: TeilnahmeStatus;
angelegt_von_name?: string | null;
}
export interface UebungListItem {
id: string;
titel: string;
typ: UebungTyp;
datum_von: string;
datum_bis: string;
ort?: string | null;
pflichtveranstaltung: boolean;
abgesagt: boolean;
anzahl_zugesagt: number;
anzahl_erschienen: number;
gesamt_eingeladen: number;
eigener_status?: TeilnahmeStatus;
}
export interface MemberParticipationStats {
userId: string;
name: string;
totalUebungen: number;
attended: number;
attendancePercent: number;
pflichtGesamt: number;
pflichtErschienen: number;
uebungsabendQuotePct: number;
}
// ---------------------------------------------------------------------------
// Form data types (sent to API)
// ---------------------------------------------------------------------------
export interface CreateUebungData {
titel: string;
beschreibung?: string | null;
typ: UebungTyp;
datum_von: string; // ISO-8601 with offset
datum_bis: string;
ort?: string | null;
treffpunkt?: string | null;
pflichtveranstaltung: boolean;
mindest_teilnehmer?: number | null;
max_teilnehmer?: number | null;
}
export type UpdateUebungData = Partial<CreateUebungData>;

View File

@@ -0,0 +1,205 @@
// =============================================================================
// Vehicle Fleet Management — Frontend Type Definitions
// Mirror of backend/src/models/vehicle.model.ts (transport layer shapes)
// =============================================================================
export enum FahrzeugStatus {
Einsatzbereit = 'einsatzbereit',
AusserDienstWartung = 'ausser_dienst_wartung',
AusserDienstSchaden = 'ausser_dienst_schaden',
InLehrgang = 'in_lehrgang',
}
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
[FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)',
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
};
export enum PruefungArt {
HU = 'HU',
AU = 'AU',
UVV = 'UVV',
Leiter = 'Leiter',
Kran = 'Kran',
Seilwinde = 'Seilwinde',
Sonstiges = 'Sonstiges',
}
export const PruefungArtLabel: Record<PruefungArt, string> = {
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
[PruefungArt.AU]: 'Abgasuntersuchung',
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
[PruefungArt.Kran]: 'Kranprüfung',
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
};
export type PruefungErgebnis =
| 'bestanden'
| 'bestanden_mit_maengeln'
| 'nicht_bestanden'
| 'ausstehend';
export type WartungslogArt =
| 'Inspektion'
| 'Reparatur'
| 'Kraftstoff'
| 'Reifenwechsel'
| 'Hauptuntersuchung'
| 'Reinigung'
| 'Sonstiges';
// ── API Response Shapes ───────────────────────────────────────────────────────
export interface FahrzeugListItem {
id: string;
bezeichnung: string;
kurzname: string | null;
amtliches_kennzeichen: string | null;
baujahr: number | null;
hersteller: string | null;
besatzung_soll: string | null;
status: FahrzeugStatus;
status_bemerkung: string | null;
bild_url: string | null;
hu_faellig_am: string | null; // ISO date string from API
hu_tage_bis_faelligkeit: number | null;
au_faellig_am: string | null;
au_tage_bis_faelligkeit: number | null;
uvv_faellig_am: string | null;
uvv_tage_bis_faelligkeit: number | null;
leiter_faellig_am: string | null;
leiter_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null;
}
export interface PruefungStatus {
pruefung_id: string | null;
faellig_am: string | null;
tage_bis_faelligkeit: number | null;
ergebnis: PruefungErgebnis | null;
}
export interface FahrzeugPruefung {
id: string;
fahrzeug_id: string;
pruefung_art: PruefungArt;
faellig_am: string;
durchgefuehrt_am: string | null;
ergebnis: PruefungErgebnis | null;
naechste_faelligkeit: string | null;
pruefende_stelle: string | null;
kosten: number | null;
dokument_url: string | null;
bemerkung: string | null;
erfasst_von: string | null;
created_at: string;
}
export interface FahrzeugWartungslog {
id: string;
fahrzeug_id: string;
datum: string;
art: WartungslogArt | null;
beschreibung: string;
km_stand: number | null;
kraftstoff_liter: number | null;
kosten: number | null;
externe_werkstatt: string | null;
erfasst_von: string | null;
created_at: string;
}
export interface FahrzeugDetail {
id: string;
bezeichnung: string;
kurzname: string | null;
amtliches_kennzeichen: string | null;
fahrgestellnummer: string | null;
baujahr: number | null;
hersteller: string | null;
typ_schluessel: string | null;
besatzung_soll: string | null;
status: FahrzeugStatus;
status_bemerkung: string | null;
standort: string;
bild_url: string | null;
created_at: string;
updated_at: string;
pruefstatus: {
hu: PruefungStatus;
au: PruefungStatus;
uvv: PruefungStatus;
leiter: PruefungStatus;
};
naechste_pruefung_tage: number | null;
pruefungen: FahrzeugPruefung[];
wartungslog: FahrzeugWartungslog[];
}
export interface VehicleStats {
total: number;
einsatzbereit: number;
ausserDienst: number;
inLehrgang: number;
inspectionsDue: number;
inspectionsOverdue: number;
}
export interface InspectionAlert {
fahrzeugId: string;
bezeichnung: string;
kurzname: string | null;
pruefungId: string;
pruefungArt: PruefungArt;
faelligAm: string;
tage: number;
}
// ── Request Payload Types ─────────────────────────────────────────────────────
export interface CreateFahrzeugPayload {
bezeichnung: string;
kurzname?: string;
amtliches_kennzeichen?: string;
fahrgestellnummer?: string;
baujahr?: number;
hersteller?: string;
typ_schluessel?: string;
besatzung_soll?: string;
status?: FahrzeugStatus;
status_bemerkung?: string;
standort?: string;
bild_url?: string;
}
export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>;
export interface UpdateStatusPayload {
status: FahrzeugStatus;
bemerkung?: string;
}
export interface CreatePruefungPayload {
pruefung_art: PruefungArt;
faellig_am: string;
durchgefuehrt_am?: string;
ergebnis?: PruefungErgebnis;
pruefende_stelle?: string;
kosten?: number;
dokument_url?: string;
bemerkung?: string;
}
export interface CreateWartungslogPayload {
datum: string;
art?: WartungslogArt;
beschreibung: string;
km_stand?: number;
kraftstoff_liter?: number;
kosten?: number;
externe_werkstatt?: string;
}