new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 14:01:39 +01:00
parent d2dc64d54a
commit 3326156b15
35 changed files with 1341 additions and 257 deletions

View File

@@ -11,6 +11,7 @@ import ServiceModeTab from '../components/admin/ServiceModeTab';
import FdiskSyncTab from '../components/admin/FdiskSyncTab';
import PermissionMatrixTab from '../components/admin/PermissionMatrixTab';
import BestellungenTab from '../components/admin/BestellungenTab';
import DataManagementTab from '../components/admin/DataManagementTab';
import { usePermissionContext } from '../contexts/PermissionContext';
interface TabPanelProps {
@@ -24,7 +25,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
const ADMIN_TAB_COUNT = 9;
const ADMIN_TAB_COUNT = 10;
function AdminDashboard() {
const navigate = useNavigate();
@@ -59,6 +60,7 @@ function AdminDashboard() {
<Tab label="FDISK Sync" />
<Tab label="Berechtigungen" />
<Tab label="Bestellungen" />
<Tab label="Datenverwaltung" />
</Tabs>
</Box>
@@ -89,6 +91,9 @@ function AdminDashboard() {
<TabPanel value={tab} index={8}>
<BestellungenTab />
</TabPanel>
<TabPanel value={tab} index={9}>
<DataManagementTab />
</TabPanel>
</DashboardLayout>
);
}

View File

@@ -89,6 +89,8 @@ export default function Bestellungen() {
const [statusFilter, setStatusFilter] = useState<string>('');
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const [orderForm, setOrderForm] = useState<BestellungFormData>({ ...emptyOrderForm });
const [inlineVendorOpen, setInlineVendorOpen] = useState(false);
const [inlineVendorForm, setInlineVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
const [vendorDialogOpen, setVendorDialogOpen] = useState(false);
const [vendorForm, setVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
@@ -122,10 +124,17 @@ export default function Bestellungen() {
const createVendor = useMutation({
mutationFn: (data: LieferantFormData) => bestellungApi.createVendor(data),
onSuccess: () => {
onSuccess: (newVendor) => {
queryClient.invalidateQueries({ queryKey: ['lieferanten'] });
showSuccess('Lieferant erstellt');
closeVendorDialog();
// If inline vendor creation during order creation, auto-select the new vendor
if (inlineVendorOpen) {
setOrderForm((f) => ({ ...f, lieferant_id: newVendor.id }));
setInlineVendorOpen(false);
setInlineVendorForm({ ...emptyVendorForm });
} else {
closeVendorDialog();
}
},
onError: () => showError('Fehler beim Erstellen des Lieferanten'),
});
@@ -264,14 +273,6 @@ export default function Bestellungen() {
{/* ── Tab 1: Vendors ── */}
<TabPanel value={tab} index={1}>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
{hasPermission('bestellungen:create') && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setVendorDialogOpen(true)}>
Lieferant hinzufügen
</Button>
)}
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
@@ -315,6 +316,12 @@ export default function Bestellungen() {
</TableBody>
</Table>
</TableContainer>
{hasPermission('bestellungen:manage_vendors') && (
<ChatAwareFab onClick={() => setVendorDialogOpen(true)} aria-label="Lieferant hinzufügen">
<AddIcon />
</ChatAwareFab>
)}
</TabPanel>
{/* ── Create Order Dialog ── */}
@@ -327,13 +334,38 @@ export default function Bestellungen() {
value={orderForm.bezeichnung}
onChange={(e) => setOrderForm((f) => ({ ...f, bezeichnung: e.target.value }))}
/>
<Autocomplete
options={vendors}
getOptionLabel={(o) => o.name}
value={vendors.find((v) => v.id === orderForm.lieferant_id) || null}
onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))}
renderInput={(params) => <TextField {...params} label="Lieferant" />}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Autocomplete
options={vendors}
getOptionLabel={(o) => o.name}
value={vendors.find((v) => v.id === orderForm.lieferant_id) || null}
onChange={(_e, v) => setOrderForm((f) => ({ ...f, lieferant_id: v?.id }))}
renderInput={(params) => <TextField {...params} label="Lieferant" />}
sx={{ flexGrow: 1 }}
/>
<Tooltip title="Neuen Lieferant anlegen">
<IconButton
onClick={() => setInlineVendorOpen(!inlineVendorOpen)}
color={inlineVendorOpen ? 'primary' : 'default'}
sx={{ mt: 1 }}
>
<AddIcon />
</IconButton>
</Tooltip>
</Box>
{inlineVendorOpen && (
<Paper variant="outlined" sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="subtitle2">Neuer Lieferant</Typography>
<TextField size="small" label="Name *" value={inlineVendorForm.name} onChange={(e) => setInlineVendorForm(f => ({ ...f, name: e.target.value }))} />
<TextField size="small" label="Kontakt-Name" value={inlineVendorForm.kontakt_name || ''} onChange={(e) => setInlineVendorForm(f => ({ ...f, kontakt_name: e.target.value }))} />
<TextField size="small" label="E-Mail" value={inlineVendorForm.email || ''} onChange={(e) => setInlineVendorForm(f => ({ ...f, email: e.target.value }))} />
<TextField size="small" label="Telefon" value={inlineVendorForm.telefon || ''} onChange={(e) => setInlineVendorForm(f => ({ ...f, telefon: e.target.value }))} />
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button size="small" onClick={() => { setInlineVendorOpen(false); setInlineVendorForm({ ...emptyVendorForm }); }}>Abbrechen</Button>
<Button size="small" variant="contained" onClick={() => createVendor.mutate(inlineVendorForm)} disabled={!inlineVendorForm.name.trim() || createVendor.isPending}>Anlegen</Button>
</Box>
</Paper>
)}
<TextField
label="Besteller"
value={orderForm.besteller_id || ''}

View File

@@ -21,11 +21,13 @@ import {
Select,
MenuItem,
FormControl,
FormControlLabel,
InputLabel,
CircularProgress,
Alert,
Popover,
Stack,
Switch,
Tooltip,
} from '@mui/material';
import {
@@ -83,6 +85,7 @@ const EMPTY_FORM: CreateBuchungInput = {
buchungsArt: 'intern',
kontaktPerson: '',
kontaktTelefon: '',
ganztaegig: false,
};
// ---------------------------------------------------------------------------
@@ -218,6 +221,7 @@ function FahrzeugBuchungen() {
const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM });
const [dialogLoading, setDialogLoading] = useState(false);
const [dialogError, setDialogError] = useState<string | null>(null);
const [overrideOutOfService, setOverrideOutOfService] = useState(false);
const [availability, setAvailability] = useState<{
available: boolean;
reason?: string;
@@ -254,6 +258,7 @@ function FahrzeugBuchungen() {
setForm({ ...EMPTY_FORM });
setDialogError(null);
setAvailability(null);
setOverrideOutOfService(false);
setDialogOpen(true);
};
@@ -265,6 +270,7 @@ function FahrzeugBuchungen() {
setForm({ ...EMPTY_FORM, fahrzeugId: vehicleId, beginn: dateStr, ende: dateEndStr });
setDialogError(null);
setAvailability(null);
setOverrideOutOfService(false);
setDialogOpen(true);
};
@@ -276,27 +282,33 @@ function FahrzeugBuchungen() {
...form,
beginn: new Date(form.beginn).toISOString(),
ende: new Date(form.ende).toISOString(),
ganztaegig: form.ganztaegig || false,
};
if (editingBooking) {
await bookingApi.update(editingBooking.id, payload);
notification.showSuccess('Buchung aktualisiert');
} else {
await bookingApi.create(payload);
await bookingApi.create({ ...payload, ignoreOutOfService: overrideOutOfService } as any);
notification.showSuccess('Buchung erstellt');
}
setDialogOpen(false);
loadData();
} catch (e: unknown) {
const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string };
if (axiosError?.response?.status === 409) {
const reason = axiosError?.response?.data?.reason;
if (reason === 'out_of_service') {
setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst');
try {
const axiosError = e as { response?: { status?: number; data?: { message?: string; reason?: string } }; message?: string };
if (axiosError?.response?.status === 409) {
const reason = axiosError?.response?.data?.reason;
if (reason === 'out_of_service') {
setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum außer Dienst');
} else {
setDialogError(axiosError?.response?.data?.message || 'Fahrzeug ist im gewählten Zeitraum bereits gebucht');
}
} else {
setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
const msg = axiosError?.response?.data?.message || axiosError?.message || 'Fehler beim Speichern';
setDialogError(msg);
}
} else {
setDialogError(axiosError?.message || 'Fehler beim Speichern');
} catch {
setDialogError(e instanceof Error ? e.message : 'Fehler beim Speichern');
}
} finally {
setDialogLoading(false);
@@ -495,11 +507,6 @@ function FahrzeugBuchungen() {
<Typography variant="body2" fontWeight={600}>
{vehicle.bezeichnung}
</Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="caption" color="text.secondary">
{vehicle.amtliches_kennzeichen}
</Typography>
)}
</TableCell>
{weekDays.map((day) => {
const cellBookings = getBookingsForCell(vehicle.id, day);
@@ -774,16 +781,39 @@ function FahrzeugBuchungen() {
}
/>
<FormControlLabel
control={
<Switch
checked={form.ganztaegig || false}
onChange={(e) => {
const checked = e.target.checked;
setForm((f) => {
if (checked && f.beginn) {
const dateStr = f.beginn.split('T')[0];
return { ...f, ganztaegig: true, beginn: `${dateStr}T00:00`, ende: f.ende ? `${(f.ende.split('T')[0])}T23:59` : `${dateStr}T23:59` };
}
return { ...f, ganztaegig: checked };
});
}}
/>
}
label="Ganztägig"
/>
<TextField
fullWidth
size="small"
label="Beginn"
type="datetime-local"
type={form.ganztaegig ? 'date' : 'datetime-local'}
required
value={form.beginn}
onChange={(e) =>
setForm((f) => ({ ...f, beginn: e.target.value }))
}
value={form.ganztaegig ? (form.beginn?.split('T')[0] || '') : form.beginn}
onChange={(e) => {
if (form.ganztaegig) {
setForm((f) => ({ ...f, beginn: `${e.target.value}T00:00` }));
} else {
setForm((f) => ({ ...f, beginn: e.target.value }));
}
}}
InputLabelProps={{ shrink: true }}
/>
@@ -791,12 +821,16 @@ function FahrzeugBuchungen() {
fullWidth
size="small"
label="Ende"
type="datetime-local"
type={form.ganztaegig ? 'date' : 'datetime-local'}
required
value={form.ende}
onChange={(e) =>
setForm((f) => ({ ...f, ende: e.target.value }))
}
value={form.ganztaegig ? (form.ende?.split('T')[0] || '') : form.ende}
onChange={(e) => {
if (form.ganztaegig) {
setForm((f) => ({ ...f, ende: `${e.target.value}T23:59` }));
} else {
setForm((f) => ({ ...f, ende: e.target.value }));
}
}}
InputLabelProps={{ shrink: true }}
/>
@@ -818,16 +852,34 @@ function FahrzeugBuchungen() {
size="small"
/>
) : availability.reason === 'out_of_service' ? (
<Chip
icon={<Block />}
label={
availability.ausserDienstBis
? `Außer Dienst bis ${new Date(availability.ausserDienstBis).toLocaleDateString('de-DE')} (geschätzt)`
: 'Fahrzeug ist außer Dienst'
}
color="error"
size="small"
/>
<Box>
<Chip
icon={<Block />}
label={
availability.ausserDienstBis
? `Außer Dienst bis ${new Date(availability.ausserDienstBis).toLocaleDateString('de-DE')} (geschätzt)`
: 'Fahrzeug ist außer Dienst'
}
color="error"
size="small"
/>
<FormControlLabel
control={
<Switch
checked={overrideOutOfService}
onChange={(e) => setOverrideOutOfService(e.target.checked)}
color="warning"
size="small"
/>
}
label={
<Typography variant="body2" color="warning.main">
Trotz Außer-Dienst-Status buchen
</Typography>
}
sx={{ mt: 0.5 }}
/>
</Box>
) : (
<Chip
icon={<Warning />}

View File

@@ -43,6 +43,7 @@ import {
DirectionsCar,
Edit,
Error as ErrorIcon,
History,
LocalFireDepartment,
MoreHoriz,
PauseCircle,
@@ -121,6 +122,58 @@ function fmtDatetime(iso: string | Date | null | undefined): string {
return fmtDate(iso ? new Date(iso).toISOString() : null);
}
// ── Status History Section ────────────────────────────────────────────────────
const StatusHistorySection: React.FC<{ vehicleId: string }> = ({ vehicleId }) => {
const [history, setHistory] = useState<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
vehiclesApi.getStatusHistory(vehicleId)
.then(setHistory)
.catch(() => setHistory([]))
.finally(() => setLoading(false));
}, [vehicleId]);
if (loading || history.length === 0) return null;
return (
<>
<Typography variant="h6" sx={{ mt: 3, mb: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
<History fontSize="small" /> Status-Historie
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Datum</TableCell>
<TableCell>Von</TableCell>
<TableCell>Nach</TableCell>
<TableCell>Bemerkung</TableCell>
<TableCell>Geändert von</TableCell>
</TableRow>
</TableHead>
<TableBody>
{history.map((h, idx) => (
<TableRow key={idx}>
<TableCell>{fmtDatetime(h.erstellt_am)}</TableCell>
<TableCell>
<Chip size="small" label={FahrzeugStatusLabel[h.alter_status as FahrzeugStatus] || h.alter_status} color={STATUS_CHIP_COLOR[h.alter_status as FahrzeugStatus] || 'default'} />
</TableCell>
<TableCell>
<Chip size="small" label={FahrzeugStatusLabel[h.neuer_status as FahrzeugStatus] || h.neuer_status} color={STATUS_CHIP_COLOR[h.neuer_status as FahrzeugStatus] || 'default'} />
</TableCell>
<TableCell>{h.bemerkung || '—'}</TableCell>
<TableCell>{h.geaendert_von_name || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
};
// ── Übersicht Tab ─────────────────────────────────────────────────────────────
interface UebersichtTabProps {
@@ -148,7 +201,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
const openDialog = () => {
setNewStatus(vehicle.status);
setBemerkung(vehicle.status_bemerkung ?? '');
setBemerkung('');
setAusserDienstVon(
vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : ''
);
@@ -323,6 +376,9 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
})}
</Grid>
{/* Status history */}
<StatusHistorySection vehicleId={vehicle.id} />
{/* Status change dialog */}
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>Fahrzeugstatus ändern</DialogTitle>
@@ -475,6 +531,42 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
entry.externe_werkstatt && entry.externe_werkstatt,
].filter(Boolean).join(' · ')}
</Typography>
{entry.dokument_url ? (
<Chip
label="Dokument"
size="small"
color="info"
variant="outlined"
component="a"
href={`/api/uploads/${entry.dokument_url.split('/uploads/')[1] || entry.dokument_url}`}
target="_blank"
clickable
sx={{ mt: 0.5 }}
/>
) : canWrite ? (
<Button
size="small"
component="label"
sx={{ mt: 0.5, textTransform: 'none', fontSize: '0.75rem' }}
>
Dokument hochladen
<input
type="file"
hidden
accept=".pdf,.doc,.docx,.jpg,.png"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await vehiclesApi.uploadWartungFile(entry.id, file);
onAdded();
} catch {
// silent fail — user can retry
}
}}
/>
</Button>
) : null}
</Box>
</Box>
);

View File

@@ -167,8 +167,6 @@ function FahrzeugForm() {
hersteller: form.hersteller.trim() || null,
typ_schluessel: form.typ_schluessel.trim() || null,
besatzung_soll: form.besatzung_soll.trim() || null,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || null,
standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || null,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || null,
@@ -186,8 +184,6 @@ function FahrzeugForm() {
hersteller: form.hersteller.trim() || undefined,
typ_schluessel: form.typ_schluessel.trim() || undefined,
besatzung_soll: form.besatzung_soll.trim() || undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
@@ -285,32 +281,6 @@ function FahrzeugForm() {
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value as FahrzeugStatus }))}
>
{Object.values(FahrzeugStatus).map((s) => (
<MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Status-Bemerkung"
fullWidth
{...f('status_bemerkung')}
placeholder="z.B. Fahrzeug in Werkstatt bis 01.03."
/>
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüf- und Wartungsfristen</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>

View File

@@ -236,8 +236,8 @@ function KatalogTab() {
{/* Artikel create/edit dialog */}
<Dialog open={artikelDialogOpen} onClose={() => setArtikelDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField label="Bezeichnung" required value={artikelForm.bezeichnung} onChange={e => setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} />
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
<TextField label="Bezeichnung" required value={artikelForm.bezeichnung} onChange={e => setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth />
<TextField label="Beschreibung" multiline rows={2} value={artikelForm.beschreibung ?? ''} onChange={e => setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} />
<Autocomplete
freeSolo

View File

@@ -300,7 +300,7 @@ function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps
export default function VeranstaltungKategorien() {
const { hasPermission } = usePermissionContext();
const canManage = hasPermission('kalender:manage_categories');
const canManage = hasPermission('kalender:create');
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
const [groups, setGroups] = useState<GroupInfo[]>([]);

View File

@@ -21,8 +21,6 @@ import {
InputLabel,
FormControlLabel,
Switch,
Checkbox,
FormGroup,
Stack,
List,
ListItem,
@@ -34,6 +32,9 @@ import {
useTheme,
useMediaQuery,
Snackbar,
Autocomplete,
Radio,
RadioGroup,
} from '@mui/material';
import {
Add,
@@ -61,6 +62,7 @@ import type {
GroupInfo,
CreateVeranstaltungInput,
ConflictEvent,
WiederholungConfig,
} from '../types/events.types';
// ---------------------------------------------------------------------------
@@ -667,16 +669,6 @@ function EventFormDialog({
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleGroupToggle = (groupId: string) => {
setForm((prev) => {
const current = prev.zielgruppen;
const updated = current.includes(groupId)
? current.filter((g) => g !== groupId)
: [...current, groupId];
return { ...prev, zielgruppen: updated };
});
};
const handleSave = async () => {
if (!form.titel.trim()) {
notification.showError('Titel ist erforderlich');
@@ -866,28 +858,33 @@ function EventFormDialog({
label="Für alle Mitglieder sichtbar"
/>
{/* Zielgruppen checkboxes */}
{/* Zielgruppen multi-select */}
{!form.alle_gruppen && groups.length > 0 && (
<Box>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
Zielgruppen
</Typography>
<FormGroup>
{groups.map((g) => (
<FormControlLabel
key={g.id}
control={
<Checkbox
checked={form.zielgruppen.includes(g.id)}
onChange={() => handleGroupToggle(g.id)}
size="small"
/>
}
label={g.label}
<Autocomplete
multiple
options={groups}
getOptionLabel={(option) => option.label}
value={groups.filter((g) => form.zielgruppen.includes(g.id))}
onChange={(_, newValue) => {
handleChange('zielgruppen', newValue.map((g) => g.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
renderInput={(params) => (
<TextField {...params} label="Zielgruppen" placeholder="Gruppen auswählen" />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option.id}
label={option.label}
size="small"
/>
))}
</FormGroup>
</Box>
))
}
size="small"
disableCloseOnSelect
/>
)}
<Divider />
@@ -929,6 +926,103 @@ function EventFormDialog({
inputProps={{ min: 1 }}
fullWidth
/>
{/* Recurrence / Wiederholung — only for new events */}
{!editingEvent && (
<>
<Divider />
<FormControlLabel
control={
<Switch
checked={Boolean(form.wiederholung)}
onChange={(e) => {
if (e.target.checked) {
const bisDefault = new Date(form.datum_von);
bisDefault.setMonth(bisDefault.getMonth() + 3);
handleChange('wiederholung', {
typ: 'wöchentlich',
intervall: 1,
bis: bisDefault.toISOString().slice(0, 10),
} as WiederholungConfig);
} else {
handleChange('wiederholung', null);
}
}}
/>
}
label="Wiederholung"
/>
{form.wiederholung && (
<Stack spacing={2} sx={{ pl: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Häufigkeit</InputLabel>
<Select
label="Häufigkeit"
value={form.wiederholung.typ}
onChange={(e) => {
const w = { ...form.wiederholung!, typ: e.target.value as WiederholungConfig['typ'] };
handleChange('wiederholung', w);
}}
>
<MenuItem value="wöchentlich">Wöchentlich</MenuItem>
<MenuItem value="zweiwöchentlich">Zweiwöchentlich</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>
{form.wiederholung.typ === 'wöchentlich' && (
<TextField
label="Alle X Wochen"
type="number"
size="small"
value={form.wiederholung.intervall ?? 1}
onChange={(e) => {
const w = { ...form.wiederholung!, intervall: Math.max(1, Number(e.target.value) || 1) };
handleChange('wiederholung', w);
}}
inputProps={{ min: 1, max: 52 }}
fullWidth
/>
)}
{(form.wiederholung.typ === 'monatlich_erster_wochentag' ||
form.wiederholung.typ === 'monatlich_letzter_wochentag') && (
<FormControl fullWidth size="small">
<InputLabel>Wochentag</InputLabel>
<Select
label="Wochentag"
value={form.wiederholung.wochentag ?? 0}
onChange={(e) => {
const w = { ...form.wiederholung!, wochentag: Number(e.target.value) };
handleChange('wiederholung', w);
}}
>
{WEEKDAY_LABELS.map((label, idx) => (
<MenuItem key={idx} value={idx}>{label === 'Mo' ? 'Montag' : label === 'Di' ? 'Dienstag' : label === 'Mi' ? 'Mittwoch' : label === 'Do' ? 'Donnerstag' : label === 'Fr' ? 'Freitag' : label === 'Sa' ? 'Samstag' : 'Sonntag'}</MenuItem>
))}
</Select>
</FormControl>
)}
<TextField
label="Wiederholen bis"
type="date"
size="small"
value={form.wiederholung.bis}
onChange={(e) => {
const w = { ...form.wiederholung!, bis: e.target.value };
handleChange('wiederholung', w);
}}
InputLabelProps={{ shrink: true }}
fullWidth
helperText="Enddatum der Wiederholungsserie"
/>
</Stack>
)}
</>
)}
</Stack>
</DialogContent>
<DialogActions>
@@ -1105,6 +1199,7 @@ export default function Veranstaltungen() {
// Delete dialog
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteMode, setDeleteMode] = useState<'all' | 'single' | 'future'>('all');
// iCal dialog
const [icalOpen, setIcalOpen] = useState(false);
@@ -1215,8 +1310,9 @@ export default function Veranstaltungen() {
if (!deleteId) return;
setDeleteLoading(true);
try {
await eventsApi.deleteEvent(deleteId);
await eventsApi.deleteEvent(deleteId, deleteMode);
setDeleteId(null);
setDeleteMode('all');
loadData();
notification.showSuccess('Veranstaltung wurde gelöscht');
} catch (e: unknown) {
@@ -1373,7 +1469,12 @@ export default function Veranstaltungen() {
canWrite={canWrite}
onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }}
onCancel={(id) => { setCancelId(id); setCancelGrund(''); }}
onDelete={(id) => setDeleteId(id)}
onDelete={(id) => {
const ev = events.find((e) => e.id === id);
const isRecurring = ev && (ev.wiederholung_parent_id || ev.wiederholung);
setDeleteMode(isRecurring ? 'single' : 'all');
setDeleteId(id);
}}
/>
</Paper>
)}
@@ -1444,15 +1545,38 @@ export default function Veranstaltungen() {
</Dialog>
{/* Delete Dialog */}
<Dialog open={Boolean(deleteId)} onClose={() => setDeleteId(null)} maxWidth="xs" fullWidth>
<Dialog open={Boolean(deleteId)} onClose={() => { setDeleteId(null); setDeleteMode('all'); }} maxWidth="xs" fullWidth>
<DialogTitle>Veranstaltung endgültig löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
{(() => {
const deleteEvent = events.find((ev) => ev.id === deleteId);
const isRecurring = deleteEvent && (deleteEvent.wiederholung_parent_id || deleteEvent.wiederholung);
if (isRecurring) {
return (
<>
<DialogContentText sx={{ mb: 2 }}>
Diese Veranstaltung ist Teil einer Wiederholungsserie. Was soll gelöscht werden?
</DialogContentText>
<RadioGroup
value={deleteMode}
onChange={(e) => setDeleteMode(e.target.value as 'all' | 'single' | 'future')}
>
<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>
</>
);
}
return (
<DialogContentText>
Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
);
})()}
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteId(null)}>Abbrechen</Button>
<Button onClick={() => { setDeleteId(null); setDeleteMode('all'); }}>Abbrechen</Button>
<Button variant="contained" color="error" onClick={handleDeleteEvent} disabled={deleteLoading}>
{deleteLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'}
</Button>