add features
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"dev": "nodemon",
|
"dev": "nodemon",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/server.js",
|
"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"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
302
backend/src/controllers/incident.controller.ts
Normal file
302
backend/src/controllers/incident.controller.ts
Normal 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();
|
||||||
234
backend/src/controllers/member.controller.ts
Normal file
234
backend/src/controllers/member.controller.ts
Normal 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();
|
||||||
346
backend/src/controllers/training.controller.ts
Normal file
346
backend/src/controllers/training.controller.ts
Normal 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();
|
||||||
344
backend/src/controllers/vehicle.controller.ts
Normal file
344
backend/src/controllers/vehicle.controller.ts
Normal 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();
|
||||||
146
backend/src/database/migrations/002_create_audit_log.sql
Normal file
146
backend/src/database/migrations/002_create_audit_log.sql
Normal 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.
|
||||||
|
-- -------------------------------------------------------
|
||||||
@@ -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);
|
||||||
270
backend/src/database/migrations/004_create_einsaetze.sql
Normal file
270
backend/src/database/migrations/004_create_einsaetze.sql
Normal 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;
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
331
backend/src/database/migrations/005_create_fahrzeuge.sql
Normal file
331
backend/src/database/migrations/005_create_fahrzeuge.sql
Normal 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 $$;
|
||||||
201
backend/src/database/migrations/006_create_uebungen.sql
Normal file
201
backend/src/database/migrations/006_create_uebungen.sql
Normal 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;
|
||||||
197
backend/src/database/seeds/einsaetze_test_data.sql
Normal file
197
backend/src/database/seeds/einsaetze_test_data.sql
Normal 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;
|
||||||
114
backend/src/jobs/audit-cleanup.job.ts
Normal file
114
backend/src/jobs/audit-cleanup.job.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
235
backend/src/middleware/audit.middleware.ts
Normal file
235
backend/src/middleware/audit.middleware.ts
Normal 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(() => {});
|
||||||
|
}
|
||||||
136
backend/src/middleware/rbac.middleware.ts
Normal file
136
backend/src/middleware/rbac.middleware.ts
Normal 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 };
|
||||||
261
backend/src/models/incident.model.ts
Normal file
261
backend/src/models/incident.model.ts
Normal 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; // 1–12
|
||||||
|
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>;
|
||||||
230
backend/src/models/member.model.ts
Normal file
230
backend/src/models/member.model.ts
Normal 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;
|
||||||
|
}
|
||||||
197
backend/src/models/training.model.ts
Normal file
197
backend/src/models/training.model.ts
Normal 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>;
|
||||||
269
backend/src/models/vehicle.model.ts
Normal file
269
backend/src/models/vehicle.model.ts
Normal 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;
|
||||||
|
}
|
||||||
169
backend/src/routes/admin.routes.ts
Normal file
169
backend/src/routes/admin.routes.ts
Normal 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;
|
||||||
139
backend/src/routes/incident.routes.ts
Normal file
139
backend/src/routes/incident.routes.ts
Normal 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;
|
||||||
139
backend/src/routes/member.routes.ts
Normal file
139
backend/src/routes/member.routes.ts
Normal 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;
|
||||||
149
backend/src/routes/training.routes.ts
Normal file
149
backend/src/routes/training.routes.ts
Normal 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;
|
||||||
147
backend/src/routes/vehicle.routes.ts
Normal file
147
backend/src/routes/vehicle.routes.ts
Normal 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;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import app from './app';
|
import app from './app';
|
||||||
import environment from './config/environment';
|
import environment from './config/environment';
|
||||||
import logger from './utils/logger';
|
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';
|
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
|
||||||
|
|
||||||
const startServer = async (): Promise<void> => {
|
const startServer = async (): Promise<void> => {
|
||||||
@@ -12,6 +12,9 @@ const startServer = async (): Promise<void> => {
|
|||||||
|
|
||||||
if (!dbConnected) {
|
if (!dbConnected) {
|
||||||
logger.warn('Database connection failed - server will start but database operations may fail');
|
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
|
// Start the GDPR IP anonymisation job
|
||||||
|
|||||||
394
backend/src/services/audit.service.ts
Normal file
394
backend/src/services/audit.service.ts
Normal 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();
|
||||||
699
backend/src/services/incident.service.ts
Normal file
699
backend/src/services/incident.service.ts
Normal 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();
|
||||||
594
backend/src/services/member.service.ts
Normal file
594
backend/src/services/member.service.ts
Normal 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();
|
||||||
614
backend/src/services/training.service.ts
Normal file
614
backend/src/services/training.service.ts
Normal 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();
|
||||||
572
backend/src/services/vehicle.service.ts
Normal file
572
backend/src/services/vehicle.service.ts
Normal 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();
|
||||||
266
frontend/src/components/incidents/CreateEinsatzDialog.tsx
Normal file
266
frontend/src/components/incidents/CreateEinsatzDialog.tsx
Normal 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;
|
||||||
258
frontend/src/components/incidents/IncidentStatsChart.tsx
Normal file
258
frontend/src/components/incidents/IncidentStatsChart.tsx
Normal 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;
|
||||||
247
frontend/src/components/training/UpcomingEvents.tsx
Normal file
247
frontend/src/components/training/UpcomingEvents.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
frontend/src/components/vehicles/InspectionAlerts.tsx
Normal file
159
frontend/src/components/vehicles/InspectionAlerts.tsx
Normal 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;
|
||||||
643
frontend/src/pages/EinsatzDetail.tsx
Normal file
643
frontend/src/pages/EinsatzDetail.tsx
Normal 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;
|
||||||
898
frontend/src/pages/FahrzeugDetail.tsx
Normal file
898
frontend/src/pages/FahrzeugDetail.tsx
Normal 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;
|
||||||
804
frontend/src/pages/Kalender.tsx
Normal file
804
frontend/src/pages/Kalender.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
792
frontend/src/pages/MitgliedDetail.tsx
Normal file
792
frontend/src/pages/MitgliedDetail.tsx
Normal 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;
|
||||||
551
frontend/src/pages/UebungDetail.tsx
Normal file
551
frontend/src/pages/UebungDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
733
frontend/src/pages/admin/AuditLog.tsx
Normal file
733
frontend/src/pages/admin/AuditLog.tsx
Normal 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;
|
||||||
299
frontend/src/services/incidents.ts
Normal file
299
frontend/src/services/incidents.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
113
frontend/src/services/members.ts
Normal file
113
frontend/src/services/members.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
131
frontend/src/services/training.ts
Normal file
131
frontend/src/services/training.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
115
frontend/src/services/vehicles.ts
Normal file
115
frontend/src/services/vehicles.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
194
frontend/src/types/member.types.ts
Normal file
194
frontend/src/types/member.types.ts
Normal 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',
|
||||||
|
};
|
||||||
115
frontend/src/types/training.types.ts
Normal file
115
frontend/src/types/training.types.ts
Normal 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>;
|
||||||
205
frontend/src/types/vehicle.types.ts
Normal file
205
frontend/src/types/vehicle.types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user