add features
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user