calendar and vehicle booking rework
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user