715 lines
28 KiB
TypeScript
715 lines
28 KiB
TypeScript
import { useState } from 'react';
|
||
import {
|
||
Box,
|
||
Container,
|
||
Typography,
|
||
Paper,
|
||
Card,
|
||
CardContent,
|
||
Button,
|
||
IconButton,
|
||
TextField,
|
||
Select,
|
||
MenuItem,
|
||
FormControl,
|
||
InputLabel,
|
||
CircularProgress,
|
||
Alert,
|
||
Chip,
|
||
Dialog,
|
||
DialogTitle,
|
||
DialogContent,
|
||
DialogActions,
|
||
Tab,
|
||
Tabs,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableContainer,
|
||
TableHead,
|
||
TableRow,
|
||
Checkbox,
|
||
Stack,
|
||
Tooltip,
|
||
} from '@mui/material';
|
||
import {
|
||
Add,
|
||
IosShare,
|
||
ContentCopy,
|
||
Cancel,
|
||
Edit,
|
||
Delete,
|
||
Save,
|
||
Close,
|
||
EventBusy,
|
||
} from '@mui/icons-material';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { format, parseISO } from 'date-fns';
|
||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||
import GermanDateField from '../components/shared/GermanDateField';
|
||
import ServiceModePage from '../components/shared/ServiceModePage';
|
||
import { useAuth } from '../contexts/AuthContext';
|
||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||
import { useNotification } from '../contexts/NotificationContext';
|
||
import { bookingApi, fetchVehicles, kategorieApi } from '../services/bookings';
|
||
import type {
|
||
BuchungsArt,
|
||
BuchungsKategorie,
|
||
} from '../types/booking.types';
|
||
import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function formatBookingDate(beginn: string, ende: string, ganztaegig: boolean): string {
|
||
try {
|
||
const b = parseISO(beginn);
|
||
const e = parseISO(ende);
|
||
if (ganztaegig) {
|
||
return `${format(b, 'dd.MM.yyyy')} – ${format(e, 'dd.MM.yyyy')} (Ganztägig)`;
|
||
}
|
||
return `${format(b, 'dd.MM.yyyy HH:mm')} – ${format(e, 'dd.MM.yyyy HH:mm')}`;
|
||
} catch {
|
||
return `${beginn} – ${ende}`;
|
||
}
|
||
}
|
||
|
||
function getCategoryColor(buchungsArt: BuchungsArt, kategorien: BuchungsKategorie[]): string {
|
||
const kat = kategorien.find((k) => k.bezeichnung.toLowerCase() === buchungsArt);
|
||
if (kat) return kat.farbe;
|
||
return BUCHUNGS_ART_COLORS[buchungsArt] || '#757575';
|
||
}
|
||
|
||
function getCategoryLabel(buchungsArt: BuchungsArt, kategorien: BuchungsKategorie[]): string {
|
||
const kat = kategorien.find((k) => k.bezeichnung.toLowerCase() === buchungsArt);
|
||
if (kat) return kat.bezeichnung;
|
||
return BUCHUNGS_ART_LABELS[buchungsArt] || buchungsArt;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main Page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function FahrzeugBuchungen() {
|
||
const { user } = useAuth();
|
||
const { hasPermission, isFeatureEnabled } = usePermissionContext();
|
||
const notification = useNotification();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
|
||
const canCreate = hasPermission('fahrzeugbuchungen:create');
|
||
const canManage = hasPermission('fahrzeugbuchungen:manage');
|
||
|
||
const [tabIndex, setTabIndex] = useState(0);
|
||
|
||
// ── Filters ────────────────────────────────────────────────────────────────
|
||
const today = new Date();
|
||
const defaultFrom = format(today, 'yyyy-MM-dd');
|
||
const defaultTo = format(new Date(today.getTime() + 90 * 86400000), 'yyyy-MM-dd');
|
||
const [filterFrom, setFilterFrom] = useState(defaultFrom);
|
||
const [filterTo, setFilterTo] = useState(defaultTo);
|
||
const [filterVehicle, setFilterVehicle] = useState('');
|
||
const [filterKategorie, setFilterKategorie] = useState('');
|
||
|
||
// ── Data ───────────────────────────────────────────────────────────────────
|
||
const { data: vehicles = [] } = useQuery({
|
||
queryKey: ['vehicles'],
|
||
queryFn: fetchVehicles,
|
||
});
|
||
|
||
const { data: kategorien = [] } = useQuery({
|
||
queryKey: ['buchungskategorien-all'],
|
||
queryFn: kategorieApi.getAll,
|
||
});
|
||
|
||
const { data: activeKategorien = [] } = useQuery({
|
||
queryKey: ['buchungskategorien'],
|
||
queryFn: kategorieApi.getActive,
|
||
});
|
||
|
||
const fromDate = filterFrom ? new Date(`${filterFrom}T00:00:00`) : new Date();
|
||
const toDate = filterTo ? new Date(`${filterTo}T23:59:59`) : new Date(Date.now() + 90 * 86400000);
|
||
|
||
const {
|
||
data: calendarData,
|
||
isLoading,
|
||
isError,
|
||
error: loadError,
|
||
} = useQuery({
|
||
queryKey: ['bookings-range', filterFrom, filterTo, filterVehicle],
|
||
queryFn: () =>
|
||
bookingApi.getCalendarRange(
|
||
fromDate,
|
||
toDate,
|
||
filterVehicle || undefined
|
||
),
|
||
});
|
||
|
||
const bookings = (calendarData?.bookings || [])
|
||
.filter((b) => !b.abgesagt)
|
||
.filter((b) => !filterKategorie || b.buchungs_art === filterKategorie)
|
||
.sort((a, b) => new Date(a.beginn).getTime() - new Date(b.beginn).getTime());
|
||
|
||
// ── Cancel ─────────────────────────────────────────────────────────────────
|
||
const [cancelId, setCancelId] = useState<string | null>(null);
|
||
const [cancelGrund, setCancelGrund] = useState('');
|
||
const [cancelLoading, setCancelLoading] = useState(false);
|
||
|
||
const handleCancel = async () => {
|
||
if (!cancelId) return;
|
||
setCancelLoading(true);
|
||
try {
|
||
await bookingApi.cancel(cancelId, cancelGrund);
|
||
notification.showSuccess('Buchung storniert');
|
||
setCancelId(null);
|
||
setCancelGrund('');
|
||
queryClient.invalidateQueries({ queryKey: ['bookings-range'] });
|
||
} catch (e: unknown) {
|
||
const axiosErr = e as { response?: { data?: { message?: string } }; message?: string };
|
||
notification.showError(
|
||
axiosErr?.response?.data?.message || (e instanceof Error ? e.message : 'Fehler beim Stornieren')
|
||
);
|
||
} finally {
|
||
setCancelLoading(false);
|
||
}
|
||
};
|
||
|
||
// ── iCal ───────────────────────────────────────────────────────────────────
|
||
const [icalOpen, setIcalOpen] = useState(false);
|
||
const [icalUrl, setIcalUrl] = useState('');
|
||
|
||
const handleIcalOpen = async () => {
|
||
try {
|
||
const { subscribeUrl } = await bookingApi.getCalendarToken();
|
||
setIcalUrl(subscribeUrl);
|
||
setIcalOpen(true);
|
||
} catch (e: unknown) {
|
||
notification.showError(e instanceof Error ? e.message : 'Fehler beim Laden des Tokens');
|
||
}
|
||
};
|
||
|
||
// ── Einstellungen: Categories management ───────────────────────────────────
|
||
const [editRowId, setEditRowId] = useState<number | null>(null);
|
||
const [editRowData, setEditRowData] = useState<Partial<BuchungsKategorie>>({});
|
||
const [newKatDialog, setNewKatDialog] = useState(false);
|
||
const [newKatForm, setNewKatForm] = useState({ bezeichnung: '', farbe: '#1976d2' });
|
||
|
||
const createKatMutation = useMutation({
|
||
mutationFn: (data: Omit<BuchungsKategorie, 'id'>) => kategorieApi.create(data),
|
||
onSuccess: () => {
|
||
notification.showSuccess('Kategorie erstellt');
|
||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
|
||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
|
||
setNewKatDialog(false);
|
||
setNewKatForm({ bezeichnung: '', farbe: '#1976d2' });
|
||
},
|
||
onError: () => notification.showError('Fehler beim Erstellen'),
|
||
});
|
||
|
||
const updateKatMutation = useMutation({
|
||
mutationFn: ({ id, data }: { id: number; data: Partial<BuchungsKategorie> }) =>
|
||
kategorieApi.update(id, data),
|
||
onSuccess: () => {
|
||
notification.showSuccess('Kategorie aktualisiert');
|
||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
|
||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
|
||
setEditRowId(null);
|
||
},
|
||
onError: () => notification.showError('Fehler beim Aktualisieren'),
|
||
});
|
||
|
||
const deleteKatMutation = useMutation({
|
||
mutationFn: (id: number) => kategorieApi.delete(id),
|
||
onSuccess: () => {
|
||
notification.showSuccess('Kategorie deaktiviert');
|
||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien-all'] });
|
||
queryClient.invalidateQueries({ queryKey: ['buchungskategorien'] });
|
||
},
|
||
onError: () => notification.showError('Fehler beim Löschen'),
|
||
});
|
||
|
||
// ── Render ─────────────────────────────────────────────────────────────────
|
||
if (!isFeatureEnabled('fahrzeugbuchungen')) {
|
||
return <ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." />;
|
||
}
|
||
|
||
return (
|
||
<DashboardLayout>
|
||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||
{/* Header */}
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||
<Typography variant="h4" fontWeight={700}>
|
||
Fahrzeugbuchungen
|
||
</Typography>
|
||
<Button startIcon={<IosShare />} onClick={handleIcalOpen} variant="outlined" size="small">
|
||
iCal abonnieren
|
||
</Button>
|
||
</Box>
|
||
|
||
{/* Tabs */}
|
||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)}>
|
||
<Tab label="Buchungen" />
|
||
{canManage && <Tab label="Einstellungen" />}
|
||
</Tabs>
|
||
</Box>
|
||
|
||
{/* ── Tab 0: Buchungen ─────────────────────────────────────────────── */}
|
||
{tabIndex === 0 && (
|
||
<>
|
||
{/* Filters */}
|
||
<Paper sx={{ p: 2, mb: 2 }}>
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
|
||
<GermanDateField
|
||
size="small"
|
||
label="Von"
|
||
value={filterFrom}
|
||
onChange={(iso) => setFilterFrom(iso)}
|
||
sx={{ minWidth: 160 }}
|
||
/>
|
||
<GermanDateField
|
||
size="small"
|
||
label="Bis"
|
||
value={filterTo}
|
||
onChange={(iso) => setFilterTo(iso)}
|
||
sx={{ minWidth: 160 }}
|
||
/>
|
||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||
<InputLabel>Fahrzeug</InputLabel>
|
||
<Select
|
||
value={filterVehicle}
|
||
onChange={(e) => setFilterVehicle(e.target.value)}
|
||
label="Fahrzeug"
|
||
>
|
||
<MenuItem value="">Alle Fahrzeuge</MenuItem>
|
||
{vehicles.map((v) => (
|
||
<MenuItem key={v.id} value={v.id}>
|
||
{v.bezeichnung}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||
<InputLabel>Kategorie</InputLabel>
|
||
<Select
|
||
value={filterKategorie}
|
||
onChange={(e) => setFilterKategorie(e.target.value)}
|
||
label="Kategorie"
|
||
>
|
||
<MenuItem value="">Alle Kategorien</MenuItem>
|
||
{activeKategorien.map((k) => (
|
||
<MenuItem key={k.id} value={k.bezeichnung.toLowerCase()}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
<Box
|
||
sx={{
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: '50%',
|
||
bgcolor: k.farbe,
|
||
}}
|
||
/>
|
||
{k.bezeichnung}
|
||
</Box>
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
</Stack>
|
||
</Paper>
|
||
|
||
{/* Loading */}
|
||
{isLoading && (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||
<CircularProgress />
|
||
</Box>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{isError && (
|
||
<Alert severity="error" sx={{ mb: 2 }}>
|
||
{loadError instanceof Error ? loadError.message : 'Fehler beim Laden'}
|
||
</Alert>
|
||
)}
|
||
|
||
{/* Bookings list */}
|
||
{!isLoading && !isError && bookings.length === 0 && (
|
||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||
<EventBusy sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
|
||
<Typography variant="body1" color="text.secondary">
|
||
Keine Buchungen vorhanden
|
||
</Typography>
|
||
</Paper>
|
||
)}
|
||
|
||
{!isLoading &&
|
||
!isError &&
|
||
bookings.map((booking) => (
|
||
<Card key={booking.id} sx={{ mb: 1.5 }}>
|
||
<CardContent sx={{ pb: '12px !important' }}>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||
<Box sx={{ flex: 1 }}>
|
||
<Typography variant="subtitle1" fontWeight={700}>
|
||
{booking.titel}
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
{booking.fahrzeug_name}
|
||
{booking.fahrzeug_kennzeichen
|
||
? ` (${booking.fahrzeug_kennzeichen})`
|
||
: ''}
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||
{formatBookingDate(booking.beginn, booking.ende, booking.ganztaegig)}
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||
<Chip
|
||
label={getCategoryLabel(booking.buchungs_art, activeKategorien)}
|
||
size="small"
|
||
sx={{
|
||
bgcolor: getCategoryColor(booking.buchungs_art, activeKategorien),
|
||
color: 'white',
|
||
}}
|
||
/>
|
||
{booking.gebucht_von_name && (
|
||
<Typography variant="caption" color="text.secondary">
|
||
von {booking.gebucht_von_name}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||
{(canManage || booking.gebucht_von === user?.id) && (
|
||
<Tooltip title="Bearbeiten">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => navigate(`/fahrzeugbuchungen/${booking.id}`)}
|
||
>
|
||
<Edit fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
)}
|
||
{(canManage || booking.gebucht_von === user?.id) && (
|
||
<Tooltip title="Stornieren">
|
||
<IconButton
|
||
size="small"
|
||
color="error"
|
||
onClick={() => {
|
||
setCancelId(booking.id);
|
||
setCancelGrund('');
|
||
}}
|
||
>
|
||
<Cancel fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{/* ── Tab 1: Einstellungen ─────────────────────────────────────────── */}
|
||
{tabIndex === 1 && canManage && (
|
||
<Paper sx={{ p: 2 }}>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||
<Typography variant="h6">Buchungskategorien</Typography>
|
||
<Button
|
||
startIcon={<Add />}
|
||
variant="contained"
|
||
size="small"
|
||
onClick={() => setNewKatDialog(true)}
|
||
>
|
||
Neue Kategorie
|
||
</Button>
|
||
</Box>
|
||
|
||
<TableContainer>
|
||
<Table size="small">
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableCell>Bezeichnung</TableCell>
|
||
<TableCell>Farbe</TableCell>
|
||
<TableCell>Sortierung</TableCell>
|
||
<TableCell>Aktiv</TableCell>
|
||
<TableCell align="right">Aktionen</TableCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{kategorien.map((kat) => {
|
||
const isEditing = editRowId === kat.id;
|
||
return (
|
||
<TableRow key={kat.id}>
|
||
<TableCell>
|
||
{isEditing ? (
|
||
<TextField
|
||
size="small"
|
||
value={editRowData.bezeichnung ?? kat.bezeichnung}
|
||
onChange={(e) =>
|
||
setEditRowData((d) => ({ ...d, bezeichnung: e.target.value }))
|
||
}
|
||
/>
|
||
) : (
|
||
kat.bezeichnung
|
||
)}
|
||
</TableCell>
|
||
<TableCell>
|
||
{isEditing ? (
|
||
<input
|
||
type="color"
|
||
value={editRowData.farbe ?? kat.farbe}
|
||
onChange={(e) =>
|
||
setEditRowData((d) => ({ ...d, farbe: e.target.value }))
|
||
}
|
||
style={{ width: 40, height: 28, border: 'none', cursor: 'pointer' }}
|
||
/>
|
||
) : (
|
||
<Box
|
||
sx={{
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: 1,
|
||
bgcolor: kat.farbe,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
}}
|
||
/>
|
||
)}
|
||
</TableCell>
|
||
<TableCell>
|
||
{isEditing ? (
|
||
<TextField
|
||
size="small"
|
||
type="number"
|
||
value={editRowData.sort_order ?? kat.sort_order}
|
||
onChange={(e) =>
|
||
setEditRowData((d) => ({
|
||
...d,
|
||
sort_order: parseInt(e.target.value) || 0,
|
||
}))
|
||
}
|
||
sx={{ width: 80 }}
|
||
/>
|
||
) : (
|
||
kat.sort_order
|
||
)}
|
||
</TableCell>
|
||
<TableCell>
|
||
{isEditing ? (
|
||
<Checkbox
|
||
checked={editRowData.aktiv ?? kat.aktiv}
|
||
onChange={(e) =>
|
||
setEditRowData((d) => ({ ...d, aktiv: e.target.checked }))
|
||
}
|
||
/>
|
||
) : (
|
||
<Checkbox checked={kat.aktiv} disabled />
|
||
)}
|
||
</TableCell>
|
||
<TableCell align="right">
|
||
{isEditing ? (
|
||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||
<IconButton
|
||
size="small"
|
||
color="primary"
|
||
onClick={() =>
|
||
updateKatMutation.mutate({ id: kat.id, data: editRowData })
|
||
}
|
||
>
|
||
<Save fontSize="small" />
|
||
</IconButton>
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => {
|
||
setEditRowId(null);
|
||
setEditRowData({});
|
||
}}
|
||
>
|
||
<Close fontSize="small" />
|
||
</IconButton>
|
||
</Stack>
|
||
) : (
|
||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => {
|
||
setEditRowId(kat.id);
|
||
setEditRowData({
|
||
bezeichnung: kat.bezeichnung,
|
||
farbe: kat.farbe,
|
||
sort_order: kat.sort_order,
|
||
aktiv: kat.aktiv,
|
||
});
|
||
}}
|
||
>
|
||
<Edit fontSize="small" />
|
||
</IconButton>
|
||
<IconButton
|
||
size="small"
|
||
color="error"
|
||
onClick={() => deleteKatMutation.mutate(kat.id)}
|
||
>
|
||
<Delete fontSize="small" />
|
||
</IconButton>
|
||
</Stack>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
{kategorien.length === 0 && (
|
||
<TableRow>
|
||
<TableCell colSpan={5} align="center">
|
||
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
|
||
Keine Kategorien vorhanden
|
||
</Typography>
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Paper>
|
||
)}
|
||
|
||
{/* ── FAB ── */}
|
||
{canCreate && tabIndex === 0 && (
|
||
<ChatAwareFab
|
||
color="primary"
|
||
aria-label="Buchung erstellen"
|
||
onClick={() => navigate('/fahrzeugbuchungen/neu')}
|
||
>
|
||
<Add />
|
||
</ChatAwareFab>
|
||
)}
|
||
|
||
{/* ── Cancel dialog ── */}
|
||
<Dialog
|
||
open={Boolean(cancelId)}
|
||
onClose={() => setCancelId(null)}
|
||
maxWidth="xs"
|
||
fullWidth
|
||
>
|
||
<DialogTitle>Buchung stornieren</DialogTitle>
|
||
<DialogContent>
|
||
<TextField
|
||
fullWidth
|
||
multiline
|
||
rows={3}
|
||
label="Stornierungsgrund"
|
||
value={cancelGrund}
|
||
onChange={(e) => setCancelGrund(e.target.value)}
|
||
sx={{ mt: 1 }}
|
||
helperText={`${cancelGrund.length}/1000 (min. 5 Zeichen)`}
|
||
inputProps={{ maxLength: 1000 }}
|
||
/>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={() => setCancelId(null)} disabled={cancelLoading}>
|
||
Abbrechen
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
color="error"
|
||
onClick={handleCancel}
|
||
disabled={cancelGrund.length < 5 || cancelLoading}
|
||
startIcon={cancelLoading ? <CircularProgress size={16} /> : undefined}
|
||
>
|
||
Stornieren
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
{/* ── iCal dialog ── */}
|
||
<Dialog open={icalOpen} onClose={() => setIcalOpen(false)} maxWidth="sm" fullWidth>
|
||
<DialogTitle>Kalender abonnieren</DialogTitle>
|
||
<DialogContent>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||
Abonniere den Fahrzeugbuchungskalender in deiner Kalenderanwendung. Kopiere die URL und
|
||
füge sie als neuen Kalender (per URL) in Apple Kalender, Google Kalender oder Outlook
|
||
ein.
|
||
</Typography>
|
||
<TextField
|
||
fullWidth
|
||
value={icalUrl}
|
||
InputProps={{
|
||
readOnly: true,
|
||
endAdornment: (
|
||
<IconButton
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(icalUrl);
|
||
notification.showSuccess('URL kopiert!');
|
||
}}
|
||
>
|
||
<ContentCopy />
|
||
</IconButton>
|
||
),
|
||
}}
|
||
/>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={() => setIcalOpen(false)}>Schließen</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
{/* ── New category dialog ── */}
|
||
<Dialog
|
||
open={newKatDialog}
|
||
onClose={() => setNewKatDialog(false)}
|
||
maxWidth="xs"
|
||
fullWidth
|
||
>
|
||
<DialogTitle>Neue Kategorie</DialogTitle>
|
||
<DialogContent>
|
||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||
<TextField
|
||
fullWidth
|
||
size="small"
|
||
label="Bezeichnung"
|
||
required
|
||
value={newKatForm.bezeichnung}
|
||
onChange={(e) =>
|
||
setNewKatForm((f) => ({ ...f, bezeichnung: e.target.value }))
|
||
}
|
||
/>
|
||
<Box>
|
||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||
Farbe
|
||
</Typography>
|
||
<input
|
||
type="color"
|
||
value={newKatForm.farbe}
|
||
onChange={(e) => setNewKatForm((f) => ({ ...f, farbe: e.target.value }))}
|
||
style={{ width: 60, height: 36, border: 'none', cursor: 'pointer' }}
|
||
/>
|
||
</Box>
|
||
</Stack>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={() => setNewKatDialog(false)}>Abbrechen</Button>
|
||
<Button
|
||
variant="contained"
|
||
onClick={() =>
|
||
createKatMutation.mutate({
|
||
bezeichnung: newKatForm.bezeichnung,
|
||
farbe: newKatForm.farbe,
|
||
aktiv: true,
|
||
sort_order: kategorien.length,
|
||
})
|
||
}
|
||
disabled={!newKatForm.bezeichnung || createKatMutation.isPending}
|
||
>
|
||
Erstellen
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</Container>
|
||
</DashboardLayout>
|
||
);
|
||
}
|
||
|
||
export default FahrzeugBuchungen;
|