207 lines
6.3 KiB
TypeScript
207 lines
6.3 KiB
TypeScript
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const UebungBaseSchema = 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(),
|
|
});
|
|
|
|
export const CreateUebungSchema = UebungBaseSchema.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 = UebungBaseSchema.partial().refine(
|
|
(d) => d.datum_bis == null || d.datum_von == null || 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 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>;
|