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.
This commit is contained in:
181
src/components/MobileConfigList.js
Normal file
181
src/components/MobileConfigList.js
Normal file
@@ -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 (
|
||||
<section className="mobile-config-list" aria-label="Slot-Konfiguration">
|
||||
<div className="mobile-config-list__heading">
|
||||
<div>
|
||||
<h2>Slot-Konfiguration</h2>
|
||||
<p>{entries.length} Betriebe</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mobile-config-list__filters">
|
||||
<label className="mobile-config-list__search">
|
||||
<span className="sr-only">Betrieb suchen</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Betrieb oder ID suchen"
|
||||
/>
|
||||
</label>
|
||||
<div className="mobile-config-list__filter-tabs" aria-label="Aktivitätsfilter">
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={activityFilter === option.value ? 'is-selected' : ''}
|
||||
aria-pressed={activityFilter === option.value}
|
||||
onClick={() => setActivityFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mobile-config-list__cards">
|
||||
{filteredEntries.map((entry) => {
|
||||
const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
|
||||
const slots = formatRegularPickup(regularPickupMap?.[entry.id]);
|
||||
return (
|
||||
<article key={entry.id} className="mobile-config-card" data-store-row={entry.id}>
|
||||
<header className="mobile-config-card__header">
|
||||
<div>
|
||||
<h3>{entry.label || `Store ${entry.id}`}</h3>
|
||||
<p>#{entry.id}</p>
|
||||
{slots && <p className="mobile-config-card__slots">Slots: {slots}</p>}
|
||||
</div>
|
||||
<a
|
||||
href={`https://foodsharing.de/store/${entry.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mobile-config-card__store-link"
|
||||
aria-label={`${entry.label || `Store ${entry.id}`} bei Foodsharing öffnen`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12.293 2.293a1 1 0 011.414 0L18 6.586V7a1 1 0 01-1 1h-.586l-3.707-3.707a1 1 0 010-1.414z" />
|
||||
<path d="M3 5a2 2 0 012-2h5a1 1 0 010 2H5v10h10v-5a1 1 0 112 0v5a2 2 0 01-2 2H5a2 2 0 01-2-2V5z" />
|
||||
<path d="M11 3a1 1 0 100 2h2v2a1 1 0 102 0V4a1 1 0 00-1-1h-3z" />
|
||||
</svg>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<label className="mobile-config-card__switch-row">
|
||||
<span>
|
||||
<strong>Automatisch buchen</strong>
|
||||
<small>{entry.active ? 'Aktiv' : 'Inaktiv'}</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!entry.active}
|
||||
onChange={() => onToggleActive(entry.id)}
|
||||
aria-label={`${entry.label || entry.id} automatisch buchen`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mobile-config-card__core-fields">
|
||||
<label>
|
||||
<span>Wochentag</span>
|
||||
<select value={entry.desiredWeekday || ''} onChange={(event) => onWeekdayChange(entry.id, event.target.value)}>
|
||||
<option value="">Kein Wochentag</option>
|
||||
{weekdays.map((day) => <option key={day} value={day}>{day}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="mobile-config-card__range" onClick={() => onRangePickerRequest(entry.id)}>
|
||||
<span>Datum / Zeitraum</span>
|
||||
<strong>{formatRangeLabel(range?.start || '', range?.end || '')}</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="mobile-config-card__check-row">
|
||||
<input type="checkbox" checked={!!entry.onlyNotify} onChange={() => onToggleOnlyNotify(entry.id)} />
|
||||
<span>
|
||||
<strong>Nur benachrichtigen</strong>
|
||||
<small>Keinen Slot automatisch buchen</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<details className="mobile-config-card__details">
|
||||
<summary>Weitere Optionen</summary>
|
||||
<label className="mobile-config-card__check-row">
|
||||
<input type="checkbox" checked={entry.checkProfileId !== false} onChange={() => onToggleProfileCheck(entry.id)} />
|
||||
<span>Profil prüfen</span>
|
||||
</label>
|
||||
<label className="mobile-config-card__check-row">
|
||||
<input type="checkbox" checked={entry.autoDeactivate !== false} onChange={() => onToggleAutoDeactivate(entry.id)} />
|
||||
<span>Nach erfolgreicher Aktion deaktivieren</span>
|
||||
</label>
|
||||
<label className="mobile-config-card__check-row">
|
||||
<input type="checkbox" checked={!entry.skipDormantCheck} onChange={() => onToggleDormantSkip(entry.id)} />
|
||||
<span>Inaktivitätswarnung</span>
|
||||
</label>
|
||||
<div className="mobile-config-card__actions">
|
||||
<button type="button" onClick={() => onHideEntry(entry.id)}>Ausblenden</button>
|
||||
{canDelete && <button type="button" className="is-danger" onClick={() => onDeleteEntry(entry.id)}>Löschen</button>}
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredEntries.length === 0 && <p className="mobile-config-list__empty">Keine passenden Betriebe gefunden.</p>}
|
||||
|
||||
{isDirty && (
|
||||
<div className="mobile-config-list__save-bar">
|
||||
<button type="button" className="mobile-config-list__reset" onClick={onResetConfig}>Zurücksetzen</button>
|
||||
<button type="button" className="mobile-config-list__save" onClick={onSaveConfig}>Speichern</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileConfigList;
|
||||
Reference in New Issue
Block a user