This commit is contained in:
Matthias Hochmeister
2026-03-26 12:12:18 +01:00
parent d351ea2647
commit 507111e8e8
12 changed files with 202 additions and 131 deletions

View File

@@ -25,6 +25,7 @@ import {
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import GermanDateField from '../shared/GermanDateField';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { bannerApi } from '../../services/banners';
import { useNotification } from '../../contexts/NotificationContext';
@@ -228,14 +229,13 @@ function BannerManagementTab() {
<MenuItem value="widget">Widget</MenuItem>
</Select>
</FormControl>
<TextField
<GermanDateField
mode="datetime"
margin="dense"
label="Ablaufdatum (optional)"
type="datetime-local"
fullWidth
value={newEndsAt}
onChange={(e) => setNewEndsAt(e.target.value)}
InputLabelProps={{ shrink: true }}
onChange={(v) => setNewEndsAt(v)}
helperText="Leer lassen für kein Ablaufdatum"
/>
</DialogContent>

View File

@@ -4,6 +4,7 @@ import {
TextField, Button, Alert, CircularProgress, Chip,
} from '@mui/material';
import BuildIcon from '@mui/icons-material/Build';
import GermanDateField from '../shared/GermanDateField';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsApi } from '../../services/settings';
import { permissionsApi } from '../../services/permissions';
@@ -113,15 +114,14 @@ export default function ServiceModeTab() {
helperText="Diese Nachricht sehen Benutzer auf der Wartungsseite."
/>
<TextField
<GermanDateField
mode="datetime"
fullWidth
label="Automatisch deaktivieren am"
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
InputLabelProps={{ shrink: true }}
onChange={(v) => setEndsAt(v)}
helperText="Optional: Wartungsmodus wird automatisch zu diesem Zeitpunkt deaktiviert."
sx={{ mb: 3, '& input': { color: 'text.primary' } }}
sx={{ mb: 3 }}
/>
<Button

View File

@@ -14,6 +14,7 @@ import {
SelectChangeEvent,
} from '@mui/material';
import { DirectionsCar } from '@mui/icons-material';
import GermanDateField from '../shared/GermanDateField';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingApi, fetchVehicles } from '../../services/bookings';
import type { CreateBuchungInput } from '../../types/booking.types';
@@ -141,28 +142,24 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
inputProps={{ maxLength: 250 }}
/>
<TextField
<GermanDateField
mode="datetime"
fullWidth
size="small"
label="Beginn"
type="datetime-local"
value={beginn}
onChange={(e) => setBeginn(e.target.value)}
onChange={(v) => setBeginn(v)}
required
InputLabelProps={{ shrink: true }}
sx={{ '& input': { color: 'text.primary' } }}
/>
<TextField
<GermanDateField
mode="datetime"
fullWidth
size="small"
label="Ende"
type="datetime-local"
value={ende}
onChange={(e) => setEnde(e.target.value)}
onChange={(v) => setEnde(v)}
required
InputLabelProps={{ shrink: true }}
sx={{ '& input': { color: 'text.primary' } }}
/>
<TextField

View File

@@ -14,6 +14,7 @@ import {
SelectChangeEvent,
} from '@mui/material';
import { AddTask } from '@mui/icons-material';
import GermanDateField from '../shared/GermanDateField';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { vikunjaApi } from '../../services/vikunja';
import { useNotification } from '../../contexts/NotificationContext';
@@ -126,14 +127,13 @@ const VikunjaQuickAddWidget: React.FC = () => {
inputProps={{ maxLength: 250 }}
/>
<TextField
<GermanDateField
mode="date"
fullWidth
size="small"
label="Fälligkeitsdatum (optional)"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
InputLabelProps={{ shrink: true }}
onChange={(v) => setDueDate(v)}
/>
<Button

View File

@@ -0,0 +1,118 @@
import { useState, useRef } from 'react';
import TextField, { type TextFieldProps } from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import IconButton from '@mui/material/IconButton';
import Box from '@mui/material/Box';
import CalendarMonth from '@mui/icons-material/CalendarMonth';
import {
toGermanDate,
toGermanDateTime,
fromGermanDate,
fromGermanDateTime,
} from '../../utils/dateInput';
export type GermanDateMode = 'date' | 'datetime';
export interface GermanDateFieldProps extends Omit<TextFieldProps, 'value' | 'onChange' | 'type'> {
/** ISO date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:MM) string */
value?: string | null;
/** Called with ISO date/datetime string whenever value changes to a valid date */
onChange?: (isoValue: string) => void;
mode?: GermanDateMode;
}
/**
* A MUI TextField that:
* - Displays dates in German format (TT.MM.JJJJ / TT.MM.JJJJ HH:MM)
* - Accepts free-text entry in that format
* - Has a calendar icon that opens the native browser date picker as a popup
* - onChange fires with an ISO string whenever the entered value is valid
*/
export default function GermanDateField({
value,
onChange,
mode = 'date',
sx,
...rest
}: GermanDateFieldProps) {
// localValue overrides the displayed text while the user is typing
const [localValue, setLocalValue] = useState<string | null>(null);
const hiddenRef = useRef<HTMLInputElement>(null);
const displayed =
localValue ?? (mode === 'date' ? toGermanDate(value) : toGermanDateTime(value));
function handleTextChange(raw: string) {
setLocalValue(raw);
const iso = mode === 'date' ? fromGermanDate(raw) : fromGermanDateTime(raw);
if (iso) {
onChange?.(iso);
setLocalValue(null);
}
}
function handleBlur() {
// Revert to the last valid formatted value if the text is incomplete
setLocalValue(null);
}
function handlePickerChange(nativeVal: string) {
if (!nativeVal) return;
if (mode === 'date') {
onChange?.(nativeVal);
} else {
// Preserve current time portion if available, default to 08:00
const time = value?.substring(11, 16) || '08:00';
onChange?.(`${nativeVal}T${time}`);
}
}
const nativeValue =
mode === 'date' ? (value?.substring(0, 10) || '') : (value?.substring(0, 16) || '');
return (
<Box sx={{ position: 'relative', display: 'inline-flex', width: rest.fullWidth ? '100%' : undefined, ...sx }}>
<TextField
{...rest}
sx={{ width: '100%' }}
value={displayed}
onChange={(e) => handleTextChange(e.target.value)}
onBlur={handleBlur}
placeholder={mode === 'date' ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'}
InputLabelProps={{ shrink: true, ...rest.InputLabelProps }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
size="small"
tabIndex={-1}
edge="end"
onClick={() => hiddenRef.current?.showPicker?.()}
>
<CalendarMonth fontSize="small" />
</IconButton>
</InputAdornment>
),
...rest.InputProps,
}}
/>
{/* Hidden native input used solely to open the browser's date picker popup */}
<input
ref={hiddenRef}
type={mode === 'date' ? 'date' : 'datetime-local'}
value={nativeValue}
onChange={(e) => handlePickerChange(e.target.value)}
tabIndex={-1}
style={{
position: 'absolute',
bottom: 0,
left: 0,
opacity: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
/>
</Box>
);
}