bug fixes

This commit is contained in:
Matthias Hochmeister
2026-03-03 11:45:08 +01:00
parent 3101f1a9c5
commit d91f757f34
12 changed files with 313 additions and 47 deletions

View File

@@ -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';

View File

@@ -42,6 +42,16 @@ export interface Veranstaltung {
kategorie_farbe?: string | null; kategorie_farbe?: string | null;
kategorie_icon?: string | null; kategorie_icon?: string | null;
erstellt_von_name?: 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 */ /** Lightweight version for calendar and list views */
@@ -60,6 +70,9 @@ export interface VeranstaltungListItem {
zielgruppen: string[]; zielgruppen: string[];
abgesagt: boolean; abgesagt: boolean;
anmeldung_erforderlich: 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)) .transform((s) => new Date(s))
.optional() .optional()
.nullable(), .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( export const CreateVeranstaltungSchema = VeranstaltungBaseSchema.refine(

View File

@@ -4,6 +4,7 @@ import {
VeranstaltungKategorie, VeranstaltungKategorie,
Veranstaltung, Veranstaltung,
VeranstaltungListItem, VeranstaltungListItem,
WiederholungConfig,
CreateKategorieData, CreateKategorieData,
UpdateKategorieData, UpdateKategorieData,
CreateVeranstaltungData, CreateVeranstaltungData,
@@ -31,6 +32,8 @@ function rowToListItem(row: any): VeranstaltungListItem {
zielgruppen: row.zielgruppen ?? [], zielgruppen: row.zielgruppen ?? [],
abgesagt: row.abgesagt, abgesagt: row.abgesagt,
anmeldung_erforderlich: row.anmeldung_erforderlich, 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_farbe: row.kategorie_farbe ?? null,
kategorie_icon: row.kategorie_icon ?? null, kategorie_icon: row.kategorie_icon ?? null,
erstellt_von_name: row.erstellt_von_name ?? 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. * 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<void> { async deleteKategorie(id: string): Promise<void> {
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( const result = await pool.query(
`DELETE FROM veranstaltung_kategorien WHERE id = $1`, `DELETE FROM veranstaltung_kategorien WHERE id = $1`,
[id] [id]
@@ -306,8 +306,8 @@ class EventsService {
datum_von, datum_bis, ganztaegig, datum_von, datum_bis, ganztaegig,
zielgruppen, alle_gruppen, zielgruppen, alle_gruppen,
max_teilnehmer, anmeldung_erforderlich, anmeldung_bis, max_teilnehmer, anmeldung_erforderlich, anmeldung_bis,
erstellt_von erstellt_von, wiederholung
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING *`, RETURNING *`,
[ [
data.titel, data.titel,
@@ -324,11 +324,110 @@ class EventsService {
data.anmeldung_erforderlich, data.anmeldung_erforderlich,
data.anmeldung_bis ?? null, data.anmeldung_bis ?? null,
userId, 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]); 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. * Updates an existing event.
* Returns the updated record or null if not found. * Returns the updated record or null if not found.
@@ -460,9 +559,13 @@ class EventsService {
}; };
const result = await pool.query( const result = await pool.query(
`SELECT DISTINCT unnest(authentik_groups) AS group_name `SELECT DISTINCT group_name
FROM (
SELECT unnest(authentik_groups) AS group_name
FROM users FROM users
WHERE is_active = true WHERE is_active = true
) g
WHERE group_name LIKE 'dashboard_%'
ORDER BY group_name` ORDER BY group_name`
); );

View File

@@ -84,7 +84,7 @@ const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
if (hideWhenEmpty && allGood) return null; if (hideWhenEmpty && allGood) return null;
return ( return (
<Card> <Card sx={{ height: '100%' }}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Atemschutz Atemschutz

View File

@@ -82,7 +82,7 @@ const EquipmentDashboardCard: React.FC<EquipmentDashboardCardProps> = ({
if (hideWhenEmpty && allGood) return null; if (hideWhenEmpty && allGood) return null;
return ( return (
<Card> <Card sx={{ height: '100%' }}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Ausrüstung Ausrüstung

View File

@@ -98,7 +98,7 @@ const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
if (hideWhenEmpty && allGood) return null; if (hideWhenEmpty && allGood) return null;
return ( return (
<Card> <Card sx={{ height: '100%' }}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Fahrzeuge Fahrzeuge

View File

@@ -37,17 +37,19 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
// Optionally verify token is still valid // Optionally verify token is still valid
try { try {
await authService.getCurrentUser(); await authService.getCurrentUser();
} catch (error) { } catch (error: any) {
console.error('Token validation failed:', error); console.error('Token validation failed:', error);
// Token is invalid, clear it // Only clear auth for explicit 401 Unauthorized
// Network errors or server errors (5xx) should not log out the user
if (error?.status === 401) {
removeToken(); removeToken();
removeUser(); removeUser();
setState({ setState({ user: null, token: null, isAuthenticated: false, isLoading: false });
user: null, } else {
token: null, // Keep existing auth state on non-auth errors (network issues, server down)
isAuthenticated: false, // The user may still be authenticated, just the server is temporarily unavailable
isLoading: false, setState({ user, token, isAuthenticated: true, isLoading: false });
}); }
} finally { } finally {
setAuthInitialized(true); setAuthInitialized(true);
} }

View File

@@ -32,7 +32,7 @@ function Dashboard() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="xl">
<Grid container spacing={3}> <Grid container spacing={3}>
{/* Welcome Message */} {/* Welcome Message */}
<Grid item xs={12}> <Grid item xs={12}>
@@ -76,48 +76,48 @@ function Dashboard() {
)} )}
{/* Vehicle Status Card */} {/* Vehicle Status Card */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6} sx={{ display: 'flex' }}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '380ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '380ms' }}>
<Box> <Box sx={{ height: '100%' }}>
<VehicleDashboardCard /> <VehicleDashboardCard />
</Box> </Box>
</Fade> </Fade>
</Grid> </Grid>
{/* Equipment Status Card */} {/* Equipment Status Card */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6} sx={{ display: 'flex' }}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '450ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '450ms' }}>
<Box> <Box sx={{ height: '100%' }}>
<EquipmentDashboardCard /> <EquipmentDashboardCard />
</Box> </Box>
</Fade> </Fade>
</Grid> </Grid>
{/* Atemschutz Status Card */} {/* Atemschutz Status Card */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6} sx={{ display: 'flex' }}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '420ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '420ms' }}>
<Box> <Box sx={{ height: '100%' }}>
<AtemschutzDashboardCard /> <AtemschutzDashboardCard />
</Box> </Box>
</Fade> </Fade>
</Grid> </Grid>
{/* Upcoming Events Widget */} {/* Upcoming Events Widget */}
<Grid item xs={12} md={6}> <Grid item xs={12} md={6} sx={{ display: 'flex' }}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}> <Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '440ms' }}>
<Box> <Box sx={{ height: '100%' }}>
<UpcomingEventsWidget /> <UpcomingEventsWidget />
</Box> </Box>
</Fade> </Fade>
</Grid> </Grid>
{/* Nextcloud Talk Widget */} {/* Nextcloud Talk Widget */}
<Grid item xs={12} md={6} lg={4}> <Grid item xs={12} md={6} lg={5} sx={{ display: 'flex' }}>
{dataLoading ? ( {dataLoading ? (
<SkeletonCard variant="basic" /> <SkeletonCard variant="basic" />
) : ( ) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '520ms' }}> <Fade in={true} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box> <Box sx={{ height: '100%' }}>
<NextcloudTalkWidget /> <NextcloudTalkWidget />
</Box> </Box>
</Fade> </Fade>
@@ -125,12 +125,12 @@ function Dashboard() {
</Grid> </Grid>
{/* Activity Feed */} {/* Activity Feed */}
<Grid item xs={12}> <Grid item xs={12} md={6} lg={7} sx={{ display: 'flex' }}>
{dataLoading ? ( {dataLoading ? (
<SkeletonCard variant="detailed" /> <SkeletonCard variant="detailed" />
) : ( ) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '550ms' }}> <Fade in={true} timeout={600} style={{ transitionDelay: '550ms' }}>
<Box> <Box sx={{ height: '100%' }}>
<ActivityFeed /> <ActivityFeed />
</Box> </Box>
</Fade> </Fade>

View File

@@ -79,6 +79,7 @@ import type {
VeranstaltungKategorie, VeranstaltungKategorie,
GroupInfo, GroupInfo,
CreateVeranstaltungInput, CreateVeranstaltungInput,
WiederholungConfig,
} from '../types/events.types'; } from '../types/events.types';
import type { import type {
FahrzeugBuchungListItem, FahrzeugBuchungListItem,
@@ -768,6 +769,11 @@ function VeranstaltungFormDialog({
const notification = useNotification(); const notification = useNotification();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [form, setForm] = useState<CreateVeranstaltungInput>({ ...EMPTY_VERANSTALTUNG_FORM }); const [form, setForm] = useState<CreateVeranstaltungInput>({ ...EMPTY_VERANSTALTUNG_FORM });
const [wiederholungAktiv, setWiederholungAktiv] = useState(false);
const [wiederholungTyp, setWiederholungTyp] = useState<WiederholungConfig['typ']>('wöchentlich');
const [wiederholungIntervall, setWiederholungIntervall] = useState(1);
const [wiederholungBis, setWiederholungBis] = useState('');
const [wiederholungWochentag, setWiederholungWochentag] = useState(0);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -787,12 +793,22 @@ function VeranstaltungFormDialog({
anmeldung_erforderlich: editingEvent.anmeldung_erforderlich, anmeldung_erforderlich: editingEvent.anmeldung_erforderlich,
anmeldung_bis: null, anmeldung_bis: null,
}); });
setWiederholungAktiv(false);
setWiederholungTyp('wöchentlich');
setWiederholungIntervall(1);
setWiederholungBis('');
setWiederholungWochentag(0);
} else { } else {
const now = new Date(); const now = new Date();
now.setMinutes(0, 0, 0); now.setMinutes(0, 0, 0);
const later = new Date(now); const later = new Date(now);
later.setHours(later.getHours() + 2); later.setHours(later.getHours() + 2);
setForm({ ...EMPTY_VERANSTALTUNG_FORM, datum_von: now.toISOString(), datum_bis: later.toISOString() }); setForm({ ...EMPTY_VERANSTALTUNG_FORM, datum_von: now.toISOString(), datum_bis: later.toISOString() });
setWiederholungAktiv(false);
setWiederholungTyp('wöchentlich');
setWiederholungIntervall(1);
setWiederholungBis('');
setWiederholungWochentag(0);
} }
}, [open, editingEvent]); }, [open, editingEvent]);
@@ -816,12 +832,29 @@ function VeranstaltungFormDialog({
} }
setLoading(true); setLoading(true);
try { 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) { if (editingEvent) {
await eventsApi.updateEvent(editingEvent.id, form); await eventsApi.updateEvent(editingEvent.id, form);
notification.showSuccess('Veranstaltung aktualisiert'); notification.showSuccess('Veranstaltung aktualisiert');
} else { } else {
await eventsApi.createEvent(form); await eventsApi.createEvent(createPayload);
notification.showSuccess('Veranstaltung erstellt'); notification.showSuccess(
wiederholungAktiv && wiederholungBis
? 'Veranstaltung und Wiederholungen erstellt'
: 'Veranstaltung erstellt'
);
} }
onSaved(); onSaved();
onClose(); onClose();
@@ -855,11 +888,14 @@ function VeranstaltungFormDialog({
fullWidth fullWidth
/> />
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Kategorie</InputLabel> <InputLabel id="kategorie-select-label" shrink>Kategorie</InputLabel>
<Select <Select
labelId="kategorie-select-label"
label="Kategorie" label="Kategorie"
value={form.kategorie_id ?? ''} value={form.kategorie_id ?? ''}
onChange={(e) => handleChange('kategorie_id', e.target.value || null)} onChange={(e) => handleChange('kategorie_id', e.target.value || null)}
displayEmpty
notched
> >
<MenuItem value=""><em>Keine Kategorie</em></MenuItem> <MenuItem value=""><em>Keine Kategorie</em></MenuItem>
{kategorien.map((k) => ( {kategorien.map((k) => (
@@ -975,6 +1011,79 @@ function VeranstaltungFormDialog({
fullWidth fullWidth
/> />
)} )}
{/* Wiederholung (only for new events) */}
{!editingEvent && (
<>
<Divider />
<FormControlLabel
control={
<Switch
checked={wiederholungAktiv}
onChange={(e) => setWiederholungAktiv(e.target.checked)}
/>
}
label="Wiederkehrende Veranstaltung"
/>
{wiederholungAktiv && (
<Stack spacing={2}>
<FormControl fullWidth size="small">
<InputLabel id="wiederholung-typ-label">Wiederholung</InputLabel>
<Select
labelId="wiederholung-typ-label"
label="Wiederholung"
value={wiederholungTyp}
onChange={(e) => setWiederholungTyp(e.target.value as WiederholungConfig['typ'])}
>
<MenuItem value="wöchentlich">Wöchentlich</MenuItem>
<MenuItem value="zweiwöchentlich">Vierzehntägig (alle 2 Wochen)</MenuItem>
<MenuItem value="monatlich_datum">Monatlich (gleicher Tag)</MenuItem>
<MenuItem value="monatlich_erster_wochentag">Monatlich (erster Wochentag)</MenuItem>
<MenuItem value="monatlich_letzter_wochentag">Monatlich (letzter Wochentag)</MenuItem>
</Select>
</FormControl>
{wiederholungTyp === 'wöchentlich' && (
<TextField
label="Intervall (Wochen)"
type="number"
size="small"
value={wiederholungIntervall}
onChange={(e) => 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') && (
<FormControl fullWidth size="small">
<InputLabel id="wiederholung-wochentag-label">Wochentag</InputLabel>
<Select
labelId="wiederholung-wochentag-label"
label="Wochentag"
value={wiederholungWochentag}
onChange={(e) => setWiederholungWochentag(Number(e.target.value))}
>
{['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'].map((d, i) => (
<MenuItem key={i} value={i}>{d}</MenuItem>
))}
</Select>
</FormControl>
)}
<TextField
label="Wiederholungen bis"
type="date"
size="small"
value={wiederholungBis}
onChange={(e) => setWiederholungBis(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
helperText="Letztes Datum für Wiederholungen"
/>
</Stack>
)}
</>
)}
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@@ -1211,7 +1320,7 @@ export default function Kalender() {
setCancelEventGrund(''); setCancelEventGrund('');
loadCalendarData(); loadCalendarData();
} catch (e: unknown) { } catch (e: unknown) {
notification.showError(e instanceof Error ? e.message : 'Fehler beim Absagen'); notification.showError((e as any)?.message || 'Fehler beim Absagen');
} finally { } finally {
setCancelEventLoading(false); setCancelEventLoading(false);
} }

View File

@@ -124,7 +124,7 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
onSaved(); onSaved();
onClose(); onClose();
} catch (e: unknown) { } 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); notification.showError(msg);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -247,7 +247,7 @@ function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps
onDeleted(); onDeleted();
onClose(); onClose();
} catch (e: unknown) { } 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); notification.showError(msg);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -306,7 +306,7 @@ export default function VeranstaltungKategorien() {
setKategorien(data); setKategorien(data);
setGroups(groupData); setGroups(groupData);
} catch (e: unknown) { } 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); setError(msg);
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -3,9 +3,17 @@ import { API_URL } from '../utils/config';
import { getToken, removeToken, removeUser } from '../utils/storage'; import { getToken, removeToken, removeUser } from '../utils/storage';
let authInitialized = false; let authInitialized = false;
let isRedirectingToLogin = false;
export function setAuthInitialized(value: boolean): void { export function setAuthInitialized(value: boolean): void {
authInitialized = value; authInitialized = value;
if (value === true) {
isRedirectingToLogin = false;
}
}
export function resetRedirectFlag(): void {
isRedirectingToLogin = false;
} }
export interface ApiError { export interface ApiError {
@@ -46,7 +54,8 @@ class ApiService {
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
if (authInitialized) { if (authInitialized && !isRedirectingToLogin) {
isRedirectingToLogin = true;
// Clear tokens and redirect to login // Clear tokens and redirect to login
console.warn('Unauthorized request, redirecting to login'); console.warn('Unauthorized request, redirecting to login');
removeToken(); removeToken();

View File

@@ -2,6 +2,13 @@
// Frontend events types — mirrors backend events model // 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 { export interface VeranstaltungKategorie {
id: string; id: string;
name: string; name: string;
@@ -29,6 +36,8 @@ export interface VeranstaltungListItem {
alle_gruppen: boolean; alle_gruppen: boolean;
abgesagt: boolean; abgesagt: boolean;
anmeldung_erforderlich: boolean; anmeldung_erforderlich: boolean;
wiederholung?: WiederholungConfig | null;
wiederholung_parent_id?: string | null;
} }
export interface Veranstaltung extends VeranstaltungListItem { export interface Veranstaltung extends VeranstaltungListItem {
@@ -41,6 +50,8 @@ export interface Veranstaltung extends VeranstaltungListItem {
abgesagt_am?: string | null; abgesagt_am?: string | null;
erstellt_am: string; erstellt_am: string;
aktualisiert_am: string; aktualisiert_am: string;
wiederholung?: WiederholungConfig | null;
wiederholung_parent_id?: string | null;
} }
export interface GroupInfo { export interface GroupInfo {
@@ -62,4 +73,5 @@ export interface CreateVeranstaltungInput {
max_teilnehmer?: number | null; max_teilnehmer?: number | null;
anmeldung_erforderlich: boolean; anmeldung_erforderlich: boolean;
anmeldung_bis?: string | null; anmeldung_bis?: string | null;
wiederholung?: WiederholungConfig | null;
} }