Feat: Geolocation
This commit is contained in:
@@ -59,7 +59,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
const [columnFilters, setColumnFilters] = useState([]);
|
||||
const [columnFilters, setColumnFilters] = useState([{ id: 'isOpen', value: 'true' }]);
|
||||
|
||||
const watchedIds = useMemo(
|
||||
() => new Set(watchList.map((entry) => String(entry.storeId))),
|
||||
@@ -73,6 +73,21 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
return regions.find((region) => String(region.id) === String(selectedRegionId)) || null;
|
||||
}, [regions, selectedRegionId]);
|
||||
|
||||
const activeRegionLabel = selectedRegionId === 'all' ? 'allen Regionen' : selectedRegion?.name || 'dieser Region';
|
||||
|
||||
const lastUpdatedAt = useMemo(() => {
|
||||
if (selectedRegionId === 'all') {
|
||||
const timestamps = regions
|
||||
.map((region) => storesByRegion[String(region.id)]?.fetchedAt)
|
||||
.filter(Boolean);
|
||||
if (timestamps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(...timestamps);
|
||||
}
|
||||
return storesByRegion[String(selectedRegionId)]?.fetchedAt || null;
|
||||
}, [regions, selectedRegionId, storesByRegion]);
|
||||
|
||||
const currentStores = useMemo(() => {
|
||||
if (selectedRegionId === 'all') {
|
||||
const combined = new Map();
|
||||
@@ -95,10 +110,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
return regionEntry.stores;
|
||||
}, [regions, storesByRegion, selectedRegionId]);
|
||||
|
||||
const eligibleStores = useMemo(
|
||||
() => currentStores.filter((store) => Number(store.cooperationStatus) === 5),
|
||||
[currentStores]
|
||||
);
|
||||
const regionStores = useMemo(() => currentStores, [currentStores]);
|
||||
|
||||
const membershipMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
@@ -116,7 +128,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
const storeId = String(store.id || store.storeId);
|
||||
const existing = prev.find((entry) => entry.storeId === storeId);
|
||||
if (checked) {
|
||||
if (existing) {
|
||||
if (!store.isOpen || existing) {
|
||||
return prev;
|
||||
}
|
||||
setDirty(true);
|
||||
@@ -154,7 +166,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
eligibleStores.map((store) => {
|
||||
regionStores.map((store) => {
|
||||
const membership = membershipMap.has(String(store.id));
|
||||
const lat = Number(store.location?.lat);
|
||||
const lon = Number(store.location?.lon);
|
||||
@@ -162,13 +174,15 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
userLocation && Number.isFinite(lat) && Number.isFinite(lon)
|
||||
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
|
||||
: null;
|
||||
const isOpen = Number(store.cooperationStatus) === 5;
|
||||
return {
|
||||
...store,
|
||||
membership,
|
||||
distanceKm: distance
|
||||
distanceKm: distance,
|
||||
isOpen
|
||||
};
|
||||
}),
|
||||
[eligibleStores, membershipMap, userLocation]
|
||||
[regionStores, membershipMap, userLocation]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
@@ -258,6 +272,55 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
return new Date(a || 0).getTime() - new Date(b || 0).getTime();
|
||||
}
|
||||
}),
|
||||
columnHelper.accessor('isOpen', {
|
||||
header: ({ column }) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between text-left font-semibold"
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
<span>Offen</span>
|
||||
{column.getIsSorted() ? (
|
||||
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">⇅</span>
|
||||
)}
|
||||
</button>
|
||||
<ColumnSelectFilter
|
||||
column={column}
|
||||
options={[
|
||||
{ value: 'true', label: 'Ja' },
|
||||
{ value: 'false', label: 'Nein' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded px-2 py-1 text-xs font-semibold ${
|
||||
value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{value ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
filterFn: (row, columnId, value) => {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
const boolValue = value === 'true';
|
||||
return row.getValue(columnId) === boolValue;
|
||||
},
|
||||
sortingFn: (rowA, rowB, columnId) => {
|
||||
const a = rowA.getValue(columnId);
|
||||
const b = rowB.getValue(columnId);
|
||||
return Number(b) - Number(a);
|
||||
}
|
||||
}),
|
||||
columnHelper.accessor('membership', {
|
||||
header: ({ column }) => (
|
||||
<div>
|
||||
@@ -397,9 +460,19 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
const data = await response.json();
|
||||
const normalized = Array.isArray(data.regions) ? data.regions : [];
|
||||
setRegions(normalized);
|
||||
if (!selectedRegionId && normalized.length > 0) {
|
||||
setSelectedRegionId(String(normalized[0].id));
|
||||
}
|
||||
setSelectedRegionId((prev) => {
|
||||
if (!prev) {
|
||||
return 'all';
|
||||
}
|
||||
if (prev === 'all') {
|
||||
return prev;
|
||||
}
|
||||
const exists = normalized.some((region) => String(region.id) === String(prev));
|
||||
if (!exists) {
|
||||
return normalized.length > 0 ? String(normalized[0].id) : 'all';
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(`Regionen konnten nicht geladen werden: ${err.message}`);
|
||||
} finally {
|
||||
@@ -582,11 +655,10 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
<select
|
||||
id="region-select"
|
||||
value={selectedRegionId}
|
||||
onChange={(event) => setSelectedRegionId(event.target.value)}
|
||||
onChange={(event) => setSelectedRegionId(event.target.value || 'all')}
|
||||
className="border rounded-md p-2 w-full"
|
||||
disabled={regionLoading}
|
||||
>
|
||||
<option value="">Region auswählen</option>
|
||||
<option value="all">Alle Regionen</option>
|
||||
{regions.map((region) => (
|
||||
<option key={region.id} value={region.id}>
|
||||
@@ -612,7 +684,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
: fetchStoresForRegion(selectedRegionId, { force: true })
|
||||
}
|
||||
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
|
||||
disabled={!selectedRegionId || storesLoading}
|
||||
disabled={regionLoading || storesLoading || (selectedRegionId === 'all' && regions.length === 0)}
|
||||
>
|
||||
Betriebe aktualisieren
|
||||
</button>
|
||||
@@ -625,19 +697,15 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold text-gray-800">Betriebe in der Region</h2>
|
||||
{storesByRegion[String(selectedRegionId)]?.fetchedAt && (
|
||||
<h2 className="text-lg font-semibold text-gray-800">Betriebe in {activeRegionLabel}</h2>
|
||||
{lastUpdatedAt && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Aktualisiert:{' '}
|
||||
{new Date(storesByRegion[String(selectedRegionId)].fetchedAt).toLocaleTimeString('de-DE')}
|
||||
Aktualisiert: {new Date(lastUpdatedAt).toLocaleTimeString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{storesLoading && <p className="text-sm text-gray-600">Lade Betriebe...</p>}
|
||||
{!storesLoading && !selectedRegionId && (
|
||||
<p className="text-sm text-gray-500">Bitte zuerst eine Region auswählen.</p>
|
||||
)}
|
||||
{!storesLoading && selectedRegionId && table.getRowModel().rows.length === 0 && (
|
||||
{!storesLoading && table.getRowModel().rows.length === 0 && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Keine Betriebe gefunden. Prüfe Filter oder sortiere anders.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user