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:
2026-08-05 18:42:40 +02:00
parent 8931418bf7
commit 6d4051be38
8 changed files with 573 additions and 9 deletions

View 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;