new features, bookstack

This commit is contained in:
Matthias Hochmeister
2026-03-03 21:30:38 +01:00
parent 817329db70
commit d3561c1109
32 changed files with 1923 additions and 207 deletions

View File

@@ -0,0 +1,138 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Divider,
Skeleton,
} from '@mui/material';
import { MenuBook } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
import { bookstackApi } from '../../services/bookstack';
import type { BookStackPage } from '../../types/bookstack.types';
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
page,
showDivider,
}) => {
const handleClick = () => {
window.open(page.url, '_blank', 'noopener,noreferrer');
};
const relativeTime = page.updated_at
? formatDistanceToNow(new Date(page.updated_at), { addSuffix: true, locale: de })
: null;
return (
<>
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{page.name}
</Typography>
{page.book && (
<Typography variant="body2" color="text.secondary" noWrap sx={{ mt: 0.25 }}>
{page.book.name}
</Typography>
)}
</Box>
{relativeTime && (
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
>
{relativeTime}
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
);
};
const BookStackRecentWidget: React.FC = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ['bookstack-recent'],
queryFn: () => bookstackApi.getRecent(),
refetchInterval: 5 * 60 * 1000,
retry: 1,
});
const configured = data?.configured ?? true;
const pages = data?.data ?? [];
if (!configured) return null;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
BookStack Neueste Seiten
</Typography>
</Box>
{isLoading && (
<Box>
{[1, 2, 3, 4, 5].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="70%" height={22} />
<Skeleton variant="text" width="50%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
BookStack nicht erreichbar
</Typography>
)}
{!isLoading && !isError && pages.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Seiten gefunden
</Typography>
)}
{!isLoading && !isError && pages.length > 0 && (
<Box>
{pages.map((page, index) => (
<PageRow
key={page.id}
page={page}
showDivider={index < pages.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default BookStackRecentWidget;

View File

@@ -0,0 +1,152 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Divider,
CircularProgress,
InputAdornment,
} from '@mui/material';
import { Search, MenuBook } from '@mui/icons-material';
import { bookstackApi } from '../../services/bookstack';
import type { BookStackSearchResult } from '../../types/bookstack.types';
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim();
}
const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean }> = ({
result,
showDivider,
}) => {
const preview = result.preview_html?.content ? stripHtml(result.preview_html.content) : '';
return (
<>
<Box
onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')}
sx={{
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Typography variant="subtitle2" noWrap>
{result.name}
</Typography>
{preview && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 0.25,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{preview}
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
);
};
const BookStackSearchWidget: React.FC = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<BookStackSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [configured, setConfigured] = useState(true);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!query.trim()) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
try {
const response = await bookstackApi.search(query.trim());
setConfigured(response.configured);
setResults(response.data);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
if (!configured) return null;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
BookStack Suche
</Typography>
</Box>
<TextField
fullWidth
size="small"
placeholder="Suchbegriff eingeben..."
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{loading ? <CircularProgress size={16} /> : <Search fontSize="small" />}
</InputAdornment>
),
}}
/>
{!loading && query.trim() && results.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Ergebnisse für {query}"
</Typography>
)}
{results.length > 0 && (
<Box sx={{ mt: 1 }}>
{results.map((result, index) => (
<ResultRow
key={result.id}
result={result}
showDivider={index < results.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default BookStackSearchWidget;

View File

@@ -14,6 +14,7 @@ import {
} from '@mui/material';
import { incidentsApi, EINSATZ_ARTEN, EINSATZ_ART_LABELS, CreateEinsatzPayload } from '../../services/incidents';
import { useNotification } from '../../contexts/NotificationContext';
import { toGermanDateTime, fromGermanDateTime } from '../../utils/dateInput';
interface CreateEinsatzDialogProps {
open: boolean;
@@ -21,16 +22,16 @@ interface CreateEinsatzDialogProps {
onSuccess: () => void;
}
// Default alarm_time = now (rounded to minute)
function nowISO(): string {
// Default alarm_time = now (rounded to minute) in DD.MM.YYYY HH:MM format
function nowGerman(): string {
const d = new Date();
d.setSeconds(0, 0);
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
return toGermanDateTime(d.toISOString());
}
const INITIAL_FORM: CreateEinsatzPayload & { alarm_time_local: string } = {
alarm_time: '',
alarm_time_local: nowISO(),
alarm_time_local: nowGerman(),
einsatz_art: 'Brand',
einsatz_stichwort: '',
strasse: '',
@@ -47,7 +48,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
onSuccess,
}) => {
const notification = useNotification();
const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowISO() });
const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -74,8 +75,9 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
setLoading(true);
try {
// Convert local datetime string to UTC ISO string
const isoLocal = fromGermanDateTime(form.alarm_time_local);
const payload: CreateEinsatzPayload = {
alarm_time: new Date(form.alarm_time_local).toISOString(),
alarm_time: isoLocal ? new Date(isoLocal).toISOString() : new Date().toISOString(),
einsatz_art: form.einsatz_art,
einsatz_stichwort: form.einsatz_stichwort || null,
strasse: form.strasse || null,
@@ -88,7 +90,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
await incidentsApi.create(payload);
notification.showSuccess('Einsatz erfolgreich angelegt');
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
onSuccess();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Fehler beim Anlegen des Einsatzes';
@@ -101,7 +103,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
const handleClose = () => {
if (loading) return;
setError(null);
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
onClose();
};
@@ -132,7 +134,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
<TextField
label="Alarmzeit *"
name="alarm_time_local"
type="datetime-local"
placeholder="TT.MM.JJJJ HH:MM"
value={form.alarm_time_local}
onChange={handleChange}
InputLabelProps={{ shrink: true }}
@@ -141,7 +143,6 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
helperText="DD.MM.YYYY HH:mm"
inputProps={{
'aria-label': 'Alarmzeit',
// HTML datetime-local uses YYYY-MM-DDTHH:mm format
}}
/>
</Grid>

View File

@@ -0,0 +1,76 @@
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import { lightTheme, darkTheme } from '../theme/theme';
type ThemeMode = 'system' | 'light' | 'dark';
interface ThemeModeContextValue {
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
resolvedMode: 'light' | 'dark';
}
const ThemeModeContext = createContext<ThemeModeContextValue>({
themeMode: 'system',
setThemeMode: () => {},
resolvedMode: 'light',
});
export function useThemeMode(): ThemeModeContextValue {
return useContext(ThemeModeContext);
}
function getSystemPreference(): 'light' | 'dark' {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
const STORAGE_KEY = 'themeMode';
export const ThemeModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [themeMode, setThemeModeState] = useState<ThemeMode>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark' || stored === 'system') {
return stored;
}
return 'system';
});
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemPreference(e.matches ? 'dark' : 'light');
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
const setThemeMode = (mode: ThemeMode) => {
setThemeModeState(mode);
localStorage.setItem(STORAGE_KEY, mode);
};
const resolvedMode: 'light' | 'dark' =
themeMode === 'system' ? systemPreference : themeMode;
const theme = resolvedMode === 'dark' ? darkTheme : lightTheme;
const value = useMemo(
() => ({ themeMode, setThemeMode, resolvedMode }),
[themeMode, resolvedMode],
);
return (
<ThemeModeContext.Provider value={value}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</ThemeModeContext.Provider>
);
};

View File

@@ -1,9 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { CssBaseline, ThemeProvider } from '@mui/material';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { lightTheme } from './theme/theme';
import { ThemeModeProvider } from './contexts/ThemeContext';
import App from './App';
const queryClient = new QueryClient({
@@ -20,12 +19,11 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={lightTheme}>
<CssBaseline />
<ThemeModeProvider>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ThemeProvider>
</ThemeModeProvider>
</BrowserRouter>
</React.StrictMode>,
);

View File

@@ -45,6 +45,7 @@ import { atemschutzApi } from '../services/atemschutz';
import { membersService } from '../services/members';
import { useNotification } from '../contexts/NotificationContext';
import { useAuth } from '../contexts/AuthContext';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import type {
AtemschutzUebersicht,
AtemschutzStats,
@@ -66,17 +67,6 @@ function formatDate(iso: string | null): string {
});
}
/** Extract YYYY-MM-DD from an ISO timestamp or date string for <input type="date"> */
function toInputDate(iso: string | null | undefined): string {
if (!iso) return '';
// Already DD.MM.YYYY
if (/^\d{2}\.\d{2}\.\d{4}$/.test(iso)) return iso;
// YYYY-MM-DD → DD.MM.YYYY
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return `${m[3]}.${m[2]}.${m[1]}`;
return '';
}
function getDisplayName(item: AtemschutzUebersicht): string {
if (item.user_family_name || item.user_given_name) {
return [item.user_family_name, item.user_given_name].filter(Boolean).join(', ');
@@ -240,12 +230,12 @@ function Atemschutz() {
setForm({
user_id: item.user_id,
atemschutz_lehrgang: item.atemschutz_lehrgang,
lehrgang_datum: toInputDate(item.lehrgang_datum),
untersuchung_datum: toInputDate(item.untersuchung_datum),
untersuchung_gueltig_bis: toInputDate(item.untersuchung_gueltig_bis),
lehrgang_datum: toGermanDate(item.lehrgang_datum),
untersuchung_datum: toGermanDate(item.untersuchung_datum),
untersuchung_gueltig_bis: toGermanDate(item.untersuchung_gueltig_bis),
untersuchung_ergebnis: item.untersuchung_ergebnis || '',
leistungstest_datum: toInputDate(item.leistungstest_datum),
leistungstest_gueltig_bis: toInputDate(item.leistungstest_gueltig_bis),
leistungstest_datum: toGermanDate(item.leistungstest_datum),
leistungstest_gueltig_bis: toGermanDate(item.leistungstest_gueltig_bis),
leistungstest_bestanden: item.leistungstest_bestanden || false,
bemerkung: item.bemerkung || '',
});
@@ -267,24 +257,11 @@ function Atemschutz() {
setForm((prev) => ({ ...prev, [field]: value }));
};
/** Normalize dates before submit: parse DD.MM.YYYY or YYYY-MM-DD → YYYY-MM-DD for API */
/** Normalize dates before submit: parse DD.MM.YYYY → YYYY-MM-DD for API */
const normalizeDate = (val: string | undefined): string | undefined => {
if (!val) return undefined;
// DD.MM.YYYY format (German)
const dmy = val.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (dmy) {
const y = parseInt(dmy[3], 10);
if (y < 1900 || y > 2100) return undefined;
return `${dmy[3]}-${dmy[2].padStart(2, '0')}-${dmy[1].padStart(2, '0')}`;
}
// YYYY-MM-DD format (ISO)
const iso = val.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) {
const y = parseInt(iso[1], 10);
if (y < 1900 || y > 2100) return undefined;
return val;
}
return undefined;
const iso = fromGermanDate(val);
return iso || undefined;
};
const handleSubmit = async () => {

View File

@@ -45,6 +45,7 @@ import {
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import { fromGermanDate } from '../utils/dateInput';
import {
AusruestungDetail,
AusruestungWartungslog,
@@ -375,6 +376,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
setSaveError(null);
await equipmentApi.addWartungslog(equipmentId, {
...form,
datum: fromGermanDate(form.datum) || form.datum,
pruefende_stelle: form.pruefende_stelle || undefined,
ergebnis: form.ergebnis || undefined,
});
@@ -459,8 +461,8 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
<Grid item xs={12} sm={6}>
<TextField
label="Datum *"
type="date"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.datum}
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
InputLabelProps={{ shrink: true }}

View File

@@ -19,6 +19,7 @@ import {
import { ArrowBack, Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import { equipmentApi } from '../services/equipment';
import { vehiclesApi } from '../services/vehicles';
import {
@@ -71,10 +72,9 @@ const EMPTY_FORM: FormState = {
// -- Helpers ------------------------------------------------------------------
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' */
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'DD.MM.YYYY' */
function toDateInput(iso: string | null | undefined): string {
if (!iso) return '';
return iso.slice(0, 10);
return toGermanDate(iso);
}
// -- Component ----------------------------------------------------------------
@@ -217,8 +217,8 @@ function AusruestungForm() {
fahrzeug_id: form.fahrzeug_id || null,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
letzte_pruefung_am: form.letzte_pruefung_am || undefined,
naechste_pruefung_am: form.naechste_pruefung_am || undefined,
letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || undefined : undefined,
naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || undefined : undefined,
bemerkung: form.bemerkung.trim() || undefined,
};
await equipmentApi.update(id, payload);
@@ -237,8 +237,8 @@ function AusruestungForm() {
fahrzeug_id: form.fahrzeug_id || undefined,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
letzte_pruefung_am: form.letzte_pruefung_am || undefined,
naechste_pruefung_am: form.naechste_pruefung_am || undefined,
letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || undefined : undefined,
naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || undefined : undefined,
bemerkung: form.bemerkung.trim() || undefined,
};
const created = await equipmentApi.create(payload);
@@ -462,8 +462,8 @@ function AusruestungForm() {
<Grid item xs={12} sm={4}>
<TextField
label="Letzte Prüfung"
type="date"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.letzte_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
@@ -472,8 +472,8 @@ function AusruestungForm() {
<Grid item xs={12} sm={4}>
<TextField
label="Nächste Prüfung"
type="date"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.naechste_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}

View File

@@ -13,6 +13,8 @@ import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
import BookStackRecentWidget from '../components/dashboard/BookStackRecentWidget';
import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget';
function Dashboard() {
const { user } = useAuth();
const canViewAtemschutz = user?.groups?.some(g =>
@@ -104,6 +106,24 @@ function Dashboard() {
</Fade>
)}
</Box>
{/* BookStack Recent Pages Widget */}
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box>
<BookStackRecentWidget />
</Box>
</Fade>
</Box>
{/* BookStack Search Widget */}
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '560ms' }}>
<Box>
<BookStackSearchWidget />
</Box>
</Fade>
</Box>
</Box>
</Container>
</DashboardLayout>

View File

@@ -36,6 +36,7 @@ import {
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { fromGermanDate } from '../utils/dateInput';
import IncidentStatsChart from '../components/incidents/IncidentStatsChart';
import {
incidentsApi,
@@ -206,12 +207,18 @@ function Einsaetze() {
limit: rowsPerPage,
offset: page * rowsPerPage,
};
if (dateFrom) filters.dateFrom = new Date(dateFrom).toISOString();
if (dateFrom) {
const iso = fromGermanDate(dateFrom);
if (iso) filters.dateFrom = new Date(iso).toISOString();
}
if (dateTo) {
// Set to end of day for dateTo
const end = new Date(dateTo);
end.setHours(23, 59, 59, 999);
filters.dateTo = end.toISOString();
const iso = fromGermanDate(dateTo);
if (iso) {
const end = new Date(iso);
end.setHours(23, 59, 59, 999);
filters.dateTo = end.toISOString();
}
}
if (selectedArts.length === 1) filters.einsatzArt = selectedArts[0];
@@ -336,7 +343,7 @@ function Einsaetze() {
<Grid item xs={12} sm={4} md={3}>
<TextField
label="Von (Alarmzeit)"
type="date"
placeholder="TT.MM.JJJJ"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(0); }}
InputLabelProps={{ shrink: true }}
@@ -348,7 +355,7 @@ function Einsaetze() {
<Grid item xs={12} sm={4} md={3}>
<TextField
label="Bis (Alarmzeit)"
type="date"
placeholder="TT.MM.JJJJ"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(0); }}
InputLabelProps={{ shrink: true }}

View File

@@ -692,18 +692,33 @@ function FahrzeugBuchungen() {
InputLabelProps={{ shrink: true }}
/>
{availability !== null && (
<Chip
icon={availability ? <CheckCircle /> : <Warning />}
label={
availability
? 'Fahrzeug verfügbar'
: 'Konflikt: bereits gebucht'
}
color={availability ? 'success' : 'error'}
size="small"
sx={{ alignSelf: 'flex-start' }}
/>
{/* Availability indicator */}
{form.fahrzeugId && form.beginn && form.ende ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{availability === null ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Verfügbarkeit wird geprüft...
</Typography>
</Box>
) : (
<Chip
icon={availability ? <CheckCircle /> : <Warning />}
label={
availability
? 'Fahrzeug verfügbar'
: 'Konflikt: bereits gebucht'
}
color={availability ? 'success' : 'error'}
size="small"
/>
)}
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Wähle Fahrzeug und Zeitraum für Verfügbarkeitsprüfung
</Typography>
)}
<FormControl fullWidth size="small">
@@ -758,25 +773,43 @@ function FahrzeugBuchungen() {
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={
dialogLoading ||
!form.titel ||
!form.fahrzeugId ||
!form.beginn ||
!form.ende
<Tooltip
title={
dialogLoading || (form.titel && form.fahrzeugId && form.beginn && form.ende)
? ''
: `Bitte fülle alle Pflichtfelder aus: ${[
!form.titel && 'Titel',
!form.fahrzeugId && 'Fahrzeug',
!form.beginn && 'Beginn',
!form.ende && 'Ende',
]
.filter(Boolean)
.join(', ')}`
}
disableHoverListener={!!(form.titel && form.fahrzeugId && form.beginn && form.ende)}
>
{dialogLoading ? (
<CircularProgress size={20} />
) : editingBooking ? (
'Speichern'
) : (
'Buchen'
)}
</Button>
<span>
<Button
variant="contained"
onClick={handleSave}
disabled={
dialogLoading ||
!form.titel ||
!form.fahrzeugId ||
!form.beginn ||
!form.ende
}
>
{dialogLoading ? (
<CircularProgress size={20} />
) : editingBooking ? (
'Speichern'
) : (
'Buchen'
)}
</Button>
</span>
</Tooltip>
</DialogActions>
</Dialog>

View File

@@ -56,6 +56,7 @@ import {
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import { fromGermanDate } from '../utils/dateInput';
import { equipmentApi } from '../services/equipment';
import {
FahrzeugDetail,
@@ -347,6 +348,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
setSaveError(null);
await vehiclesApi.addWartungslog(fahrzeugId, {
...form,
datum: fromGermanDate(form.datum) || form.datum,
externe_werkstatt: form.externe_werkstatt || undefined,
});
setDialogOpen(false);
@@ -411,8 +413,8 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
<Grid item xs={12} sm={6}>
<TextField
label="Datum *"
type="date"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.datum}
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
InputLabelProps={{ shrink: true }}

View File

@@ -18,6 +18,7 @@ import { ArrowBack, Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import {
FahrzeugStatus,
FahrzeugStatusLabel,
@@ -64,10 +65,9 @@ const EMPTY_FORM: FormState = {
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' */
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'DD.MM.YYYY' */
function toDateInput(iso: string | null | undefined): string {
if (!iso) return '';
return iso.slice(0, 10);
return toGermanDate(iso);
}
// ── Component ─────────────────────────────────────────────────────────────────
@@ -169,8 +169,8 @@ function FahrzeugForm() {
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,
naechste_wartung_am: form.naechste_wartung_am || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined,
naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined,
};
await vehiclesApi.update(id, payload);
navigate(`/fahrzeuge/${id}`);
@@ -188,8 +188,8 @@ function FahrzeugForm() {
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,
naechste_wartung_am: form.naechste_wartung_am || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am ? fromGermanDate(form.paragraph57a_faellig_am) || undefined : undefined,
naechste_wartung_am: form.naechste_wartung_am ? fromGermanDate(form.naechste_wartung_am) || undefined : undefined,
};
const newVehicle = await vehiclesApi.create(payload);
navigate(`/fahrzeuge/${newVehicle.id}`);
@@ -314,8 +314,8 @@ function FahrzeugForm() {
<Grid item xs={12} sm={6}>
<TextField
label="§57a fällig am"
type="date"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.paragraph57a_faellig_am}
onChange={(e) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
@@ -325,8 +325,8 @@ function FahrzeugForm() {
<Grid item xs={12} sm={6}>
<TextField
label="Nächste Wartung am"
type="date"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.naechste_wartung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}

View File

@@ -55,8 +55,11 @@ import {
DirectionsCar as CarIcon,
Edit as EditIcon,
Event as EventIcon,
FileDownload as FileDownloadIcon,
FileUpload as FileUploadIcon,
HelpOutline as UnknownIcon,
IosShare,
PictureAsPdf as PdfIcon,
Star as StarIcon,
Today as TodayIcon,
Tune,
@@ -65,6 +68,7 @@ import {
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput';
import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext';
import { trainingApi } from '../services/training';
@@ -193,13 +197,12 @@ function formatDateLong(d: Date): string {
return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`;
}
function toDatetimeLocal(isoString: string): string {
const d = new Date(isoString);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fromDatetimeLocal(value: string): string {
if (!value) return new Date().toISOString();
const dtIso = fromGermanDateTime(value);
if (dtIso) return new Date(dtIso).toISOString();
const dIso = fromGermanDate(value);
if (dIso) return new Date(dIso).toISOString();
return new Date(value).toISOString();
}
@@ -609,6 +612,323 @@ function DayPopover({
);
}
// ──────────────────────────────────────────────────────────────────────────────
// PDF Export helper
// ──────────────────────────────────────────────────────────────────────────────
async function generatePdf(
year: number,
month: number,
trainingEvents: UebungListItem[],
veranstaltungen: VeranstaltungListItem[],
) {
// Dynamically import jsPDF to avoid bundle bloat if not needed
const { jsPDF } = await import('jspdf');
const autoTable = (await import('jspdf-autotable')).default;
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
const monthLabel = MONTH_LABELS[month];
// Header bar
doc.setFillColor(183, 28, 28); // fire-red
doc.rect(0, 0, 297, 18, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(`Kalender — ${monthLabel} ${year}`, 10, 12);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('Feuerwehr Rems', 250, 12);
// Build combined list (same logic as CombinedListView)
type ListEntry =
| { kind: 'training'; item: UebungListItem }
| { kind: 'event'; item: VeranstaltungListItem };
const combined: ListEntry[] = [
...trainingEvents.map((t): ListEntry => ({ kind: 'training', item: t })),
...veranstaltungen.map((e): ListEntry => ({ kind: 'event', item: e })),
].sort((a, b) => a.item.datum_von.localeCompare(b.item.datum_von));
const formatDateCell = (iso: string) => {
const d = new Date(iso);
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
};
const formatTimeCell = (iso: string) => {
const d = new Date(iso);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const rows = combined.map((entry) => {
const item = entry.item;
return [
formatDateCell(item.datum_von),
formatTimeCell(item.datum_von),
item.titel,
entry.kind === 'training'
? (item as UebungListItem).typ
: ((item as VeranstaltungListItem).kategorie_name ?? 'Veranstaltung'),
(item as any).ort ?? '',
];
});
autoTable(doc, {
head: [['Datum', 'Uhrzeit', 'Titel', 'Kategorie/Typ', 'Ort']],
body: rows,
startY: 22,
headStyles: { fillColor: [183, 28, 28], textColor: 255, fontStyle: 'bold' },
alternateRowStyles: { fillColor: [250, 235, 235] },
margin: { left: 10, right: 10 },
styles: { fontSize: 9, cellPadding: 2 },
columnStyles: {
0: { cellWidth: 25 },
1: { cellWidth: 18 },
2: { cellWidth: 90 },
3: { cellWidth: 40 },
4: { cellWidth: 60 },
},
});
const filename = `kalender_${year}_${String(month + 1).padStart(2, '0')}.pdf`;
doc.save(filename);
}
// ──────────────────────────────────────────────────────────────────────────────
// CSV Import Dialog
// ──────────────────────────────────────────────────────────────────────────────
const CSV_EXAMPLE = [
'Titel;Datum Von;Datum Bis;Ganztaegig;Ort;Kategorie;Beschreibung',
'Übung Atemschutz;15.03.2026 19:00;15.03.2026 21:00;Nein;Feuerwehrhaus;Übung;Atemschutzübung für alle',
'Tag der offenen Tür;20.04.2026;20.04.2026;Ja;Feuerwehrhaus;Veranstaltung;',
].join('\n');
interface CsvRow {
titel: string;
datum_von: string;
datum_bis: string;
ganztaegig: boolean;
ort: string | null;
beschreibung: string | null;
valid: boolean;
error?: string;
}
function parseCsvRow(line: string, lineNo: number): CsvRow {
const parts = line.split(';');
if (parts.length < 4) {
return { titel: '', datum_von: '', datum_bis: '', ganztaegig: false, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Zu wenige Spalten` };
}
const [titel, rawVon, rawBis, rawGanztaegig, ort, , beschreibung] = parts;
const ganztaegig = rawGanztaegig?.trim().toLowerCase() === 'ja';
const convertDate = (raw: string): string => {
const trimmed = raw.trim();
// DD.MM.YYYY HH:MM
const dtIso = fromGermanDateTime(trimmed);
if (dtIso) return new Date(dtIso).toISOString();
// DD.MM.YYYY
const dIso = fromGermanDate(trimmed);
if (dIso) return new Date(dIso + 'T00:00:00').toISOString();
return '';
};
const datum_von = convertDate(rawVon ?? '');
const datum_bis = convertDate(rawBis ?? '');
if (!titel?.trim()) {
return { titel: '', datum_von, datum_bis, ganztaegig, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Titel fehlt` };
}
if (!datum_von) {
return { titel: titel.trim(), datum_von, datum_bis, ganztaegig, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Datum Von ungültig` };
}
return {
titel: titel.trim(),
datum_von,
datum_bis: datum_bis || datum_von,
ganztaegig,
ort: ort?.trim() || null,
beschreibung: beschreibung?.trim() || null,
valid: true,
};
}
interface CsvImportDialogProps {
open: boolean;
onClose: () => void;
onImported: () => void;
}
function CsvImportDialog({ open, onClose, onImported }: CsvImportDialogProps) {
const [rows, setRows] = useState<CsvRow[]>([]);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ created: number; errors: string[] } | null>(null);
const notification = useNotification();
const fileRef = React.useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result as string;
const lines = text.split(/\r?\n/).filter((l) => l.trim());
// Skip header line
const dataLines = lines[0]?.toLowerCase().includes('titel') ? lines.slice(1) : lines;
const parsed = dataLines.map((line, i) => parseCsvRow(line, i + 2));
setRows(parsed);
setResult(null);
};
reader.readAsText(file, 'UTF-8');
};
const downloadExample = () => {
const blob = new Blob(['\uFEFF' + CSV_EXAMPLE], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'kalender_import_beispiel.csv';
a.click();
URL.revokeObjectURL(url);
};
const validRows = rows.filter((r) => r.valid);
const handleImport = async () => {
if (validRows.length === 0) return;
setImporting(true);
try {
const events = validRows.map((r) => ({
titel: r.titel,
datum_von: r.datum_von,
datum_bis: r.datum_bis,
ganztaegig: r.ganztaegig,
ort: r.ort,
beschreibung: r.beschreibung,
zielgruppen: [],
alle_gruppen: true,
anmeldung_erforderlich: false,
}));
const res = await eventsApi.importEvents(events);
setResult(res);
if (res.created > 0) {
notification.showSuccess(`${res.created} Veranstaltung${res.created !== 1 ? 'en' : ''} importiert`);
onImported();
}
} catch {
notification.showError('Import fehlgeschlagen');
} finally {
setImporting(false);
}
};
const handleClose = () => {
setRows([]);
setResult(null);
if (fileRef.current) fileRef.current.value = '';
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>Kalender importieren (CSV)</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<FileDownloadIcon />}
onClick={downloadExample}
sx={{ alignSelf: 'flex-start' }}
>
Beispiel-CSV herunterladen
</Button>
<Box>
<input
ref={fileRef}
type="file"
accept=".csv,text/csv"
onChange={handleFileChange}
style={{ display: 'none' }}
id="csv-import-input"
/>
<label htmlFor="csv-import-input">
<Button variant="contained" component="span" startIcon={<FileUploadIcon />}>
CSV-Datei auswählen
</Button>
</label>
</Box>
{rows.length > 0 && (
<>
<Typography variant="body2">
{validRows.length} gültige / {rows.length - validRows.length} fehlerhafte Zeilen
</Typography>
{rows.some((r) => !r.valid) && (
<Alert severity="warning" sx={{ whiteSpace: 'pre-line' }}>
{rows.filter((r) => !r.valid).map((r) => r.error).join('\n')}
</Alert>
)}
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Titel</TableCell>
<TableCell>Von</TableCell>
<TableCell>Bis</TableCell>
<TableCell>Ganztägig</TableCell>
<TableCell>Ort</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i} sx={{ bgcolor: row.valid ? undefined : 'error.light' }}>
<TableCell>
<Chip
label={row.valid ? 'OK' : 'Fehler'}
color={row.valid ? 'success' : 'error'}
size="small"
/>
</TableCell>
<TableCell>{row.titel}</TableCell>
<TableCell>{row.datum_von ? new Date(row.datum_von).toLocaleDateString('de-DE') : '—'}</TableCell>
<TableCell>{row.datum_bis ? new Date(row.datum_bis).toLocaleDateString('de-DE') : '—'}</TableCell>
<TableCell>{row.ganztaegig ? 'Ja' : 'Nein'}</TableCell>
<TableCell>{row.ort ?? '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
)}
{result && (
<Alert severity={result.errors.length === 0 ? 'success' : 'warning'}>
{result.created} Veranstaltung{result.created !== 1 ? 'en' : ''} importiert.
{result.errors.length > 0 && ` ${result.errors.length} Fehler.`}
</Alert>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Schließen</Button>
<Button
variant="contained"
onClick={handleImport}
disabled={validRows.length === 0 || importing}
startIcon={importing ? <CircularProgress size={16} /> : <FileUploadIcon />}
>
{validRows.length > 0 ? `${validRows.length} importieren` : 'Importieren'}
</Button>
</DialogActions>
</Dialog>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Combined List View (training + events sorted by date)
// ──────────────────────────────────────────────────────────────────────────────
@@ -874,7 +1194,7 @@ function VeranstaltungFormDialog({
wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis)
? {
typ: wiederholungTyp,
bis: wiederholungBis,
bis: fromGermanDate(wiederholungBis) || wiederholungBis,
intervall: wiederholungTyp === 'wöchentlich' ? wiederholungIntervall : undefined,
wochentag: (wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag')
? wiederholungWochentag
@@ -956,16 +1276,17 @@ function VeranstaltungFormDialog({
/>
<TextField
label="Von"
type={form.ganztaegig ? 'date' : 'datetime-local'}
placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'}
value={
form.ganztaegig
? toDatetimeLocal(form.datum_von).slice(0, 10)
: toDatetimeLocal(form.datum_von)
? toGermanDate(form.datum_von)
: toGermanDateTime(form.datum_von)
}
onChange={(e) => {
const raw = e.target.value;
const iso = form.ganztaegig
? fromDatetimeLocal(`${e.target.value}T00:00`)
: fromDatetimeLocal(e.target.value);
? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 00:00` : '')
: fromDatetimeLocal(raw);
handleChange('datum_von', iso);
}}
InputLabelProps={{ shrink: true }}
@@ -973,16 +1294,17 @@ function VeranstaltungFormDialog({
/>
<TextField
label="Bis"
type={form.ganztaegig ? 'date' : 'datetime-local'}
placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'}
value={
form.ganztaegig
? toDatetimeLocal(form.datum_bis).slice(0, 10)
: toDatetimeLocal(form.datum_bis)
? toGermanDate(form.datum_bis)
: toGermanDateTime(form.datum_bis)
}
onChange={(e) => {
const raw = e.target.value;
const iso = form.ganztaegig
? fromDatetimeLocal(`${e.target.value}T23:59`)
: fromDatetimeLocal(e.target.value);
? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 23:59` : '')
: fromDatetimeLocal(raw);
handleChange('datum_bis', iso);
}}
InputLabelProps={{ shrink: true }}
@@ -1039,11 +1361,14 @@ function VeranstaltungFormDialog({
{form.anmeldung_erforderlich && (
<TextField
label="Anmeldeschluss"
type="datetime-local"
value={form.anmeldung_bis ? toDatetimeLocal(form.anmeldung_bis) : ''}
onChange={(e) =>
handleChange('anmeldung_bis', e.target.value ? fromDatetimeLocal(e.target.value) : null)
}
placeholder="TT.MM.JJJJ HH:MM"
value={form.anmeldung_bis ? toGermanDateTime(form.anmeldung_bis) : ''}
onChange={(e) => {
const raw = e.target.value;
if (!raw) { handleChange('anmeldung_bis', null); return; }
const iso = fromGermanDateTime(raw);
if (iso) handleChange('anmeldung_bis', new Date(iso).toISOString());
}}
InputLabelProps={{ shrink: true }}
fullWidth
/>
@@ -1109,8 +1434,8 @@ function VeranstaltungFormDialog({
<TextField
label="Wiederholungen bis"
type="date"
size="small"
placeholder="TT.MM.JJJJ"
value={wiederholungBis}
onChange={(e) => setWiederholungBis(e.target.value)}
InputLabelProps={{ shrink: true }}
@@ -1216,6 +1541,7 @@ export default function Kalender() {
const [icalEventOpen, setIcalEventOpen] = useState(false);
const [icalEventUrl, setIcalEventUrl] = useState('');
const [icalBookingOpen, setIcalBookingOpen] = useState(false);
const [csvImportOpen, setCsvImportOpen] = useState(false);
const [icalBookingUrl, setIcalBookingUrl] = useState('');
// ── Data loading ─────────────────────────────────────────────────────────────
@@ -1405,8 +1731,8 @@ export default function Kalender() {
setBookingForm({
...EMPTY_BOOKING_FORM,
fahrzeugId: vehicleId,
beginn: fnsFormat(day, "yyyy-MM-dd'T'08:00"),
ende: fnsFormat(day, "yyyy-MM-dd'T'17:00"),
beginn: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'08:00")),
ende: toGermanDateTime(fnsFormat(day, "yyyy-MM-dd'T'17:00")),
});
setBookingDialogError(null);
setAvailability(null);
@@ -1420,8 +1746,8 @@ export default function Kalender() {
fahrzeugId: detailBooking.fahrzeug_id,
titel: detailBooking.titel,
beschreibung: '',
beginn: fnsFormat(parseISO(detailBooking.beginn as unknown as string), "yyyy-MM-dd'T'HH:mm"),
ende: fnsFormat(parseISO(detailBooking.ende as unknown as string), "yyyy-MM-dd'T'HH:mm"),
beginn: toGermanDateTime(fnsFormat(parseISO(detailBooking.beginn as unknown as string), "yyyy-MM-dd'T'HH:mm")),
ende: toGermanDateTime(fnsFormat(parseISO(detailBooking.ende as unknown as string), "yyyy-MM-dd'T'HH:mm")),
buchungsArt: detailBooking.buchungs_art,
kontaktPerson: '',
kontaktTelefon: '',
@@ -1440,11 +1766,13 @@ export default function Kalender() {
return;
}
let cancelled = false;
const beginnIso = fromGermanDateTime(bookingForm.beginn) || bookingForm.beginn;
const endeIso = fromGermanDateTime(bookingForm.ende) || bookingForm.ende;
bookingApi
.checkAvailability(
bookingForm.fahrzeugId,
new Date(bookingForm.beginn),
new Date(bookingForm.ende)
new Date(beginnIso),
new Date(endeIso)
)
.then(({ available }) => {
if (!cancelled) setAvailability(available);
@@ -1461,8 +1789,8 @@ export default function Kalender() {
try {
const payload: CreateBuchungInput = {
...bookingForm,
beginn: new Date(bookingForm.beginn).toISOString(),
ende: new Date(bookingForm.ende).toISOString(),
beginn: (() => { const iso = fromGermanDateTime(bookingForm.beginn); return iso ? new Date(iso).toISOString() : new Date(bookingForm.beginn).toISOString(); })(),
ende: (() => { const iso = fromGermanDateTime(bookingForm.ende); return iso ? new Date(iso).toISOString() : new Date(bookingForm.ende).toISOString(); })(),
};
if (editingBooking) {
await bookingApi.update(editingBooking.id, payload);
@@ -1622,6 +1950,35 @@ export default function Kalender() {
</Tooltip>
)}
{/* PDF Export — only in list view */}
{viewMode === 'list' && (
<Tooltip title="Als PDF exportieren">
<IconButton
size="small"
onClick={() => generatePdf(
viewMonth.year,
viewMonth.month,
trainingForMonth,
eventsForMonth,
)}
>
<PdfIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* CSV Import */}
{canWriteEvents && (
<Tooltip title="Kalender importieren (CSV)">
<IconButton
size="small"
onClick={() => setCsvImportOpen(true)}
>
<FileUploadIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{/* iCal subscribe */}
<Button
startIcon={<IosShare />}
@@ -2166,7 +2523,7 @@ export default function Kalender() {
/>
<TextField
fullWidth size="small" label="Beginn" type="datetime-local" required
fullWidth size="small" label="Beginn" placeholder="TT.MM.JJJJ HH:MM" required
value={bookingForm.beginn}
onChange={(e) =>
setBookingForm((f) => ({ ...f, beginn: e.target.value }))
@@ -2175,7 +2532,7 @@ export default function Kalender() {
/>
<TextField
fullWidth size="small" label="Ende" type="datetime-local" required
fullWidth size="small" label="Ende" placeholder="TT.MM.JJJJ HH:MM" required
value={bookingForm.ende}
onChange={(e) =>
setBookingForm((f) => ({ ...f, ende: e.target.value }))
@@ -2327,6 +2684,16 @@ export default function Kalender() {
)}
</Box>
{/* CSV Import Dialog */}
<CsvImportDialog
open={csvImportOpen}
onClose={() => setCsvImportOpen(false)}
onImported={() => {
setCsvImportOpen(false);
loadCalendarData();
}}
/>
</DashboardLayout>
);
}

View File

@@ -39,6 +39,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { membersService } from '../services/members';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import {
MemberWithProfile,
StatusEnum,
@@ -239,10 +240,11 @@ function MitgliedDetail() {
setFormData({
mitglieds_nr: member.profile.mitglieds_nr ?? undefined,
dienstgrad: member.profile.dienstgrad ?? undefined,
dienstgrad_seit: toGermanDate(member.profile.dienstgrad_seit) || undefined,
funktion: member.profile.funktion,
status: member.profile.status,
eintrittsdatum: member.profile.eintrittsdatum ?? undefined,
geburtsdatum: member.profile.geburtsdatum ?? undefined,
eintrittsdatum: toGermanDate(member.profile.eintrittsdatum) || undefined,
geburtsdatum: toGermanDate(member.profile.geburtsdatum) || undefined,
telefon_mobil: member.profile.telefon_mobil ?? undefined,
telefon_privat: member.profile.telefon_privat ?? undefined,
notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined,
@@ -263,7 +265,13 @@ function MitgliedDetail() {
setSaving(true);
setSaveError(null);
try {
const updated = await membersService.updateMember(userId, formData);
const payload: UpdateMemberProfileData = {
...formData,
eintrittsdatum: formData.eintrittsdatum ? fromGermanDate(formData.eintrittsdatum) || undefined : undefined,
geburtsdatum: formData.geburtsdatum ? fromGermanDate(formData.geburtsdatum) || undefined : undefined,
dienstgrad_seit: formData.dienstgrad_seit ? fromGermanDate(formData.dienstgrad_seit) || undefined : undefined,
};
const updated = await membersService.updateMember(userId, payload);
setMember(updated);
setEditMode(false);
} catch {
@@ -491,9 +499,9 @@ function MitgliedDetail() {
<TextField
label="Dienstgrad seit"
type="date"
fullWidth
size="small"
placeholder="TT.MM.JJJJ"
value={formData.dienstgrad_seit ?? ''}
onChange={(e) => handleFieldChange('dienstgrad_seit', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
@@ -522,9 +530,9 @@ function MitgliedDetail() {
<TextField
label="Eintrittsdatum"
type="date"
fullWidth
size="small"
placeholder="TT.MM.JJJJ"
value={formData.eintrittsdatum ?? ''}
onChange={(e) => handleFieldChange('eintrittsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
@@ -532,9 +540,9 @@ function MitgliedDetail() {
<TextField
label="Geburtsdatum"
type="date"
fullWidth
size="small"
placeholder="TT.MM.JJJJ"
value={formData.geburtsdatum ?? ''}
onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)}
InputLabelProps={{ shrink: true }}

View File

@@ -11,20 +11,23 @@ import {
Divider,
Box,
Button,
ToggleButtonGroup,
ToggleButton,
} from '@mui/material';
import { Settings as SettingsIcon, Notifications, Palette, Language, Save } from '@mui/icons-material';
import { Settings as SettingsIcon, Notifications, Palette, Language, Save, SettingsBrightness, LightMode, DarkMode } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext';
import { useThemeMode } from '../contexts/ThemeContext';
function Settings() {
const notification = useNotification();
const { themeMode, setThemeMode } = useThemeMode();
// Settings state
const [emailNotifications, setEmailNotifications] = useState(true);
const [alarmNotifications, setAlarmNotifications] = useState(true);
const [maintenanceReminders, setMaintenanceReminders] = useState(false);
const [systemNotifications, setSystemNotifications] = useState(true);
const [darkMode, setDarkMode] = useState(false);
const [compactView, setCompactView] = useState(true);
const [animations, setAnimations] = useState(true);
@@ -105,18 +108,6 @@ function Settings() {
</Box>
<Divider sx={{ mb: 2 }} />
<FormGroup>
<FormControlLabel
control={
<Switch
checked={darkMode}
onChange={(e) => {
setDarkMode(e.target.checked);
notification.showInfo('Dunkler Modus wird in einer zukünftigen Version verfügbar sein');
}}
/>
}
label="Dunkler Modus (Vorschau)"
/>
<FormControlLabel
control={
<Switch
@@ -135,6 +126,30 @@ function Settings() {
}
label="Animationen"
/>
<Box sx={{ mt: 1 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
Farbschema
</Typography>
<ToggleButtonGroup
value={themeMode}
exclusive
onChange={(_e, value) => { if (value) setThemeMode(value); }}
size="small"
>
<ToggleButton value="system">
<SettingsBrightness fontSize="small" sx={{ mr: 0.5 }} />
System
</ToggleButton>
<ToggleButton value="light">
<LightMode fontSize="small" sx={{ mr: 0.5 }} />
Hell
</ToggleButton>
<ToggleButton value="dark">
<DarkMode fontSize="small" sx={{ mr: 0.5 }} />
Dunkel
</ToggleButton>
</ToggleButtonGroup>
</Box>
</FormGroup>
</CardContent>
</Card>

View File

@@ -52,6 +52,7 @@ import {
Delete as DeleteIcon,
} from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput';
import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext';
import { eventsApi } from '../services/events';
@@ -121,15 +122,15 @@ function formatDateShort(isoString: string): string {
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
}
/** Convert a Date to datetime-local input value "YYYY-MM-DDTHH:MM" */
function toDatetimeLocal(isoString: string): string {
const d = new Date(isoString);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Convert datetime-local value back to ISO string */
function fromDatetimeLocal(value: string): string {
if (!value) return new Date().toISOString();
// DD.MM.YYYY HH:MM format
const dtIso = fromGermanDateTime(value);
if (dtIso) return new Date(dtIso).toISOString();
// DD.MM.YYYY format (for ganztaegig)
const dIso = fromGermanDate(value);
if (dIso) return new Date(dIso).toISOString();
return new Date(value).toISOString();
}
@@ -720,16 +721,16 @@ function EventFormDialog({
{/* Datum von */}
<TextField
label="Von"
type={form.ganztaegig ? 'date' : 'datetime-local'}
placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'}
value={
form.ganztaegig
? toDatetimeLocal(form.datum_von).slice(0, 10)
: toDatetimeLocal(form.datum_von)
? toGermanDate(form.datum_von)
: toGermanDateTime(form.datum_von)
}
onChange={(e) => {
const raw = e.target.value;
const iso = form.ganztaegig
? fromDatetimeLocal(`${raw}T00:00`)
? fromDatetimeLocal(raw ? `${fromGermanDate(raw)}T00:00` : '')
: fromDatetimeLocal(raw);
handleChange('datum_von', iso);
}}
@@ -740,16 +741,16 @@ function EventFormDialog({
{/* Datum bis */}
<TextField
label="Bis"
type={form.ganztaegig ? 'date' : 'datetime-local'}
placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'}
value={
form.ganztaegig
? toDatetimeLocal(form.datum_bis).slice(0, 10)
: toDatetimeLocal(form.datum_bis)
? toGermanDate(form.datum_bis)
: toGermanDateTime(form.datum_bis)
}
onChange={(e) => {
const raw = e.target.value;
const iso = form.ganztaegig
? fromDatetimeLocal(`${raw}T23:59`)
? fromDatetimeLocal(raw ? `${fromGermanDate(raw)}T23:59` : '')
: fromDatetimeLocal(raw);
handleChange('datum_bis', iso);
}}
@@ -827,9 +828,14 @@ function EventFormDialog({
{form.anmeldung_erforderlich && (
<TextField
label="Anmeldeschluss"
type="datetime-local"
value={form.anmeldung_bis ? toDatetimeLocal(form.anmeldung_bis) : ''}
onChange={(e) => handleChange('anmeldung_bis', e.target.value ? fromDatetimeLocal(e.target.value) : null)}
placeholder="TT.MM.JJJJ HH:MM"
value={form.anmeldung_bis ? toGermanDateTime(form.anmeldung_bis) : ''}
onChange={(e) => {
const raw = e.target.value;
if (!raw) { handleChange('anmeldung_bis', null); return; }
const iso = fromGermanDateTime(raw);
if (iso) handleChange('anmeldung_bis', new Date(iso).toISOString());
}}
InputLabelProps={{ shrink: true }}
fullWidth
/>

View File

@@ -40,10 +40,7 @@ import {
DialogTitle,
Divider,
IconButton,
MenuItem,
Paper,
Select,
SelectChangeEvent,
Skeleton,
Stack,
TextField,
@@ -57,8 +54,6 @@ import {
GridRenderCellParams,
GridRowParams,
} from '@mui/x-data-grid';
import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { de } from 'date-fns/locale';
import { format, parseISO } from 'date-fns';
import CloseIcon from '@mui/icons-material/Close';
@@ -66,6 +61,7 @@ import DownloadIcon from '@mui/icons-material/Download';
import FilterAltIcon from '@mui/icons-material/FilterAlt';
import DashboardLayout from '../../components/dashboard/DashboardLayout';
import { api } from '../../services/api';
import { fromGermanDate } from '../../utils/dateInput';
// ---------------------------------------------------------------------------
// Types — mirror the backend AuditLogEntry interface
@@ -106,8 +102,8 @@ interface AuditFilters {
userId?: string;
action?: AuditAction[];
resourceType?: AuditResourceType[];
dateFrom?: Date | null;
dateTo?: Date | null;
dateFrom?: string;
dateTo?: string;
}
// ---------------------------------------------------------------------------
@@ -349,17 +345,23 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, onReset })
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{/* Date range */}
<DatePicker
<TextField
label="Von"
value={filters.dateFrom ?? null}
onChange={(date) => onChange({ ...filters, dateFrom: date })}
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }}
size="small"
placeholder="TT.MM.JJJJ"
value={filters.dateFrom ?? ''}
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
sx={{ minWidth: 160 }}
InputLabelProps={{ shrink: true }}
/>
<DatePicker
<TextField
label="Bis"
value={filters.dateTo ?? null}
onChange={(date) => onChange({ ...filters, dateTo: date })}
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }}
size="small"
placeholder="TT.MM.JJJJ"
value={filters.dateTo ?? ''}
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
sx={{ minWidth: 160 }}
InputLabelProps={{ shrink: true }}
/>
{/* Action multi-select */}
@@ -416,8 +418,8 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, onReset })
const DEFAULT_FILTERS: AuditFilters = {
action: [],
resourceType: [],
dateFrom: null,
dateTo: null,
dateFrom: '',
dateTo: '',
};
const AuditLog: React.FC = () => {
@@ -460,8 +462,14 @@ const AuditLog: React.FC = () => {
pageSize: String(pagination.pageSize),
};
if (f.dateFrom) params.dateFrom = f.dateFrom.toISOString();
if (f.dateTo) params.dateTo = f.dateTo.toISOString();
if (f.dateFrom) {
const iso = fromGermanDate(f.dateFrom);
if (iso) params.dateFrom = new Date(iso).toISOString();
}
if (f.dateTo) {
const iso = fromGermanDate(f.dateTo);
if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString();
}
if (f.action && f.action.length > 0) {
params.action = f.action.join(',');
}
@@ -513,8 +521,14 @@ const AuditLog: React.FC = () => {
setExporting(true);
try {
const params: Record<string, string> = {};
if (appliedFilters.dateFrom) params.dateFrom = appliedFilters.dateFrom.toISOString();
if (appliedFilters.dateTo) params.dateTo = appliedFilters.dateTo.toISOString();
if (appliedFilters.dateFrom) {
const iso = fromGermanDate(appliedFilters.dateFrom);
if (iso) params.dateFrom = new Date(iso).toISOString();
}
if (appliedFilters.dateTo) {
const iso = fromGermanDate(appliedFilters.dateTo);
if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString();
}
if (appliedFilters.action && appliedFilters.action.length > 0) {
params.action = appliedFilters.action.join(',');
}
@@ -637,8 +651,7 @@ const AuditLog: React.FC = () => {
// -------------------------------------------------------------------------
return (
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={de}>
<DashboardLayout>
<DashboardLayout>
<Container maxWidth="xl">
{/* Header */}
<Stack
@@ -726,7 +739,6 @@ const AuditLog: React.FC = () => {
/>
</Container>
</DashboardLayout>
</LocalizationProvider>
);
};

View File

@@ -0,0 +1,24 @@
import { api } from './api';
import type { BookStackRecentResponse, BookStackSearchResponse } from '../types/bookstack.types';
interface ApiResponse<T> {
success: boolean;
data: T;
configured: boolean;
}
export const bookstackApi = {
getRecent(): Promise<BookStackRecentResponse> {
return api
.get<ApiResponse<BookStackRecentResponse['data']>>('/api/bookstack/recent')
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
},
search(query: string): Promise<BookStackSearchResponse> {
return api
.get<ApiResponse<BookStackSearchResponse['data']>>('/api/bookstack/search', {
params: { query },
})
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
},
};

View File

@@ -146,4 +146,11 @@ export const eventsApi = {
)
.then((r) => r.data.data);
},
/** Bulk-import events from CSV-parsed data */
importEvents(events: CreateVeranstaltungInput[]): Promise<{ created: number; errors: string[] }> {
return api
.post<ApiResponse<{ created: number; errors: string[] }>>('/api/events/import', { events })
.then((r) => r.data.data);
},
};

View File

@@ -0,0 +1,36 @@
export interface BookStackPage {
id: number;
name: string;
slug: string;
book_id: number;
book_slug: string;
chapter_id: number;
draft: boolean;
template: boolean;
created_at: string;
updated_at: string;
url: string;
book?: { name: string };
updatedBy?: { name: string };
}
export interface BookStackSearchResult {
id: number;
name: string;
slug: string;
book_id: number;
book_slug: string;
url: string;
preview_html: { content: string };
tags: { name: string; value: string; order: number }[];
}
export interface BookStackRecentResponse {
configured: boolean;
data: BookStackPage[];
}
export interface BookStackSearchResponse {
configured: boolean;
data: BookStackSearchResult[];
}

View File

@@ -0,0 +1,60 @@
/**
* Shared utilities for German date format (DD.MM.YYYY / DD.MM.YYYY HH:MM)
* Used as plain TextField inputs with placeholder instead of type="date"/"datetime-local"
*/
/** ISO/YYYY-MM-DD → DD.MM.YYYY */
export function toGermanDate(iso: string | null | undefined): string {
if (!iso) return '';
// Already DD.MM.YYYY
if (/^\d{2}\.\d{2}\.\d{4}$/.test(iso)) return iso;
// YYYY-MM-DD (possibly with time)
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return `${m[3]}.${m[2]}.${m[1]}`;
return '';
}
/** ISO datetime → DD.MM.YYYY HH:MM */
export function toGermanDateTime(iso: string | null | undefined): string {
if (!iso) return '';
// Already DD.MM.YYYY HH:MM
if (/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}$/.test(iso)) return iso;
// YYYY-MM-DDTHH:MM or YYYY-MM-DD HH:MM
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if (m) return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
// Date only → midnight
const d = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (d) return `${d[3]}.${d[2]}.${d[1]} 00:00`;
return '';
}
/** DD.MM.YYYY → YYYY-MM-DD (ISO date string) */
export function fromGermanDate(german: string): string {
const m = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!m) return '';
return `${m[3]}-${m[2]}-${m[1]}`;
}
/** DD.MM.YYYY HH:MM → ISO datetime string (YYYY-MM-DDTHH:MM:00.000Z local) */
export function fromGermanDateTime(german: string): string {
const m = german.match(/^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2})$/);
if (!m) return '';
return `${m[3]}-${m[2]}-${m[1]}T${m[4]}:${m[5]}`;
}
/** Validates DD.MM.YYYY format and that the date is valid */
export function isValidGermanDate(value: string): boolean {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return false;
const [dd, mm, yyyy] = value.split('.').map(Number);
const date = new Date(yyyy, mm - 1, dd);
return date.getFullYear() === yyyy && date.getMonth() === mm - 1 && date.getDate() === dd;
}
/** Validates DD.MM.YYYY HH:MM format */
export function isValidGermanDateTime(value: string): boolean {
if (!/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}$/.test(value)) return false;
const [datePart, timePart] = value.split(' ');
const [hh, mm] = timePart.split(':').map(Number);
if (hh > 23 || mm > 59) return false;
return isValidGermanDate(datePart);
}