From 6d4051be38be2b8b7e1f8fb36c7e94a5799a87b1 Mon Sep 17 00:00:00 2001 From: Meik Date: Wed, 5 Aug 2026 18:42:40 +0200 Subject: [PATCH] feat: add mobile slot configuration Replace the mobile table with a searchable card editor while retaining the desktop table. Add a mobile-friendly date picker layout and sticky save action. --- .commitmessage | 7 +- src/App.css | 35 +++- src/App.js | 1 + src/components/DashboardView.js | 27 ++- src/components/MobileConfigList.css | 262 ++++++++++++++++++++++++ src/components/MobileConfigList.js | 181 ++++++++++++++++ src/components/MobileConfigList.test.js | 65 ++++++ src/components/RangePickerModal.js | 4 +- 8 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 src/components/MobileConfigList.css create mode 100644 src/components/MobileConfigList.js create mode 100644 src/components/MobileConfigList.test.js diff --git a/.commitmessage b/.commitmessage index 9f67e46..844d1c1 100644 --- a/.commitmessage +++ b/.commitmessage @@ -1,5 +1,4 @@ -fix: tolerate same-day pickup profile check +feat: add mobile slot configuration -Allow configured stores to ignore a visible same-day profile pickup when booking -a future pickup slot, starting with store 33875. Keep the default profile check -behavior unchanged for all other stores. +Replace the mobile table with a searchable card editor while retaining the +desktop table. Add a mobile-friendly date picker layout and sticky save action. diff --git a/src/App.css b/src/App.css index 08c5d97..09ec14b 100644 --- a/src/App.css +++ b/src/App.css @@ -119,7 +119,7 @@ pre { gap: 12px; } - button { + .button-container button { width: 100%; } @@ -129,6 +129,39 @@ pre { } } +.range-picker-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.4); +} + +.range-picker-modal { + width: 100%; + max-width: 32rem; + overflow: hidden; + border-radius: 8px; + background: #fff; + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.25); +} + +@media (max-width: 767px) { + .range-picker-backdrop { + align-items: flex-end; + padding: 0; + } + + .range-picker-modal { + max-height: calc(100dvh - 1rem); + border-radius: 8px 8px 0 0; + overflow-y: auto; + } +} + @keyframes dashboard-row-glow { 0% { background-color: rgba(59, 130, 246, 0.25); diff --git a/src/App.js b/src/App.js index 5a44d5a..9b5e493 100644 --- a/src/App.js +++ b/src/App.js @@ -836,6 +836,7 @@ function App() { onHideEntry={hideEntry} onDeleteEntry={deleteEntry} canDelete={Boolean(session?.isAdmin)} + isDirty={isDirty} focusedStoreId={focusedStoreId} onClearFocus={() => setFocusedStoreId(null)} userLocation={userLocationWithLabel} diff --git a/src/components/DashboardView.js b/src/components/DashboardView.js index fae206d..c0b0dd3 100644 --- a/src/components/DashboardView.js +++ b/src/components/DashboardView.js @@ -10,6 +10,7 @@ import { useReactTable } from '@tanstack/react-table'; import NotificationPanel from './NotificationPanel'; +import MobileConfigList from './MobileConfigList'; const CONFIG_TABLE_STATE_KEY = 'dashboardConfigTableState'; const columnHelper = createColumnHelper(); @@ -215,6 +216,7 @@ const DashboardView = ({ onHideEntry, onDeleteEntry, canDelete, + isDirty, focusedStoreId, onClearFocus, userLocation, @@ -966,7 +968,28 @@ const DashboardView = ({ -
+ + +
{configTable.getHeaderGroups().map((headerGroup) => ( @@ -1007,7 +1030,7 @@ const DashboardView = ({
-
+
diff --git a/src/components/MobileConfigList.css b/src/components/MobileConfigList.css new file mode 100644 index 0000000..86b901d --- /dev/null +++ b/src/components/MobileConfigList.css @@ -0,0 +1,262 @@ +.mobile-config-list { + display: none; +} + +@media (max-width: 767px) { + .mobile-config-list { + display: block; + padding-bottom: 5.5rem; + } + + .mobile-config-list__heading { + display: flex; + align-items: center; + justify-content: space-between; + margin: 1.25rem 0 0.75rem; + } + + .mobile-config-list__heading h2, + .mobile-config-card h3 { + margin: 0; + color: #1f2937; + } + + .mobile-config-list__heading h2 { + font-size: 1.125rem; + } + + .mobile-config-list__heading p, + .mobile-config-card__header p, + .mobile-config-card small { + margin: 0.15rem 0 0; + color: #6b7280; + font-size: 0.75rem; + } + + .mobile-config-list__filters { + display: grid; + gap: 0.6rem; + margin-bottom: 0.75rem; + } + + .mobile-config-list__search input { + box-sizing: border-box; + width: 100%; + min-height: 2.75rem; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 0.5rem 0.75rem; + background: #fff; + } + + .mobile-config-list__filter-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.35rem; + } + + .mobile-config-list__filter-tabs button { + width: auto; + min-height: 2.35rem; + border: 1px solid #cbd5e1; + border-radius: 5px; + background: #fff; + color: #374151; + font-size: 0.8125rem; + } + + .mobile-config-list__filter-tabs button.is-selected { + border-color: #2563eb; + background: #2563eb; + color: #fff; + } + + .mobile-config-list__cards { + display: grid; + gap: 0.75rem; + } + + .mobile-config-card { + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + padding: 0.9rem; + } + + .mobile-config-card__header, + .mobile-config-card__switch-row, + .mobile-config-card__check-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + + .mobile-config-card__slots { + margin-top: 0.45rem !important; + } + + .mobile-config-card__store-link { + flex: 0 0 auto; + width: 2.5rem; + height: 2.5rem; + display: grid; + place-items: center; + border: 1px solid #cbd5e1; + border-radius: 5px; + color: #475569; + } + + .mobile-config-card__store-link svg { + width: 1.15rem; + height: 1.15rem; + } + + .mobile-config-card__switch-row { + margin-top: 0.9rem; + padding: 0.75rem 0; + border-top: 1px solid #e5e7eb; + border-bottom: 1px solid #e5e7eb; + } + + .mobile-config-card input[type='checkbox'] { + width: 1.2rem; + height: 1.2rem; + flex: 0 0 auto; + } + + .mobile-config-card__core-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); + gap: 0.6rem; + margin: 0.85rem 0; + } + + .mobile-config-card__core-fields label, + .mobile-config-card__range { + width: 100%; + min-width: 0; + display: grid; + gap: 0.25rem; + color: #4b5563; + font-size: 0.75rem; + text-align: left; + } + + .mobile-config-card__core-fields select, + .mobile-config-card__range { + box-sizing: border-box; + min-height: 2.6rem; + border: 1px solid #cbd5e1; + border-radius: 5px; + background: #fff; + } + + .mobile-config-card__range { + align-content: center; + padding: 0.4rem 0.55rem; + } + + .mobile-config-card__range strong { + overflow: hidden; + color: #1f2937; + font-size: 0.8rem; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mobile-config-card__check-row { + justify-content: flex-start; + padding: 0.25rem 0; + color: #374151; + font-size: 0.875rem; + } + + .mobile-config-card__check-row span { + display: grid; + gap: 0.1rem; + } + + .mobile-config-card__details { + margin-top: 0.8rem; + border-top: 1px solid #e5e7eb; + } + + .mobile-config-card__details summary { + padding: 0.75rem 0; + color: #374151; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + } + + .mobile-config-card__details[open] summary { + margin-bottom: 0.2rem; + } + + .mobile-config-card__actions { + display: flex; + gap: 0.6rem; + margin-top: 0.65rem; + } + + .mobile-config-card__actions button, + .mobile-config-list__reset, + .mobile-config-list__save { + min-height: 2.5rem; + border: 1px solid #cbd5e1; + border-radius: 5px; + padding: 0.5rem 0.75rem; + background: #fff; + color: #374151; + font-size: 0.875rem; + } + + .mobile-config-card__actions button { + width: auto; + flex: 1; + } + + .mobile-config-card__actions button.is-danger { + border-color: #fecaca; + color: #b91c1c; + } + + .mobile-config-list__empty { + margin: 1.5rem 0; + text-align: center; + color: #6b7280; + font-size: 0.875rem; + } + + .mobile-config-list__save-bar { + position: fixed; + z-index: 30; + right: 0; + bottom: 0; + left: 0; + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 0.6rem; + padding: 0.7rem 1rem calc(0.7rem + env(safe-area-inset-bottom)); + border-top: 1px solid #cbd5e1; + background: #fff; + box-shadow: 0 -4px 12px rgba(15, 23, 42, 0.12); + } + + .mobile-config-list__save { + border-color: #2563eb; + background: #2563eb; + color: #fff; + font-weight: 600; + } + + .desktop-config-table, + .desktop-config-actions { + display: none; + } + + .App { + padding: 0; + } +} diff --git a/src/components/MobileConfigList.js b/src/components/MobileConfigList.js new file mode 100644 index 0000000..29c54b2 --- /dev/null +++ b/src/components/MobileConfigList.js @@ -0,0 +1,181 @@ +import { useMemo, useState } from 'react'; +import './MobileConfigList.css'; + +const FILTER_OPTIONS = [ + { value: 'all', label: 'Alle' }, + { value: 'active', label: 'Aktiv' }, + { value: 'inactive', label: 'Inaktiv' } +]; + +const MobileConfigList = ({ + entries, + regularPickupMap, + weekdays, + formatRangeLabel, + formatRegularPickup, + onToggleActive, + onToggleAutoDeactivate, + onToggleProfileCheck, + onToggleOnlyNotify, + onToggleDormantSkip, + onWeekdayChange, + onRangePickerRequest, + onHideEntry, + onDeleteEntry, + canDelete, + isDirty, + onSaveConfig, + onResetConfig +}) => { + const [search, setSearch] = useState(''); + const [activityFilter, setActivityFilter] = useState('all'); + + const filteredEntries = useMemo(() => { + const normalizedSearch = search.trim().toLocaleLowerCase('de-DE'); + return (Array.isArray(entries) ? entries : []).filter((entry) => { + if (activityFilter === 'active' && !entry.active) { + return false; + } + if (activityFilter === 'inactive' && entry.active) { + return false; + } + if (!normalizedSearch) { + return true; + } + return `${entry.label || ''} ${entry.id || ''}`.toLocaleLowerCase('de-DE').includes(normalizedSearch); + }); + }, [activityFilter, entries, search]); + + return ( +
+
+
+

Slot-Konfiguration

+

{entries.length} Betriebe

+
+
+ +
+ +
+ {FILTER_OPTIONS.map((option) => ( + + ))} +
+
+ +
+ {filteredEntries.map((entry) => { + const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null); + const slots = formatRegularPickup(regularPickupMap?.[entry.id]); + return ( +
+
+
+

{entry.label || `Store ${entry.id}`}

+

#{entry.id}

+ {slots &&

Slots: {slots}

} +
+ + + +
+ + + +
+ + +
+ + + +
+ Weitere Optionen + + + +
+ + {canDelete && } +
+
+
+ ); + })} +
+ + {filteredEntries.length === 0 &&

Keine passenden Betriebe gefunden.

} + + {isDirty && ( +
+ + +
+ )} +
+ ); +}; + +export default MobileConfigList; diff --git a/src/components/MobileConfigList.test.js b/src/components/MobileConfigList.test.js new file mode 100644 index 0000000..ca133f7 --- /dev/null +++ b/src/components/MobileConfigList.test.js @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import MobileConfigList from './MobileConfigList'; + +const entry = { + id: '33875', + label: 'Balkanbäckerei', + active: false, + autoDeactivate: true, + checkProfileId: true, + onlyNotify: false, + skipDormantCheck: false +}; + +const props = { + entries: [entry, { ...entry, id: '69375', label: 'Donat', active: true }], + regularPickupMap: {}, + weekdays: ['Montag', 'Dienstag'], + formatRangeLabel: () => 'Kein Zeitraum', + formatRegularPickup: () => null, + onToggleActive: jest.fn(), + onToggleAutoDeactivate: jest.fn(), + onToggleProfileCheck: jest.fn(), + onToggleOnlyNotify: jest.fn(), + onToggleDormantSkip: jest.fn(), + onWeekdayChange: jest.fn(), + onRangePickerRequest: jest.fn(), + onHideEntry: jest.fn(), + onDeleteEntry: jest.fn(), + canDelete: true, + isDirty: true, + onSaveConfig: jest.fn(), + onResetConfig: jest.fn() +}; + +describe('MobileConfigList', () => { + beforeEach(() => jest.clearAllMocks()); + + it('filters entries by search and activity', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Aktiv' })); + expect(screen.getByText('Donat')).toBeInTheDocument(); + expect(screen.queryByText('Balkanbäckerei')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Alle' })); + fireEvent.change(screen.getByPlaceholderText('Betrieb oder ID suchen'), { target: { value: '33875' } }); + expect(screen.getByText('Balkanbäckerei')).toBeInTheDocument(); + expect(screen.queryByText('Donat')).not.toBeInTheDocument(); + }); + + it('uses the existing configuration callbacks', () => { + render(); + + fireEvent.click(screen.getByLabelText('Balkanbäckerei automatisch buchen')); + fireEvent.click(screen.getAllByText('Kein Zeitraum')[0]); + fireEvent.click(screen.getAllByText('Weitere Optionen')[0]); + fireEvent.click(screen.getAllByLabelText('Profil prüfen')[0]); + fireEvent.click(screen.getByText('Speichern')); + + expect(props.onToggleActive).toHaveBeenCalledWith('33875'); + expect(props.onRangePickerRequest).toHaveBeenCalledWith('33875'); + expect(props.onToggleProfileCheck).toHaveBeenCalledWith('33875'); + expect(props.onSaveConfig).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/RangePickerModal.js b/src/components/RangePickerModal.js index b145459..e9f498b 100644 --- a/src/components/RangePickerModal.js +++ b/src/components/RangePickerModal.js @@ -121,9 +121,9 @@ const RangePickerModal = ({ }; return ( -
+
event.stopPropagation()} >