feat: add calendar date-range picker for slots
This commit is contained in:
53
package-lock.json
generated
53
package-lock.json
generated
@@ -15,9 +15,11 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-date-range": "^2.0.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"uuid": "^11.0.3",
|
"uuid": "^11.0.3",
|
||||||
@@ -6241,6 +6243,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/classnames": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/clean-css": {
|
"node_modules/clean-css": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
|
||||||
@@ -7142,6 +7150,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -12324,7 +12343,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -14901,7 +14919,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -14913,7 +14930,6 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
@@ -15085,6 +15101,22 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-date-range": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-date-range/-/react-date-range-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-jwKYc9zcjYMg2hWbPht+6BF2wjGG5DkRVNJLRXn2Y0B/QCOOnvQX6YXziZVujVADWmgsBaoQnILdmzYw+Bwh0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"classnames": "^2.2.6",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"react-list": "^0.8.13",
|
||||||
|
"shallow-equal": "^1.2.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"date-fns": "3.0.6 || >=3.0.0",
|
||||||
|
"react": "^0.14 || ^15.0.0-rc || >=15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dev-utils": {
|
"node_modules/react-dev-utils": {
|
||||||
"version": "12.0.1",
|
"version": "12.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz",
|
||||||
@@ -15222,6 +15254,15 @@
|
|||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-list": {
|
||||||
|
"version": "0.8.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-list/-/react-list-0.8.18.tgz",
|
||||||
|
"integrity": "sha512-1OSdDvzuKuwDJvQNuhXxxL+jTmmdtKg1i6KtYgxI9XR98kbOql1FcSGP+Lcvo91fk3cYng+Z6YkC6X9HRJwxfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "0.14 || 15 - 19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
||||||
@@ -16268,6 +16309,12 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/shallow-equal": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-date-range": "^2.0.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"uuid": "^11.0.3",
|
"uuid": "^11.0.3",
|
||||||
|
|||||||
179
src/App.js
179
src/App.js
@@ -1,8 +1,54 @@
|
|||||||
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 { format, parseISO, isValid } from 'date-fns';
|
||||||
|
import { de } from 'date-fns/locale';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import 'react-date-range/dist/styles.css';
|
||||||
|
import 'react-date-range/dist/theme/default.css';
|
||||||
|
|
||||||
const TOKEN_STORAGE_KEY = 'pickupConfigToken';
|
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() {
|
function App() {
|
||||||
const [session, setSession] = useState(null);
|
const [session, setSession] = useState(null);
|
||||||
@@ -29,6 +75,7 @@ function App() {
|
|||||||
const [pendingNavigation, setPendingNavigation] = useState(null);
|
const [pendingNavigation, setPendingNavigation] = useState(null);
|
||||||
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 weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||||
|
|
||||||
@@ -787,46 +834,26 @@ function App() {
|
|||||||
return updated;
|
return updated;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
if (value) {
|
||||||
|
setActiveRangePicker((prev) => (prev === entryId ? null : prev));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (entryId, field, value) => {
|
const handleDateRangeSelection = useCallback((entryId, startDate, endDate) => {
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
setConfig((prev) =>
|
setConfig((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((item) => {
|
||||||
if (item.id !== entryId) {
|
if (item.id !== entryId) {
|
||||||
return item;
|
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 };
|
const updated = { ...item };
|
||||||
if (hasRange) {
|
const startValue = formatDateValue(startDate);
|
||||||
updated.desiredDateRange = nextRange;
|
const endValue = formatDateValue(endDate);
|
||||||
|
if (startValue || endValue) {
|
||||||
|
updated.desiredDateRange = {
|
||||||
|
start: startValue || endValue,
|
||||||
|
end: endValue || startValue
|
||||||
|
};
|
||||||
if (updated.desiredWeekday) {
|
if (updated.desiredWeekday) {
|
||||||
delete updated.desiredWeekday;
|
delete updated.desiredWeekday;
|
||||||
}
|
}
|
||||||
@@ -839,7 +866,7 @@ function App() {
|
|||||||
return updated;
|
return updated;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
}, [setConfig, setIsDirty]);
|
||||||
|
|
||||||
const configMap = useMemo(() => {
|
const configMap = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -853,6 +880,16 @@ function App() {
|
|||||||
|
|
||||||
const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]);
|
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 handleStoreSelection = async (store) => {
|
||||||
const storeId = String(store.id);
|
const storeId = String(store.id);
|
||||||
const existing = configMap.get(storeId);
|
const existing = configMap.get(storeId);
|
||||||
@@ -1305,30 +1342,64 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 border-b">
|
<td className="px-4 py-2 border-b">
|
||||||
<div className="border rounded p-2 bg-gray-50">
|
<div className="relative">
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datum / Zeitraum</label>
|
<button
|
||||||
<div className="flex items-center gap-2">
|
type="button"
|
||||||
<input
|
onClick={() => {
|
||||||
type="date"
|
if (item.desiredWeekday) {
|
||||||
value={rangeStart}
|
return;
|
||||||
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"
|
setActiveRangePicker((prev) => (prev === item.id ? null : item.id));
|
||||||
disabled={Boolean(item.desiredWeekday)}
|
}}
|
||||||
/>
|
disabled={Boolean(item.desiredWeekday)}
|
||||||
<span className="text-xs text-gray-500">bis</span>
|
className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
<input
|
item.desiredWeekday
|
||||||
type="date"
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
value={rangeEnd}
|
: 'bg-white hover:border-blue-400'
|
||||||
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)}
|
<span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span>
|
||||||
min={rangeStart || undefined}
|
<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>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">Ein einzelner Tag: Start und Ende identisch wählen.</p>
|
)}
|
||||||
</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">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,10 +1,58 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { DateRange } from 'react-date-range';
|
||||||
|
import { format, parseISO, isValid } from 'date-fns';
|
||||||
|
import { de } from 'date-fns/locale';
|
||||||
|
import 'react-date-range/dist/styles.css';
|
||||||
|
import 'react-date-range/dist/theme/default.css';
|
||||||
|
|
||||||
|
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'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const PickupConfigEditor = () => {
|
const PickupConfigEditor = () => {
|
||||||
const [config, setConfig] = useState([]);
|
const [config, setConfig] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [activeRangePicker, setActiveRangePicker] = useState(null);
|
||||||
|
|
||||||
// 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';
|
||||||
@@ -96,44 +144,26 @@ const PickupConfigEditor = () => {
|
|||||||
|
|
||||||
const handleWeekdayChange = (index, value) => {
|
const handleWeekdayChange = (index, value) => {
|
||||||
const newConfig = [...config];
|
const newConfig = [...config];
|
||||||
|
const entryId = newConfig[index]?.id;
|
||||||
newConfig[index].desiredWeekday = value;
|
newConfig[index].desiredWeekday = value;
|
||||||
if (newConfig[index].desiredDateRange) {
|
if (newConfig[index].desiredDateRange) {
|
||||||
delete newConfig[index].desiredDateRange;
|
delete newConfig[index].desiredDateRange;
|
||||||
}
|
}
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
|
if (value && entryId) {
|
||||||
|
setActiveRangePicker((prev) => (prev === entryId ? null : prev));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (index, field, value) => {
|
const handleDateRangeSelection = (index, startDate, endDate) => {
|
||||||
const newConfig = [...config];
|
const newConfig = [...config];
|
||||||
const currentRange = newConfig[index].desiredDateRange
|
const startValue = formatDateValue(startDate);
|
||||||
? { ...newConfig[index].desiredDateRange }
|
const endValue = formatDateValue(endDate);
|
||||||
: newConfig[index].desiredDate
|
if (startValue || endValue) {
|
||||||
? { start: newConfig[index].desiredDate, end: newConfig[index].desiredDate }
|
newConfig[index].desiredDateRange = {
|
||||||
: { start: null, end: null };
|
start: startValue || endValue,
|
||||||
const nextRange = {
|
end: endValue || startValue
|
||||||
start: currentRange.start || null,
|
};
|
||||||
end: currentRange.end || null
|
|
||||||
};
|
|
||||||
if (field === 'start') {
|
|
||||||
nextRange.start = value || null;
|
|
||||||
if (!nextRange.start) {
|
|
||||||
nextRange.end = null;
|
|
||||||
} else if (!nextRange.end || nextRange.end < nextRange.start) {
|
|
||||||
nextRange.end = nextRange.start;
|
|
||||||
}
|
|
||||||
} else if (field === 'end') {
|
|
||||||
nextRange.end = value || null;
|
|
||||||
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);
|
|
||||||
if (hasRange) {
|
|
||||||
newConfig[index].desiredDateRange = nextRange;
|
|
||||||
if (newConfig[index].desiredWeekday) {
|
if (newConfig[index].desiredWeekday) {
|
||||||
delete newConfig[index].desiredWeekday;
|
delete newConfig[index].desiredWeekday;
|
||||||
}
|
}
|
||||||
@@ -235,32 +265,66 @@ const PickupConfigEditor = () => {
|
|||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<div className="border rounded p-2 bg-gray-50">
|
<div className="relative">
|
||||||
<label className="block text-xs text-gray-500 mb-1">Datum / Zeitraum</label>
|
<button
|
||||||
<div className="flex items-center gap-2">
|
type="button"
|
||||||
<input
|
onClick={() => {
|
||||||
type="date"
|
if (item.desiredWeekday) {
|
||||||
value={rangeStart}
|
return;
|
||||||
onChange={(e) => handleDateRangeChange(index, 'start', e.target.value)}
|
}
|
||||||
className="border rounded p-1 w-full"
|
setActiveRangePicker((prev) => (prev === item.id ? null : item.id));
|
||||||
disabled={Boolean(item.desiredWeekday)}
|
}}
|
||||||
/>
|
disabled={Boolean(item.desiredWeekday)}
|
||||||
<span className="text-xs text-gray-500">bis</span>
|
className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||||
<input
|
item.desiredWeekday
|
||||||
type="date"
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
value={rangeEnd}
|
: 'bg-white hover:border-blue-400'
|
||||||
onChange={(e) => handleDateRangeChange(index, 'end', e.target.value)}
|
}`}
|
||||||
className="border rounded p-1 w-full"
|
>
|
||||||
disabled={Boolean(item.desiredWeekday)}
|
<span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span>
|
||||||
min={rangeStart || undefined}
|
<span className="block text-xs text-gray-500">Klicke zum Auswählen</span>
|
||||||
/>
|
</button>
|
||||||
</div>
|
{activeRangePicker === item.id && !item.desiredWeekday && (
|
||||||
<p className="text-xs text-gray-500 mt-1">Ein einzelner Tag: Start = Ende.</p>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user