annoucement banners, calendar pdf export, vehicle booking quck-add, even quick-add
This commit is contained in:
229
frontend/src/components/admin/BannerManagementTab.tsx
Normal file
229
frontend/src/components/admin/BannerManagementTab.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
IconButton,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { bannerApi } from '../../services/banners';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import type { BannerLevel } from '../../types/banner.types';
|
||||
|
||||
const LEVEL_LABEL: Record<BannerLevel, string> = {
|
||||
info: 'Info',
|
||||
important: 'Wichtig',
|
||||
critical: 'Kritisch',
|
||||
};
|
||||
|
||||
const LEVEL_COLOR: Record<BannerLevel, 'info' | 'warning' | 'error'> = {
|
||||
info: 'info',
|
||||
important: 'warning',
|
||||
critical: 'error',
|
||||
};
|
||||
|
||||
function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return 'Kein Ablauf';
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function BannerManagementTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [newLevel, setNewLevel] = useState<BannerLevel>('info');
|
||||
const [newEndsAt, setNewEndsAt] = useState('');
|
||||
|
||||
const { data: banners, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'banners'],
|
||||
queryFn: bannerApi.getAll,
|
||||
placeholderData: (previousData: any) => previousData,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
bannerApi.create({
|
||||
message: newMessage.trim(),
|
||||
level: newLevel,
|
||||
starts_at: new Date().toISOString(),
|
||||
ends_at: newEndsAt ? new Date(newEndsAt).toISOString() : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'banners'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['banners', 'active'] });
|
||||
showSuccess('Banner wurde erstellt');
|
||||
setDialogOpen(false);
|
||||
setNewMessage('');
|
||||
setNewLevel('info');
|
||||
setNewEndsAt('');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error?.response?.data?.message || 'Banner konnte nicht erstellt werden';
|
||||
showError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => bannerApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'banners'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['banners', 'active'] });
|
||||
showSuccess('Banner wurde gelöscht');
|
||||
},
|
||||
onError: () => {
|
||||
showError('Banner konnte nicht gelöscht werden');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newMessage.trim()) {
|
||||
createMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setDialogOpen(false);
|
||||
setNewMessage('');
|
||||
setNewLevel('info');
|
||||
setNewEndsAt('');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Ankündigungsbanner</Typography>
|
||||
<Button startIcon={<AddIcon />} variant="contained" onClick={() => setDialogOpen(true)}>
|
||||
Banner erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Stufe</TableCell>
|
||||
<TableCell>Nachricht</TableCell>
|
||||
<TableCell>Erstellt am</TableCell>
|
||||
<TableCell>Ablauf</TableCell>
|
||||
<TableCell>Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(banners ?? []).map((banner) => (
|
||||
<TableRow key={banner.id}>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={LEVEL_LABEL[banner.level]}
|
||||
color={LEVEL_COLOR[banner.level]}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 400 }}>{banner.message}</TableCell>
|
||||
<TableCell>{formatDateTime(banner.created_at)}</TableCell>
|
||||
<TableCell>{formatDateTime(banner.ends_at)}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => deleteMutation.mutate(banner.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(banners ?? []).length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center">Keine Banner vorhanden</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Banner erstellen</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Nachricht"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
inputProps={{ maxLength: 2000 }}
|
||||
helperText={`${newMessage.length}/2000`}
|
||||
/>
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>Stufe</InputLabel>
|
||||
<Select
|
||||
value={newLevel}
|
||||
label="Stufe"
|
||||
onChange={(e) => setNewLevel(e.target.value as BannerLevel)}
|
||||
>
|
||||
<MenuItem value="info">Info</MenuItem>
|
||||
<MenuItem value="important">Wichtig</MenuItem>
|
||||
<MenuItem value="critical">Kritisch</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Ablaufdatum (optional)"
|
||||
type="datetime-local"
|
||||
fullWidth
|
||||
value={newEndsAt}
|
||||
onChange={(e) => setNewEndsAt(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
helperText="Leer lassen für kein Ablaufdatum"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Abbrechen</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
variant="contained"
|
||||
disabled={createMutation.isPending || !newMessage.trim()}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default BannerManagementTab;
|
||||
72
frontend/src/components/dashboard/AnnouncementBanner.tsx
Normal file
72
frontend/src/components/dashboard/AnnouncementBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
frontend/src/components/dashboard/EventQuickAddWidget.tsx
Normal file
216
frontend/src/components/dashboard/EventQuickAddWidget.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user