diff --git a/backend/src/database/migrations/019_add_wiederholung.sql b/backend/src/database/migrations/019_add_wiederholung.sql new file mode 100644 index 0000000..ce684e7 --- /dev/null +++ b/backend/src/database/migrations/019_add_wiederholung.sql @@ -0,0 +1,12 @@ +-- Migration 019: Add recurring event support +-- Adds wiederholung (recurrence) config and parent link for generated instances + +ALTER TABLE veranstaltungen + ADD COLUMN IF NOT EXISTS wiederholung JSONB, + ADD COLUMN IF NOT EXISTS wiederholung_parent_id UUID REFERENCES veranstaltungen(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_veranstaltungen_parent_id + ON veranstaltungen(wiederholung_parent_id); + +COMMENT ON COLUMN veranstaltungen.wiederholung IS 'JSON config for recurring events: {typ, intervall?, bis, wochentag?}'; +COMMENT ON COLUMN veranstaltungen.wiederholung_parent_id IS 'Links generated recurrence instances back to the parent event'; diff --git a/backend/src/models/events.model.ts b/backend/src/models/events.model.ts index 71f97eb..a97e3fc 100644 --- a/backend/src/models/events.model.ts +++ b/backend/src/models/events.model.ts @@ -42,6 +42,16 @@ export interface Veranstaltung { kategorie_farbe?: string | null; kategorie_icon?: string | null; erstellt_von_name?: string | null; + // Recurrence fields + wiederholung?: WiederholungConfig | null; + wiederholung_parent_id?: string | null; +} + +export interface WiederholungConfig { + typ: 'wöchentlich' | 'zweiwöchentlich' | 'monatlich_datum' | 'monatlich_erster_wochentag' | 'monatlich_letzter_wochentag'; + intervall?: number; + bis: string; + wochentag?: number; } /** Lightweight version for calendar and list views */ @@ -60,6 +70,9 @@ export interface VeranstaltungListItem { zielgruppen: string[]; abgesagt: boolean; anmeldung_erforderlich: boolean; + // Recurrence fields + wiederholung?: WiederholungConfig | null; + wiederholung_parent_id?: string | null; } // --------------------------------------------------------------------------- @@ -119,6 +132,12 @@ const VeranstaltungBaseSchema = z.object({ .transform((s) => new Date(s)) .optional() .nullable(), + wiederholung: z.object({ + typ: z.enum(['wöchentlich', 'zweiwöchentlich', 'monatlich_datum', 'monatlich_erster_wochentag', 'monatlich_letzter_wochentag']), + intervall: z.number().int().min(1).max(52).optional(), + bis: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'bis muss ein Datum (YYYY-MM-DD) sein'), + wochentag: z.number().int().min(0).max(6).optional(), + }).optional().nullable(), }); export const CreateVeranstaltungSchema = VeranstaltungBaseSchema.refine( diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index ebb2f15..fa9e05c 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -4,6 +4,7 @@ import { VeranstaltungKategorie, Veranstaltung, VeranstaltungListItem, + WiederholungConfig, CreateKategorieData, UpdateKategorieData, CreateVeranstaltungData, @@ -31,6 +32,8 @@ function rowToListItem(row: any): VeranstaltungListItem { zielgruppen: row.zielgruppen ?? [], abgesagt: row.abgesagt, anmeldung_erforderlich: row.anmeldung_erforderlich, + wiederholung: row.wiederholung ?? null, + wiederholung_parent_id: row.wiederholung_parent_id ?? null, }; } @@ -62,6 +65,9 @@ function rowToVeranstaltung(row: any): Veranstaltung { kategorie_farbe: row.kategorie_farbe ?? null, kategorie_icon: row.kategorie_icon ?? null, erstellt_von_name: row.erstellt_von_name ?? null, + // Recurrence fields + wiederholung: row.wiederholung ?? null, + wiederholung_parent_id: row.wiederholung_parent_id ?? null, }; } @@ -193,16 +199,10 @@ class EventsService { /** * Deletes an event category. - * Throws if any events still reference this category. + * The DB schema uses ON DELETE SET NULL, so related events will have + * their kategorie_id set to NULL automatically. */ async deleteKategorie(id: string): Promise { - const refCheck = await pool.query( - `SELECT COUNT(*) AS cnt FROM veranstaltungen WHERE kategorie_id = $1`, - [id] - ); - if (Number(refCheck.rows[0].cnt) > 0) { - throw new Error('Kategorie kann nicht gelöscht werden, da sie noch Veranstaltungen enthält'); - } const result = await pool.query( `DELETE FROM veranstaltung_kategorien WHERE id = $1`, [id] @@ -306,8 +306,8 @@ class EventsService { datum_von, datum_bis, ganztaegig, zielgruppen, alle_gruppen, max_teilnehmer, anmeldung_erforderlich, anmeldung_bis, - erstellt_von - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + erstellt_von, wiederholung + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, [ data.titel, @@ -324,11 +324,110 @@ class EventsService { data.anmeldung_erforderlich, data.anmeldung_bis ?? null, userId, + data.wiederholung ?? null, ] ); + + // Generate recurrence instances if wiederholung is specified + if (data.wiederholung) { + const occurrenceDates = this.generateRecurrenceDates(data.datum_von, data.datum_bis, data.wiederholung); + if (occurrenceDates.length > 0) { + const duration = data.datum_bis.getTime() - data.datum_von.getTime(); + const instanceParams: any[][] = []; + for (const occDate of occurrenceDates) { + const occBis = new Date(occDate.getTime() + duration); + instanceParams.push([ + result.rows[0].id, // wiederholung_parent_id + data.titel, + data.beschreibung ?? null, + data.ort ?? null, + data.ort_url ?? null, + data.kategorie_id ?? null, + occDate, + occBis, + data.ganztaegig, + data.zielgruppen, + data.alle_gruppen, + data.max_teilnehmer ?? null, + data.anmeldung_erforderlich, + userId, + ]); + } + // Insert instances in a loop (simpler than building dynamic bulk insert) + for (const params of instanceParams) { + await pool.query( + `INSERT INTO veranstaltungen ( + wiederholung_parent_id, titel, beschreibung, ort, ort_url, kategorie_id, + datum_von, datum_bis, ganztaegig, zielgruppen, alle_gruppen, + max_teilnehmer, anmeldung_erforderlich, erstellt_von + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + params + ); + } + logger.info(`Created ${instanceParams.length} recurrence instances for event ${result.rows[0].id}`); + } + } + return rowToVeranstaltung(result.rows[0]); } + /** Returns all future occurrence dates for a recurring event (excluding the base occurrence). + * Capped at 100 instances and 2 years from the start date. */ + private generateRecurrenceDates(startDate: Date, _endDate: Date, config: WiederholungConfig): Date[] { + const dates: Date[] = []; + const limitDate = new Date(config.bis); + const interval = config.intervall ?? 1; + // Cap at 100 instances max, and 2 years + const maxDate = new Date(startDate); + maxDate.setFullYear(maxDate.getFullYear() + 2); + const effectiveLimit = limitDate < maxDate ? limitDate : maxDate; + + let current = new Date(startDate); + + while (dates.length < 100) { + // Advance to next occurrence + switch (config.typ) { + case 'wöchentlich': + current = new Date(current); + current.setDate(current.getDate() + 7 * interval); + break; + case 'zweiwöchentlich': + current = new Date(current); + current.setDate(current.getDate() + 14); + break; + case 'monatlich_datum': + current = new Date(current); + current.setMonth(current.getMonth() + 1); + break; + case 'monatlich_erster_wochentag': { + const targetWeekday = config.wochentag ?? 0; // 0=Mon + current = new Date(current); + current.setMonth(current.getMonth() + 1); + current.setDate(1); + // Convert JS Sunday=0 to Monday=0: (getDay()+6)%7 + while ((current.getDay() + 6) % 7 !== targetWeekday) { + current.setDate(current.getDate() + 1); + } + break; + } + case 'monatlich_letzter_wochentag': { + const targetWeekday = config.wochentag ?? 0; + current = new Date(current); + // Go to last day of next month + current.setMonth(current.getMonth() + 2); + current.setDate(0); + while ((current.getDay() + 6) % 7 !== targetWeekday) { + current.setDate(current.getDate() - 1); + } + break; + } + } + if (current > effectiveLimit) break; + dates.push(new Date(current)); + } + return dates; + } + /** * Updates an existing event. * Returns the updated record or null if not found. @@ -460,9 +559,13 @@ class EventsService { }; const result = await pool.query( - `SELECT DISTINCT unnest(authentik_groups) AS group_name - FROM users - WHERE is_active = true + `SELECT DISTINCT group_name + FROM ( + SELECT unnest(authentik_groups) AS group_name + FROM users + WHERE is_active = true + ) g + WHERE group_name LIKE 'dashboard_%' ORDER BY group_name` ); diff --git a/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx b/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx index 8fc8a9d..2987658 100644 --- a/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx +++ b/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx @@ -84,7 +84,7 @@ const AtemschutzDashboardCard: React.FC = ({ if (hideWhenEmpty && allGood) return null; return ( - + Atemschutz diff --git a/frontend/src/components/equipment/EquipmentDashboardCard.tsx b/frontend/src/components/equipment/EquipmentDashboardCard.tsx index 89b045b..22908d0 100644 --- a/frontend/src/components/equipment/EquipmentDashboardCard.tsx +++ b/frontend/src/components/equipment/EquipmentDashboardCard.tsx @@ -82,7 +82,7 @@ const EquipmentDashboardCard: React.FC = ({ if (hideWhenEmpty && allGood) return null; return ( - + Ausrüstung diff --git a/frontend/src/components/vehicles/VehicleDashboardCard.tsx b/frontend/src/components/vehicles/VehicleDashboardCard.tsx index 4b2a78e..8a75d4d 100644 --- a/frontend/src/components/vehicles/VehicleDashboardCard.tsx +++ b/frontend/src/components/vehicles/VehicleDashboardCard.tsx @@ -98,7 +98,7 @@ const VehicleDashboardCard: React.FC = ({ if (hideWhenEmpty && allGood) return null; return ( - + Fahrzeuge diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index da50b0b..7975710 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -37,17 +37,19 @@ export const AuthProvider: React.FC = ({ children }) => { // Optionally verify token is still valid try { await authService.getCurrentUser(); - } catch (error) { + } catch (error: any) { console.error('Token validation failed:', error); - // Token is invalid, clear it - removeToken(); - removeUser(); - setState({ - user: null, - token: null, - isAuthenticated: false, - isLoading: false, - }); + // Only clear auth for explicit 401 Unauthorized + // Network errors or server errors (5xx) should not log out the user + if (error?.status === 401) { + removeToken(); + removeUser(); + setState({ user: null, token: null, isAuthenticated: false, isLoading: false }); + } else { + // Keep existing auth state on non-auth errors (network issues, server down) + // The user may still be authenticated, just the server is temporarily unavailable + setState({ user, token, isAuthenticated: true, isLoading: false }); + } } finally { setAuthInitialized(true); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 043614b..69005a4 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -32,7 +32,7 @@ function Dashboard() { return ( - + {/* Welcome Message */} @@ -76,48 +76,48 @@ function Dashboard() { )} {/* Vehicle Status Card */} - + - + {/* Equipment Status Card */} - + - + {/* Atemschutz Status Card */} - + - + {/* Upcoming Events Widget */} - + - + {/* Nextcloud Talk Widget */} - + {dataLoading ? ( ) : ( - + @@ -125,12 +125,12 @@ function Dashboard() { {/* Activity Feed */} - + {dataLoading ? ( ) : ( - + diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 3995af1..cbca6a3 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -79,6 +79,7 @@ import type { VeranstaltungKategorie, GroupInfo, CreateVeranstaltungInput, + WiederholungConfig, } from '../types/events.types'; import type { FahrzeugBuchungListItem, @@ -768,6 +769,11 @@ function VeranstaltungFormDialog({ const notification = useNotification(); const [loading, setLoading] = useState(false); const [form, setForm] = useState({ ...EMPTY_VERANSTALTUNG_FORM }); + const [wiederholungAktiv, setWiederholungAktiv] = useState(false); + const [wiederholungTyp, setWiederholungTyp] = useState('wöchentlich'); + const [wiederholungIntervall, setWiederholungIntervall] = useState(1); + const [wiederholungBis, setWiederholungBis] = useState(''); + const [wiederholungWochentag, setWiederholungWochentag] = useState(0); useEffect(() => { if (!open) return; @@ -787,12 +793,22 @@ function VeranstaltungFormDialog({ anmeldung_erforderlich: editingEvent.anmeldung_erforderlich, anmeldung_bis: null, }); + setWiederholungAktiv(false); + setWiederholungTyp('wöchentlich'); + setWiederholungIntervall(1); + setWiederholungBis(''); + setWiederholungWochentag(0); } else { const now = new Date(); now.setMinutes(0, 0, 0); const later = new Date(now); later.setHours(later.getHours() + 2); setForm({ ...EMPTY_VERANSTALTUNG_FORM, datum_von: now.toISOString(), datum_bis: later.toISOString() }); + setWiederholungAktiv(false); + setWiederholungTyp('wöchentlich'); + setWiederholungIntervall(1); + setWiederholungBis(''); + setWiederholungWochentag(0); } }, [open, editingEvent]); @@ -816,12 +832,29 @@ function VeranstaltungFormDialog({ } setLoading(true); try { + const createPayload: CreateVeranstaltungInput = { + ...form, + wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis) + ? { + typ: wiederholungTyp, + bis: wiederholungBis, + intervall: wiederholungTyp === 'wöchentlich' ? wiederholungIntervall : undefined, + wochentag: (wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') + ? wiederholungWochentag + : undefined, + } + : null, + }; if (editingEvent) { await eventsApi.updateEvent(editingEvent.id, form); notification.showSuccess('Veranstaltung aktualisiert'); } else { - await eventsApi.createEvent(form); - notification.showSuccess('Veranstaltung erstellt'); + await eventsApi.createEvent(createPayload); + notification.showSuccess( + wiederholungAktiv && wiederholungBis + ? 'Veranstaltung und Wiederholungen erstellt' + : 'Veranstaltung erstellt' + ); } onSaved(); onClose(); @@ -855,11 +888,14 @@ function VeranstaltungFormDialog({ fullWidth /> - Kategorie + Kategorie setWiederholungTyp(e.target.value as WiederholungConfig['typ'])} + > + Wöchentlich + Vierzehntägig (alle 2 Wochen) + Monatlich (gleicher Tag) + Monatlich (erster Wochentag) + Monatlich (letzter Wochentag) + + + + {wiederholungTyp === 'wöchentlich' && ( + setWiederholungIntervall(Math.max(1, Math.min(52, parseInt(e.target.value) || 1)))} + inputProps={{ min: 1, max: 52 }} + fullWidth + /> + )} + + {(wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') && ( + + Wochentag + + + )} + + setWiederholungBis(e.target.value)} + InputLabelProps={{ shrink: true }} + fullWidth + helperText="Letztes Datum für Wiederholungen" + /> + + )} + + )} @@ -1211,7 +1320,7 @@ export default function Kalender() { setCancelEventGrund(''); loadCalendarData(); } catch (e: unknown) { - notification.showError(e instanceof Error ? e.message : 'Fehler beim Absagen'); + notification.showError((e as any)?.message || 'Fehler beim Absagen'); } finally { setCancelEventLoading(false); } diff --git a/frontend/src/pages/VeranstaltungKategorien.tsx b/frontend/src/pages/VeranstaltungKategorien.tsx index 89e3bd2..9028d20 100644 --- a/frontend/src/pages/VeranstaltungKategorien.tsx +++ b/frontend/src/pages/VeranstaltungKategorien.tsx @@ -124,7 +124,7 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD onSaved(); onClose(); } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Fehler beim Speichern'; + const msg = (e as any)?.message || 'Fehler beim Speichern'; notification.showError(msg); } finally { setLoading(false); @@ -247,7 +247,7 @@ function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps onDeleted(); onClose(); } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Fehler beim Löschen'; + const msg = (e as any)?.message || 'Fehler beim Löschen'; notification.showError(msg); } finally { setLoading(false); @@ -306,7 +306,7 @@ export default function VeranstaltungKategorien() { setKategorien(data); setGroups(groupData); } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Kategorien'; + const msg = (e as any)?.message || 'Fehler beim Laden der Kategorien'; setError(msg); } finally { setLoading(false); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index cc20221..f91c745 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -3,9 +3,17 @@ import { API_URL } from '../utils/config'; import { getToken, removeToken, removeUser } from '../utils/storage'; let authInitialized = false; +let isRedirectingToLogin = false; export function setAuthInitialized(value: boolean): void { authInitialized = value; + if (value === true) { + isRedirectingToLogin = false; + } +} + +export function resetRedirectFlag(): void { + isRedirectingToLogin = false; } export interface ApiError { @@ -46,7 +54,8 @@ class ApiService { (response) => response, async (error: AxiosError) => { if (error.response?.status === 401) { - if (authInitialized) { + if (authInitialized && !isRedirectingToLogin) { + isRedirectingToLogin = true; // Clear tokens and redirect to login console.warn('Unauthorized request, redirecting to login'); removeToken(); diff --git a/frontend/src/types/events.types.ts b/frontend/src/types/events.types.ts index 6f27616..0804aaa 100644 --- a/frontend/src/types/events.types.ts +++ b/frontend/src/types/events.types.ts @@ -2,6 +2,13 @@ // Frontend events types — mirrors backend events model // --------------------------------------------------------------------------- +export interface WiederholungConfig { + typ: 'wöchentlich' | 'zweiwöchentlich' | 'monatlich_datum' | 'monatlich_erster_wochentag' | 'monatlich_letzter_wochentag'; + intervall?: number; + bis: string; // YYYY-MM-DD + wochentag?: number; // 0=Mon...6=Sun +} + export interface VeranstaltungKategorie { id: string; name: string; @@ -29,6 +36,8 @@ export interface VeranstaltungListItem { alle_gruppen: boolean; abgesagt: boolean; anmeldung_erforderlich: boolean; + wiederholung?: WiederholungConfig | null; + wiederholung_parent_id?: string | null; } export interface Veranstaltung extends VeranstaltungListItem { @@ -41,6 +50,8 @@ export interface Veranstaltung extends VeranstaltungListItem { abgesagt_am?: string | null; erstellt_am: string; aktualisiert_am: string; + wiederholung?: WiederholungConfig | null; + wiederholung_parent_id?: string | null; } export interface GroupInfo { @@ -62,4 +73,5 @@ export interface CreateVeranstaltungInput { max_teilnehmer?: number | null; anmeldung_erforderlich: boolean; anmeldung_bis?: string | null; + wiederholung?: WiederholungConfig | null; }