fix: enforce future-dated ranges with modal picker

This commit is contained in:
2025-11-09 19:47:40 +01:00
parent 7d56356ebf
commit d4a28c9897
2 changed files with 246 additions and 141 deletions

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, Link, useLocation, useNavigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate, Link, useLocation, useNavigate } from 'react-router-dom';
import { DateRange } from 'react-date-range'; import { DateRange } from 'react-date-range';
import { format, parseISO, isValid } from 'date-fns'; import { format, parseISO, isValid, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import './App.css'; import './App.css';
import 'react-date-range/dist/styles.css'; import 'react-date-range/dist/styles.css';
@@ -40,9 +40,16 @@ const formatRangeLabel = (start, end) => {
return 'Zeitraum auswählen'; return 'Zeitraum auswählen';
}; };
const buildSelectionRange = (start, end) => { const buildSelectionRange = (start, end, minDate) => {
const startDate = parseDateValue(start) || parseDateValue(end) || new Date(); const minimum = minDate || startOfDay(new Date());
const endDate = parseDateValue(end) || parseDateValue(start) || startDate; let startDate = parseDateValue(start) || parseDateValue(end) || minimum;
let endDate = parseDateValue(end) || parseDateValue(start) || startDate;
if (startDate < minimum) {
startDate = minimum;
}
if (endDate < minimum) {
endDate = startDate;
}
return { return {
startDate, startDate,
endDate, endDate,
@@ -76,6 +83,7 @@ function App() {
const [dirtyDialogSaving, setDirtyDialogSaving] = useState(false); const [dirtyDialogSaving, setDirtyDialogSaving] = useState(false);
const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null }); const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null });
const [activeRangePicker, setActiveRangePicker] = useState(null); const [activeRangePicker, setActiveRangePicker] = useState(null);
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']; const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
@@ -866,7 +874,7 @@ function App() {
return updated; return updated;
}) })
); );
}, [setConfig, setIsDirty]); }, [setConfig]);
const configMap = useMemo(() => { const configMap = useMemo(() => {
const map = new Map(); const map = new Map();
@@ -880,15 +888,21 @@ function App() {
const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]); const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]);
const activeRangeEntry = useMemo(() => {
if (!activeRangePicker) {
return null;
}
return config.find((item) => item.id === activeRangePicker) || null;
}, [activeRangePicker, config]);
useEffect(() => { useEffect(() => {
if (!activeRangePicker) { if (!activeRangePicker) {
return; return;
} }
const stillExists = config.some((item) => item.id === activeRangePicker); if (!activeRangeEntry || activeRangeEntry.desiredWeekday) {
if (!stillExists) {
setActiveRangePicker(null); setActiveRangePicker(null);
} }
}, [activeRangePicker, config]); }, [activeRangePicker, activeRangeEntry]);
const handleStoreSelection = async (store) => { const handleStoreSelection = async (store) => {
const storeId = String(store.id); const storeId = String(store.id);
@@ -1343,14 +1357,13 @@ function App() {
</select> </select>
</td> </td>
<td className="px-4 py-2 border-b"> <td className="px-4 py-2 border-b">
<div className="relative">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
if (item.desiredWeekday) { if (item.desiredWeekday) {
return; return;
} }
setActiveRangePicker((prev) => (prev === item.id ? null : item.id)); setActiveRangePicker(item.id);
}} }}
disabled={Boolean(item.desiredWeekday)} disabled={Boolean(item.desiredWeekday)}
className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${ className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
@@ -1362,43 +1375,6 @@ function App() {
<span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span> <span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span>
<span className="block text-xs text-gray-500">Klicke zum Auswählen</span> <span className="block text-xs text-gray-500">Klicke zum Auswählen</span>
</button> </button>
{activeRangePicker === item.id && !item.desiredWeekday && (
<div className="absolute z-20 mt-2 bg-white border border-gray-200 rounded-lg shadow-2xl">
<DateRange
onChange={(ranges) => {
const { startDate, endDate } = ranges.selection;
handleDateRangeSelection(item.id, startDate, endDate);
}}
moveRangeOnFirstSelection={false}
ranges={[buildSelectionRange(rangeStart, rangeEnd)]}
rangeColors={['#2563EB']}
months={1}
direction="horizontal"
showDateDisplay={false}
locale={de}
/>
<div className="flex items-center justify-between px-4 py-2 border-t text-sm bg-gray-50 rounded-b-lg">
<button
type="button"
className="text-gray-600 hover:text-gray-900"
onClick={() => {
handleDateRangeSelection(item.id, null, null);
setActiveRangePicker(null);
}}
>
Zurücksetzen
</button>
<button
type="button"
className="text-blue-600 font-semibold hover:text-blue-800"
onClick={() => setActiveRangePicker(null)}
>
Fertig
</button>
</div>
</div>
)}
</div>
</td> </td>
<td className="px-4 py-2 border-b"> <td className="px-4 py-2 border-b">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
@@ -1437,6 +1413,75 @@ function App() {
Konfiguration speichern Konfiguration speichern
</button> </button>
</div> </div>
{activeRangeEntry && !activeRangeEntry.desiredWeekday && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-40 px-4"
onClick={() => setActiveRangePicker(null)}
>
<div
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg"
onClick={(event) => event.stopPropagation()}
>
<div className="px-5 pt-5 pb-3 border-b">
<p className="text-xs uppercase tracking-wide text-gray-500">Zeitraum auswählen für</p>
<p className="text-lg font-semibold text-gray-900">
{activeRangeEntry.label || `Store ${activeRangeEntry.id}`}
</p>
</div>
<div className="px-2 py-4">
<DateRange
onChange={(ranges) => {
const { startDate, endDate } = ranges.selection;
handleDateRangeSelection(activeRangeEntry.id, startDate, endDate);
}}
moveRangeOnFirstSelection={false}
ranges={[
buildSelectionRange(
activeRangeEntry.desiredDateRange?.start,
activeRangeEntry.desiredDateRange?.end,
minSelectableDate
)
]}
rangeColors={['#2563EB']}
months={1}
direction="horizontal"
showDateDisplay={false}
locale={de}
minDate={minSelectableDate}
/>
</div>
<div className="flex items-center justify-between px-5 py-3 border-t bg-gray-50 rounded-b-2xl">
<button
type="button"
className="text-sm text-gray-600 hover:text-gray-900"
onClick={() => {
handleDateRangeSelection(activeRangeEntry.id, null, null);
setActiveRangePicker(null);
}}
>
Zurücksetzen
</button>
<div className="flex items-center gap-3">
<button
type="button"
className="text-sm text-gray-600 hover:text-gray-900"
onClick={() => setActiveRangePicker(null)}
>
Abbrechen
</button>
<button
type="button"
className="text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-md"
onClick={() => setActiveRangePicker(null)}
>
Fertig
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { DateRange } from 'react-date-range'; import { DateRange } from 'react-date-range';
import { format, parseISO, isValid } from 'date-fns'; import { format, parseISO, isValid, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import 'react-date-range/dist/styles.css'; import 'react-date-range/dist/styles.css';
import 'react-date-range/dist/theme/default.css'; import 'react-date-range/dist/theme/default.css';
@@ -37,9 +37,16 @@ const formatRangeLabel = (start, end) => {
return 'Zeitraum auswählen'; return 'Zeitraum auswählen';
}; };
const buildSelectionRange = (start, end) => { const buildSelectionRange = (start, end, minDate) => {
const startDate = parseDateValue(start) || parseDateValue(end) || new Date(); const minimum = minDate || startOfDay(new Date());
const endDate = parseDateValue(end) || parseDateValue(start) || startDate; let startDate = parseDateValue(start) || parseDateValue(end) || minimum;
let endDate = parseDateValue(end) || parseDateValue(start) || startDate;
if (startDate < minimum) {
startDate = minimum;
}
if (endDate < minimum) {
endDate = startDate;
}
return { return {
startDate, startDate,
endDate, endDate,
@@ -53,6 +60,7 @@ const PickupConfigEditor = () => {
const [status, setStatus] = useState(''); const [status, setStatus] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [activeRangePicker, setActiveRangePicker] = useState(null); const [activeRangePicker, setActiveRangePicker] = useState(null);
const minSelectableDate = startOfDay(new Date());
// Simulierte API-Endpunkte - diese müssen in Ihrer tatsächlichen Implementierung angepasst werden // Simulierte API-Endpunkte - diese müssen in Ihrer tatsächlichen Implementierung angepasst werden
const API_URL = '/api/iobroker/pickup-config'; const API_URL = '/api/iobroker/pickup-config';
@@ -62,6 +70,16 @@ const PickupConfigEditor = () => {
fetchConfig(); fetchConfig();
}, []); }, []);
useEffect(() => {
if (!activeRangePicker) {
return;
}
const entry = config.find((item) => item.id === activeRangePicker);
if (!entry || entry.desiredWeekday) {
setActiveRangePicker(null);
}
}, [activeRangePicker, config]);
const fetchConfig = async () => { const fetchConfig = async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -155,27 +173,38 @@ const PickupConfigEditor = () => {
} }
}; };
const handleDateRangeSelection = (index, startDate, endDate) => { const handleDateRangeSelection = (entryId, startDate, endDate) => {
const newConfig = [...config];
const startValue = formatDateValue(startDate); const startValue = formatDateValue(startDate);
const endValue = formatDateValue(endDate); const endValue = formatDateValue(endDate);
setConfig((prev) =>
prev.map((item) => {
if (item.id !== entryId) {
return item;
}
const updated = { ...item };
if (startValue || endValue) { if (startValue || endValue) {
newConfig[index].desiredDateRange = { updated.desiredDateRange = {
start: startValue || endValue, start: startValue || endValue,
end: endValue || startValue end: endValue || startValue
}; };
if (newConfig[index].desiredWeekday) { if (updated.desiredWeekday) {
delete newConfig[index].desiredWeekday; delete updated.desiredWeekday;
} }
} else if (newConfig[index].desiredDateRange) { } else if (updated.desiredDateRange) {
delete newConfig[index].desiredDateRange; delete updated.desiredDateRange;
} }
if (newConfig[index].desiredDate) { if (updated.desiredDate) {
delete newConfig[index].desiredDate; delete updated.desiredDate;
} }
setConfig(newConfig); return updated;
})
);
}; };
const activeRangeEntry = activeRangePicker
? config.find((item) => item.id === activeRangePicker) || null
: null;
if (loading) { if (loading) {
return <div className="text-center p-8">Lade Konfiguration...</div>; return <div className="text-center p-8">Lade Konfiguration...</div>;
} }
@@ -265,14 +294,13 @@ const PickupConfigEditor = () => {
</select> </select>
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<div className="relative">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
if (item.desiredWeekday) { if (item.desiredWeekday) {
return; return;
} }
setActiveRangePicker((prev) => (prev === item.id ? null : item.id)); setActiveRangePicker(item.id);
}} }}
disabled={Boolean(item.desiredWeekday)} disabled={Boolean(item.desiredWeekday)}
className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${ className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
@@ -284,43 +312,6 @@ const PickupConfigEditor = () => {
<span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span> <span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span>
<span className="block text-xs text-gray-500">Klicke zum Auswählen</span> <span className="block text-xs text-gray-500">Klicke zum Auswählen</span>
</button> </button>
{activeRangePicker === item.id && !item.desiredWeekday && (
<div className="absolute z-20 mt-2 bg-white border border-gray-200 rounded-lg shadow-2xl">
<DateRange
onChange={(ranges) => {
const { startDate, endDate } = ranges.selection;
handleDateRangeSelection(index, startDate, endDate);
}}
moveRangeOnFirstSelection={false}
ranges={[buildSelectionRange(rangeStart, rangeEnd)]}
rangeColors={['#2563EB']}
months={1}
direction="horizontal"
showDateDisplay={false}
locale={de}
/>
<div className="flex items-center justify-between px-4 py-2 border-t text-sm bg-gray-50 rounded-b-lg">
<button
type="button"
className="text-gray-600 hover:text-gray-900"
onClick={() => {
handleDateRangeSelection(index, null, null);
setActiveRangePicker(null);
}}
>
Zurücksetzen
</button>
<button
type="button"
className="text-blue-600 font-semibold hover:text-blue-800"
onClick={() => setActiveRangePicker(null)}
>
Fertig
</button>
</div>
</div>
)}
</div>
</td> </td>
</tr> </tr>
); );
@@ -350,6 +341,75 @@ const PickupConfigEditor = () => {
{JSON.stringify(config, null, 2)} {JSON.stringify(config, null, 2)}
</pre> </pre>
</div> </div>
{activeRangeEntry && !activeRangeEntry.desiredWeekday && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-40 px-4"
onClick={() => setActiveRangePicker(null)}
>
<div
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg"
onClick={(event) => event.stopPropagation()}
>
<div className="px-5 pt-5 pb-3 border-b">
<p className="text-xs uppercase tracking-wide text-gray-500">Zeitraum auswählen für</p>
<p className="text-lg font-semibold text-gray-900">
{activeRangeEntry.label || `Store ${activeRangeEntry.id}`}
</p>
</div>
<div className="px-2 py-4">
<DateRange
onChange={(ranges) => {
const { startDate, endDate } = ranges.selection;
handleDateRangeSelection(activeRangeEntry.id, startDate, endDate);
}}
moveRangeOnFirstSelection={false}
ranges={[
buildSelectionRange(
activeRangeEntry.desiredDateRange?.start,
activeRangeEntry.desiredDateRange?.end,
minSelectableDate
)
]}
rangeColors={['#2563EB']}
months={1}
direction="horizontal"
showDateDisplay={false}
locale={de}
minDate={minSelectableDate}
/>
</div>
<div className="flex items-center justify-between px-5 py-3 border-t bg-gray-50 rounded-b-2xl">
<button
type="button"
className="text-sm text-gray-600 hover:text-gray-900"
onClick={() => {
handleDateRangeSelection(activeRangeEntry.id, null, null);
setActiveRangePicker(null);
}}
>
Zurücksetzen
</button>
<div className="flex items-center gap-3">
<button
type="button"
className="text-sm text-gray-600 hover:text-gray-900"
onClick={() => setActiveRangePicker(null)}
>
Abbrechen
</button>
<button
type="button"
className="text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-md"
onClick={() => setActiveRangePicker(null)}
>
Fertig
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };