bug fixes
This commit is contained in:
12
backend/src/database/migrations/019_add_wiederholung.sql
Normal file
12
backend/src/database/migrations/019_add_wiederholung.sql
Normal 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';
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 users
|
FROM (
|
||||||
WHERE is_active = true
|
SELECT unnest(authentik_groups) AS group_name
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = true
|
||||||
|
) g
|
||||||
|
WHERE group_name LIKE 'dashboard_%'
|
||||||
ORDER BY group_name`
|
ORDER BY group_name`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
removeToken();
|
// Network errors or server errors (5xx) should not log out the user
|
||||||
removeUser();
|
if (error?.status === 401) {
|
||||||
setState({
|
removeToken();
|
||||||
user: null,
|
removeUser();
|
||||||
token: null,
|
setState({ user: null, token: null, isAuthenticated: false, isLoading: false });
|
||||||
isAuthenticated: false,
|
} else {
|
||||||
isLoading: false,
|
// 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 {
|
} finally {
|
||||||
setAuthInitialized(true);
|
setAuthInitialized(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user