annoucement banners, calendar pdf export, vehicle booking quck-add, even quick-add

This commit is contained in:
Matthias Hochmeister
2026-03-12 11:47:08 +01:00
parent 71a04aee89
commit cd68bd3795
15 changed files with 997 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Alert, AlertTitle, Box, IconButton, Collapse } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { useQuery } from '@tanstack/react-query';
import { bannerApi } from '../../services/banners';
import type { Banner, BannerLevel } from '../../types/banner.types';
const DISMISSED_KEY = 'dismissed_banners'; // sessionStorage key
function getDismissed(): string[] {
try { return JSON.parse(sessionStorage.getItem(DISMISSED_KEY) ?? '[]'); } catch { return []; }
}
function addDismissed(id: string) {
const list = getDismissed();
if (!list.includes(id)) sessionStorage.setItem(DISMISSED_KEY, JSON.stringify([...list, id]));
}
const LEVEL_MAP: Record<BannerLevel, 'info' | 'warning' | 'error'> = {
info: 'info',
important: 'warning',
critical: 'error',
};
const LEVEL_TITLE: Record<BannerLevel, string> = {
info: 'Information',
important: 'Wichtig',
critical: 'Kritisch',
};
export default function AnnouncementBanner() {
const [dismissed, setDismissed] = useState<string[]>(() => getDismissed());
const { data: banners = [] } = useQuery({
queryKey: ['banners', 'active'],
queryFn: bannerApi.getActive,
refetchInterval: 60_000,
retry: 1,
});
const visible = banners.filter(b => !dismissed.includes(b.id) || b.level === 'critical');
const handleDismiss = (banner: Banner) => {
if (banner.level === 'critical') return; // never dismiss critical
addDismissed(banner.id);
setDismissed(getDismissed());
};
if (visible.length === 0) return null;
return (
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
{visible.map(banner => (
<Collapse key={banner.id} in>
<Alert
severity={LEVEL_MAP[banner.level]}
variant="filled"
action={
banner.level !== 'critical' ? (
<IconButton size="small" color="inherit" onClick={() => handleDismiss(banner)}>
<CloseIcon fontSize="small" />
</IconButton>
) : undefined
}
>
<AlertTitle sx={{ fontWeight: 700 }}>{LEVEL_TITLE[banner.level]}</AlertTitle>
{banner.message}
</Alert>
</Collapse>
))}
</Box>
);
}

View File

@@ -0,0 +1,216 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Button,
Switch,
FormControlLabel,
Skeleton,
} from '@mui/material';
import { CalendarMonth } from '@mui/icons-material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsApi } from '../../services/events';
import type { CreateVeranstaltungInput } from '../../types/events.types';
import { useNotification } from '../../contexts/NotificationContext';
import { useAuth } from '../../contexts/AuthContext';
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'];
function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return (
date.getFullYear() +
'-' + pad(date.getMonth() + 1) +
'-' + pad(date.getDate()) +
'T' + pad(date.getHours()) +
':' + pad(date.getMinutes())
);
}
function toDateOnly(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
}
function makeDefaults() {
const now = new Date();
const later = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return { datumVon: toDatetimeLocal(now), datumBis: toDatetimeLocal(later) };
}
const EventQuickAddWidget: React.FC = () => {
const { user } = useAuth();
const canWrite = user?.groups?.some(g => WRITE_GROUPS.includes(g)) ?? false;
const defaults = makeDefaults();
const [titel, setTitel] = useState('');
const [datumVon, setDatumVon] = useState(defaults.datumVon);
const [datumBis, setDatumBis] = useState(defaults.datumBis);
const [ganztaegig, setGanztaegig] = useState(false);
const [beschreibung, setBeschreibung] = useState('');
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => {
let datum_von: string;
let datum_bis: string;
if (ganztaegig) {
const vonDate = new Date(datumVon);
const bisDate = new Date(datumBis);
datum_von = new Date(toDateOnly(vonDate) + 'T00:00:00').toISOString();
datum_bis = new Date(toDateOnly(bisDate) + 'T23:59:59').toISOString();
} else {
datum_von = new Date(datumVon).toISOString();
datum_bis = new Date(datumBis).toISOString();
}
const data: CreateVeranstaltungInput = {
titel: titel.trim(),
beschreibung: beschreibung.trim() || null,
ort: null,
kategorie_id: null,
datum_von,
datum_bis,
ganztaegig,
zielgruppen: [],
alle_gruppen: true,
max_teilnehmer: null,
anmeldung_erforderlich: false,
};
return eventsApi.createEvent(data);
},
onSuccess: () => {
showSuccess('Veranstaltung erstellt');
const fresh = makeDefaults();
setTitel('');
setDatumVon(fresh.datumVon);
setDatumBis(fresh.datumBis);
setGanztaegig(false);
setBeschreibung('');
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['upcoming-events'] });
},
onError: () => {
showError('Veranstaltung konnte nicht erstellt werden');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!titel.trim() || !datumVon || !datumBis) return;
mutation.mutate();
};
if (!canWrite) return null;
const dateFieldType = ganztaegig ? 'date' : 'datetime-local';
const datumVonValue = ganztaegig ? datumVon.slice(0, 10) : datumVon;
const datumBisValue = ganztaegig ? datumBis.slice(0, 10) : datumBis;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<CalendarMonth color="primary" />
<Typography variant="h6">Veranstaltung</Typography>
</Box>
{false ? (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
fullWidth
size="small"
label="Titel"
value={titel}
onChange={(e) => setTitel(e.target.value)}
required
inputProps={{ maxLength: 250 }}
/>
<FormControlLabel
control={
<Switch
checked={ganztaegig}
onChange={(e) => setGanztaegig(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2">Ganztägig</Typography>}
sx={{ mx: 0 }}
/>
<TextField
fullWidth
size="small"
label="Datum von"
type={dateFieldType}
value={datumVonValue}
onChange={(e) => {
const val = e.target.value;
setDatumVon(ganztaegig ? val + 'T00:00' : val);
}}
required
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Datum bis"
type={dateFieldType}
value={datumBisValue}
onChange={(e) => {
const val = e.target.value;
setDatumBis(ganztaegig ? val + 'T00:00' : val);
}}
required
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Beschreibung (optional)"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
multiline
rows={2}
inputProps={{ maxLength: 1000 }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!titel.trim() || !datumVon || !datumBis || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
)}
</CardContent>
</Card>
);
};
export default EventQuickAddWidget;

View File

@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Button,
MenuItem,
Select,
FormControl,
InputLabel,
Skeleton,
SelectChangeEvent,
} from '@mui/material';
import { DirectionsCar } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingApi, fetchVehicles } from '../../services/bookings';
import type { CreateBuchungInput } from '../../types/booking.types';
import { useNotification } from '../../contexts/NotificationContext';
import { useAuth } from '../../contexts/AuthContext';
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_moderator', 'dashboard_gruppenfuehrer'];
function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return (
date.getFullYear() +
'-' + pad(date.getMonth() + 1) +
'-' + pad(date.getDate()) +
'T' + pad(date.getHours()) +
':' + pad(date.getMinutes())
);
}
function makeDefaults() {
const now = new Date();
const later = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return { beginn: toDatetimeLocal(now), ende: toDatetimeLocal(later) };
}
const VehicleBookingQuickAddWidget: React.FC = () => {
const { user } = useAuth();
const canWrite = user?.groups?.some(g => WRITE_GROUPS.includes(g)) ?? false;
const defaults = makeDefaults();
const [fahrzeugId, setFahrzeugId] = useState<string>('');
const [titel, setTitel] = useState('');
const [beginn, setBeginn] = useState(defaults.beginn);
const [ende, setEnde] = useState(defaults.ende);
const [beschreibung, setBeschreibung] = useState('');
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const { data: vehicles, isLoading: vehiclesLoading } = useQuery({
queryKey: ['vehicles'],
queryFn: fetchVehicles,
refetchInterval: 10 * 60 * 1000,
retry: 1,
});
const mutation = useMutation({
mutationFn: () => {
const data: CreateBuchungInput = {
fahrzeugId,
titel: titel.trim(),
beschreibung: beschreibung.trim() || null,
beginn: new Date(beginn).toISOString(),
ende: new Date(ende).toISOString(),
buchungsArt: 'intern',
};
return bookingApi.create(data);
},
onSuccess: () => {
showSuccess('Fahrzeugbuchung erstellt');
const fresh = makeDefaults();
setFahrzeugId('');
setTitel('');
setBeginn(fresh.beginn);
setEnde(fresh.ende);
setBeschreibung('');
queryClient.invalidateQueries({ queryKey: ['bookings'] });
},
onError: () => {
showError('Fahrzeugbuchung konnte nicht erstellt werden');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!fahrzeugId || !titel.trim() || !beginn || !ende) return;
mutation.mutate();
};
if (!canWrite) return null;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<DirectionsCar color="primary" />
<Typography variant="h6">Fahrzeugbuchung</Typography>
</Box>
{vehiclesLoading ? (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth size="small">
<InputLabel>Fahrzeug</InputLabel>
<Select
value={fahrzeugId}
label="Fahrzeug"
onChange={(e: SelectChangeEvent<string>) => setFahrzeugId(e.target.value)}
>
{(vehicles ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}{v.amtliches_kennzeichen ? ` (${v.amtliches_kennzeichen})` : ''}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
size="small"
label="Titel"
value={titel}
onChange={(e) => setTitel(e.target.value)}
required
inputProps={{ maxLength: 250 }}
/>
<TextField
fullWidth
size="small"
label="Beginn"
type="datetime-local"
value={beginn}
onChange={(e) => setBeginn(e.target.value)}
required
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Ende"
type="datetime-local"
value={ende}
onChange={(e) => setEnde(e.target.value)}
required
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Beschreibung (optional)"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
multiline
rows={2}
inputProps={{ maxLength: 1000 }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!fahrzeugId || !titel.trim() || !beginn || !ende || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
)}
</CardContent>
</Card>
);
};
export default VehicleBookingQuickAddWidget;

View File

@@ -10,3 +10,6 @@ export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
export { default as AdminStatusWidget } from './AdminStatusWidget';
export { default as VehicleBookingQuickAddWidget } from './VehicleBookingQuickAddWidget';
export { default as EventQuickAddWidget } from './EventQuickAddWidget';
export { default as AnnouncementBanner } from './AnnouncementBanner';