feat: add calendar date-range picker for slots
This commit is contained in:
179
src/App.js
179
src/App.js
@@ -1,8 +1,54 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { DateRange } from 'react-date-range';
|
||||
import { format, parseISO, isValid } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import './App.css';
|
||||
import 'react-date-range/dist/styles.css';
|
||||
import 'react-date-range/dist/theme/default.css';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'pickupConfigToken';
|
||||
const parseDateValue = (value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseISO(value);
|
||||
return isValid(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const formatDateValue = (date) => {
|
||||
if (!(date instanceof Date) || !isValid(date)) {
|
||||
return null;
|
||||
}
|
||||
return format(date, 'yyyy-MM-dd');
|
||||
};
|
||||
|
||||
const formatRangeLabel = (start, end) => {
|
||||
const startDate = parseDateValue(start);
|
||||
const endDate = parseDateValue(end);
|
||||
if (startDate && endDate) {
|
||||
const startLabel = format(startDate, 'dd.MM.yyyy', { locale: de });
|
||||
const endLabel = format(endDate, 'dd.MM.yyyy', { locale: de });
|
||||
if (startLabel === endLabel) {
|
||||
return startLabel;
|
||||
}
|
||||
return `${startLabel} – ${endLabel}`;
|
||||
}
|
||||
if (startDate) {
|
||||
return format(startDate, 'dd.MM.yyyy', { locale: de });
|
||||
}
|
||||
return 'Zeitraum auswählen';
|
||||
};
|
||||
|
||||
const buildSelectionRange = (start, end) => {
|
||||
const startDate = parseDateValue(start) || parseDateValue(end) || new Date();
|
||||
const endDate = parseDateValue(end) || parseDateValue(start) || startDate;
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
key: 'selection'
|
||||
};
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [session, setSession] = useState(null);
|
||||
@@ -29,6 +75,7 @@ function App() {
|
||||
const [pendingNavigation, setPendingNavigation] = useState(null);
|
||||
const [dirtyDialogSaving, setDirtyDialogSaving] = useState(false);
|
||||
const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null });
|
||||
const [activeRangePicker, setActiveRangePicker] = useState(null);
|
||||
|
||||
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||
|
||||
@@ -787,46 +834,26 @@ function App() {
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
if (value) {
|
||||
setActiveRangePicker((prev) => (prev === entryId ? null : prev));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (entryId, field, value) => {
|
||||
const handleDateRangeSelection = useCallback((entryId, startDate, endDate) => {
|
||||
setIsDirty(true);
|
||||
setConfig((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== entryId) {
|
||||
return item;
|
||||
}
|
||||
const sanitizedValue = value || null;
|
||||
const currentRange = item.desiredDateRange
|
||||
? { ...item.desiredDateRange }
|
||||
: item.desiredDate
|
||||
? { start: item.desiredDate, end: item.desiredDate }
|
||||
: { start: null, end: null };
|
||||
const nextRange = {
|
||||
start: currentRange.start || null,
|
||||
end: currentRange.end || null
|
||||
};
|
||||
if (field === 'start') {
|
||||
nextRange.start = sanitizedValue;
|
||||
if (!nextRange.start) {
|
||||
nextRange.end = null;
|
||||
} else if (!nextRange.end || nextRange.end < nextRange.start) {
|
||||
nextRange.end = nextRange.start;
|
||||
}
|
||||
} else if (field === 'end') {
|
||||
nextRange.end = sanitizedValue;
|
||||
if (!nextRange.end && nextRange.start) {
|
||||
nextRange.end = nextRange.start;
|
||||
} else if (!nextRange.start && nextRange.end) {
|
||||
nextRange.start = nextRange.end;
|
||||
} else if (nextRange.start && nextRange.end < nextRange.start) {
|
||||
nextRange.start = nextRange.end;
|
||||
}
|
||||
}
|
||||
const hasRange = Boolean(nextRange.start || nextRange.end);
|
||||
const updated = { ...item };
|
||||
if (hasRange) {
|
||||
updated.desiredDateRange = nextRange;
|
||||
const startValue = formatDateValue(startDate);
|
||||
const endValue = formatDateValue(endDate);
|
||||
if (startValue || endValue) {
|
||||
updated.desiredDateRange = {
|
||||
start: startValue || endValue,
|
||||
end: endValue || startValue
|
||||
};
|
||||
if (updated.desiredWeekday) {
|
||||
delete updated.desiredWeekday;
|
||||
}
|
||||
@@ -839,7 +866,7 @@ function App() {
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
};
|
||||
}, [setConfig, setIsDirty]);
|
||||
|
||||
const configMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
@@ -853,6 +880,16 @@ function App() {
|
||||
|
||||
const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRangePicker) {
|
||||
return;
|
||||
}
|
||||
const stillExists = config.some((item) => item.id === activeRangePicker);
|
||||
if (!stillExists) {
|
||||
setActiveRangePicker(null);
|
||||
}
|
||||
}, [activeRangePicker, config]);
|
||||
|
||||
const handleStoreSelection = async (store) => {
|
||||
const storeId = String(store.id);
|
||||
const existing = configMap.get(storeId);
|
||||
@@ -1305,30 +1342,64 @@ function App() {
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-2 border-b">
|
||||
<div className="border rounded p-2 bg-gray-50">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datum / Zeitraum</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={rangeStart}
|
||||
onChange={(e) => handleDateRangeChange(item.id, 'start', e.target.value)}
|
||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={Boolean(item.desiredWeekday)}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">bis</span>
|
||||
<input
|
||||
type="date"
|
||||
value={rangeEnd}
|
||||
onChange={(e) => handleDateRangeChange(item.id, 'end', e.target.value)}
|
||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={Boolean(item.desiredWeekday)}
|
||||
min={rangeStart || undefined}
|
||||
<td className="px-4 py-2 border-b">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.desiredWeekday) {
|
||||
return;
|
||||
}
|
||||
setActiveRangePicker((prev) => (prev === item.id ? null : item.id));
|
||||
}}
|
||||
disabled={Boolean(item.desiredWeekday)}
|
||||
className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
item.desiredWeekday
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-white hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
<p className="text-xs text-gray-500 mt-1">Ein einzelner Tag: Start und Ende identisch wählen.</p>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 border-b">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user