calendar and vehicle booking rework

This commit is contained in:
Matthias Hochmeister
2026-03-26 08:47:38 +01:00
parent 21bbe57d6f
commit 884397b520
5 changed files with 586 additions and 291 deletions

View File

@@ -24,16 +24,20 @@ import {
MenuItem,
Paper,
Popover,
Radio,
RadioGroup,
Select,
Skeleton,
Stack,
Switch,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
@@ -55,14 +59,14 @@ import {
HelpOutline as UnknownIcon,
IosShare,
PictureAsPdf as PdfIcon,
Settings as SettingsIcon,
Star as StarIcon,
Today as TodayIcon,
Tune,
ViewList as ListViewIcon,
ViewDay as ViewDayIcon,
ViewWeek as ViewWeekIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ServiceModePage from '../components/shared/ServiceModePage';
import ChatAwareFab from '../components/shared/ChatAwareFab';
@@ -1620,6 +1624,155 @@ function VeranstaltungFormDialog({
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Settings Tab (Kategorien CRUD)
// ──────────────────────────────────────────────────────────────────────────────
interface SettingsTabProps {
kategorien: VeranstaltungKategorie[];
onKategorienChange: (k: VeranstaltungKategorie[]) => void;
}
function SettingsTab({ kategorien, onKategorienChange }: SettingsTabProps) {
const notification = useNotification();
const [editingKat, setEditingKat] = useState<VeranstaltungKategorie | null>(null);
const [newKatOpen, setNewKatOpen] = useState(false);
const [newKatForm, setNewKatForm] = useState({ name: '', farbe: '#1976d2', beschreibung: '' });
const [saving, setSaving] = useState(false);
const reload = async () => {
const kat = await eventsApi.getKategorien();
onKategorienChange(kat);
};
const handleCreate = async () => {
if (!newKatForm.name.trim()) return;
setSaving(true);
try {
await eventsApi.createKategorie({ name: newKatForm.name.trim(), farbe: newKatForm.farbe, beschreibung: newKatForm.beschreibung || undefined });
notification.showSuccess('Kategorie erstellt');
setNewKatOpen(false);
setNewKatForm({ name: '', farbe: '#1976d2', beschreibung: '' });
await reload();
} catch { notification.showError('Fehler beim Erstellen'); }
finally { setSaving(false); }
};
const handleUpdate = async () => {
if (!editingKat) return;
setSaving(true);
try {
await eventsApi.updateKategorie(editingKat.id, { name: editingKat.name, farbe: editingKat.farbe, beschreibung: editingKat.beschreibung ?? undefined });
notification.showSuccess('Kategorie gespeichert');
setEditingKat(null);
await reload();
} catch { notification.showError('Fehler beim Speichern'); }
finally { setSaving(false); }
};
const handleDelete = async (id: string) => {
try {
await eventsApi.deleteKategorie(id);
notification.showSuccess('Kategorie gelöscht');
await reload();
} catch { notification.showError('Fehler beim Löschen'); }
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>Veranstaltungskategorien</Typography>
<Button startIcon={<Add />} variant="contained" size="small" onClick={() => setNewKatOpen(true)}>
Neue Kategorie
</Button>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Farbe</TableCell>
<TableCell>Name</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kategorien.map((k) => (
<TableRow key={k.id}>
<TableCell>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', bgcolor: k.farbe, border: '1px solid', borderColor: 'divider' }} />
</TableCell>
<TableCell>{k.name}</TableCell>
<TableCell>{k.beschreibung ?? '—'}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => setEditingKat({ ...k })}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDelete(k.id)}>
<DeleteForeverIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{kategorien.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 3, color: 'text.secondary' }}>
Noch keine Kategorien vorhanden
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Edit dialog */}
<Dialog open={Boolean(editingKat)} onClose={() => setEditingKat(null)} maxWidth="xs" fullWidth>
<DialogTitle>Kategorie bearbeiten</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Name" value={editingKat?.name ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, name: e.target.value } : k)} fullWidth required />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">Farbe</Typography>
<input type="color" value={editingKat?.farbe ?? '#1976d2'} onChange={(e) => setEditingKat((k) => k ? { ...k, farbe: e.target.value } : k)} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
<Typography variant="body2" color="text.secondary">{editingKat?.farbe}</Typography>
</Box>
<TextField label="Beschreibung" value={editingKat?.beschreibung ?? ''} onChange={(e) => setEditingKat((k) => k ? { ...k, beschreibung: e.target.value || null } : k)} fullWidth multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditingKat(null)}>Abbrechen</Button>
<Button variant="contained" onClick={handleUpdate} disabled={saving || !editingKat?.name.trim()}>
{saving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
{/* New category dialog */}
<Dialog open={newKatOpen} onClose={() => setNewKatOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Name" value={newKatForm.name} onChange={(e) => setNewKatForm((f) => ({ ...f, name: e.target.value }))} fullWidth required />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2">Farbe</Typography>
<input type="color" value={newKatForm.farbe} onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))} style={{ width: 40, height: 36, border: 'none', cursor: 'pointer', borderRadius: 4 }} />
<Typography variant="body2" color="text.secondary">{newKatForm.farbe}</Typography>
</Box>
<TextField label="Beschreibung" value={newKatForm.beschreibung} onChange={(e) => setNewKatForm((f) => ({ ...f, beschreibung: e.target.value }))} fullWidth multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewKatOpen(false)}>Abbrechen</Button>
<Button variant="contained" onClick={handleCreate} disabled={saving || !newKatForm.name.trim()}>
{saving ? <CircularProgress size={20} /> : 'Erstellen'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Main Kalender Page
// ──────────────────────────────────────────────────────────────────────────────
@@ -1633,6 +1786,11 @@ export default function Kalender() {
const canWriteEvents = hasPermission('kalender:create');
// ── Tab / search params ───────────────────────────────────────────────────
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = Number(searchParams.get('tab') ?? 0);
const setActiveTab = (n: number) => setSearchParams({ tab: String(n) });
// ── Calendar state ─────────────────────────────────────────────────────────
const today = new Date();
const [viewMonth, setViewMonth] = useState({
@@ -1664,6 +1822,8 @@ export default function Kalender() {
const [cancelEventLoading, setCancelEventLoading] = useState(false);
const [deleteEventId, setDeleteEventId] = useState<string | null>(null);
const [deleteEventLoading, setDeleteEventLoading] = useState(false);
const [deleteMode, setDeleteMode] = useState<'single' | 'future' | 'all'>('all');
const [editScopeEvent, setEditScopeEvent] = useState<VeranstaltungListItem | null>(null);
// iCal subscription
const [icalEventOpen, setIcalEventOpen] = useState(false);
@@ -1850,7 +2010,7 @@ export default function Kalender() {
if (!deleteEventId) return;
setDeleteEventLoading(true);
try {
await eventsApi.deleteEvent(deleteEventId);
await eventsApi.deleteEvent(deleteEventId, deleteMode);
notification.showSuccess('Veranstaltung wurde endgültig gelöscht');
setDeleteEventId(null);
loadCalendarData();
@@ -1861,6 +2021,20 @@ export default function Kalender() {
}
};
const handleOpenDeleteDialog = useCallback((id: string) => {
setDeleteMode('all');
setDeleteEventId(id);
}, []);
const handleEventEdit = useCallback(async (ev: VeranstaltungListItem) => {
if (ev.wiederholung_parent_id) {
setEditScopeEvent(ev);
return;
}
setVeranstEditing(ev);
setVeranstFormOpen(true);
}, []);
const handleIcalEventOpen = async () => {
try {
const { subscribeUrl } = await eventsApi.getCalendarToken();
@@ -1889,6 +2063,15 @@ export default function Kalender() {
</Typography>
</Box>
{canWriteEvents ? (
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}>
<Tab label="Kalender" value={0} />
<Tab icon={<SettingsIcon fontSize="small" />} iconPosition="start" label="Einstellungen" value={1} />
</Tabs>
) : null}
{activeTab === 0 && (
<>
{/* ── Calendar ───────────────────────────────────────────── */}
<Box>
{/* Controls row */}
@@ -1941,18 +2124,6 @@ export default function Kalender() {
</Tooltip>
</ButtonGroup>
{/* Kategorien verwalten */}
{canWriteEvents && (
<Tooltip title="Kategorien verwalten">
<IconButton
size="small"
onClick={() => navigate('/veranstaltungen/kategorien')}
>
<Tune fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* PDF Export — available in all views */}
<Tooltip title="Als PDF exportieren">
<IconButton
@@ -2259,15 +2430,12 @@ export default function Kalender() {
selectedKategorie={selectedKategorie}
canWriteEvents={canWriteEvents}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventEdit={handleEventEdit}
onEventCancel={(id) => {
setCancelEventId(id);
setCancelEventGrund('');
}}
onEventDelete={(id) => setDeleteEventId(id)}
onEventDelete={handleOpenDeleteDialog}
/>
</Paper>
</>
@@ -2294,11 +2462,8 @@ export default function Kalender() {
canWriteEvents={canWriteEvents}
onClose={() => setPopoverAnchor(null)}
onTrainingClick={(id) => navigate(`/training/${id}`)}
onEventEdit={(ev) => {
setVeranstEditing(ev);
setVeranstFormOpen(true);
}}
onEventDelete={(id) => setDeleteEventId(id)}
onEventEdit={handleEventEdit}
onEventDelete={handleOpenDeleteDialog}
/>
{/* Veranstaltung Form Dialog */}
@@ -2347,30 +2512,111 @@ export default function Kalender() {
</Dialog>
{/* Endgültig löschen Dialog */}
{(() => {
const deleteEvent = veranstaltungen.find(e => e.id === deleteEventId) ?? null;
const isRecurring = Boolean(deleteEvent?.wiederholung || deleteEvent?.wiederholung_parent_id);
return (
<Dialog
open={Boolean(deleteEventId)}
onClose={() => setDeleteEventId(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Veranstaltung endgültig löschen?</DialogTitle>
<DialogContent>
<DialogContentText>
Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
{isRecurring && (
<RadioGroup
value={deleteMode}
onChange={(e) => setDeleteMode(e.target.value as 'single' | 'future' | 'all')}
sx={{ mt: 2 }}
>
<FormControlLabel value="single" control={<Radio />} label="Nur diesen Termin" />
<FormControlLabel value="future" control={<Radio />} label="Diesen und alle folgenden Termine" />
<FormControlLabel value="all" control={<Radio />} label="Alle Termine der Serie" />
</RadioGroup>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteEventId(null)} disabled={deleteEventLoading}>
Abbrechen
</Button>
<Button
variant="contained"
color="error"
onClick={handleDeleteEvent}
disabled={deleteEventLoading}
>
{deleteEventLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'}
</Button>
</DialogActions>
</Dialog>
);
})()}
{/* Edit scope dialog for recurring event instances */}
<Dialog
open={Boolean(deleteEventId)}
onClose={() => setDeleteEventId(null)}
open={Boolean(editScopeEvent)}
onClose={() => setEditScopeEvent(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Veranstaltung endgültig löschen?</DialogTitle>
<DialogTitle>Wiederkehrenden Termin bearbeiten</DialogTitle>
<DialogContent>
<DialogContentText>
Diese Veranstaltung wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
Welche Termine möchtest du bearbeiten?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteEventId(null)} disabled={deleteEventLoading}>
Abbrechen
<DialogActions sx={{ flexDirection: 'column', alignItems: 'stretch', gap: 1, pb: 2, px: 2 }}>
<Button
variant="outlined"
onClick={() => {
if (editScopeEvent) {
setVeranstEditing(editScopeEvent);
setVeranstFormOpen(true);
}
setEditScopeEvent(null);
}}
>
Nur diesen Termin bearbeiten
</Button>
<Button
variant="contained"
color="error"
onClick={handleDeleteEvent}
disabled={deleteEventLoading}
onClick={async () => {
if (!editScopeEvent?.wiederholung_parent_id) return;
try {
const parent = await eventsApi.getById(editScopeEvent.wiederholung_parent_id);
setVeranstEditing({
id: parent.id,
titel: parent.titel,
beschreibung: parent.beschreibung,
datum_von: parent.datum_von,
datum_bis: parent.datum_bis,
ganztaegig: parent.ganztaegig,
ort: parent.ort,
kategorie_id: parent.kategorie_id,
kategorie_name: parent.kategorie_name,
kategorie_farbe: parent.kategorie_farbe,
kategorie_icon: parent.kategorie_icon,
wiederholung: parent.wiederholung,
wiederholung_parent_id: null,
alle_gruppen: parent.alle_gruppen,
zielgruppen: parent.zielgruppen ?? [],
anmeldung_erforderlich: parent.anmeldung_erforderlich,
abgesagt: parent.abgesagt,
});
setVeranstFormOpen(true);
} catch {
notification.showError('Fehler beim Laden der Serie');
}
setEditScopeEvent(null);
}}
>
{deleteEventLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'}
Alle Termine der Serie bearbeiten
</Button>
<Button onClick={() => setEditScopeEvent(null)}>Abbrechen</Button>
</DialogActions>
</Dialog>
@@ -2414,6 +2660,12 @@ export default function Kalender() {
</DialogActions>
</Dialog>
</Box>
</>
)}
{activeTab === 1 && canWriteEvents && (
<SettingsTab kategorien={kategorien} onKategorienChange={setKategorien} />
)}
</Box>