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