new features
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 || ''}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user