Files
Pickup-Config/src/components/StoreWatchPage.js
2025-11-10 17:44:43 +01:00

814 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from 'react';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable
} from '@tanstack/react-table';
import { haversineDistanceKm } from '../utils/distance';
const columnHelper = createColumnHelper();
const ColumnTextFilter = ({ column, placeholder }) => {
if (!column.getCanFilter()) {
return null;
}
return (
<input
type="text"
value={column.getFilterValue() ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
placeholder={placeholder}
className="mt-1 w-full rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
);
};
const ColumnSelectFilter = ({ column, options }) => {
if (!column.getCanFilter()) {
return null;
}
return (
<select
value={column.getFilterValue() ?? ''}
onChange={(event) => column.setFilterValue(event.target.value || undefined)}
className="mt-1 w-full rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Alle</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
};
const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => {
const [regions, setRegions] = useState([]);
const [selectedRegionId, setSelectedRegionId] = useState('');
const [storesByRegion, setStoresByRegion] = useState({});
const [watchList, setWatchList] = useState([]);
const [regionLoading, setRegionLoading] = useState(false);
const [storesLoading, setStoresLoading] = useState(false);
const [subscriptionsLoading, setSubscriptionsLoading] = useState(false);
const [status, setStatus] = useState('');
const [error, setError] = useState('');
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([{ id: 'isOpen', value: 'true' }]);
const watchedIds = useMemo(
() => new Set(watchList.map((entry) => String(entry.storeId))),
[watchList]
);
const selectedRegion = useMemo(() => {
if (selectedRegionId === 'all') {
return null;
}
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();
regions.forEach((region) => {
const entry = storesByRegion[String(region.id)];
if (entry?.stores) {
entry.stores.forEach((store) => {
if (store?.id) {
combined.set(String(store.id), store);
}
});
}
});
return Array.from(combined.values());
}
const regionEntry = storesByRegion[String(selectedRegionId)];
if (!regionEntry || !Array.isArray(regionEntry.stores)) {
return [];
}
return regionEntry.stores;
}, [regions, storesByRegion, selectedRegionId]);
const regionStores = useMemo(() => currentStores, [currentStores]);
const membershipMap = useMemo(() => {
const map = new Map();
(knownStores || []).forEach((store) => {
if (store?.id) {
map.set(String(store.id), store);
}
});
return map;
}, [knownStores]);
const handleToggleStore = useCallback(
(store, checked) => {
setWatchList((prev) => {
const storeId = String(store.id || store.storeId);
const existing = prev.find((entry) => entry.storeId === storeId);
if (checked) {
if (!store.isOpen || existing) {
return prev;
}
setDirty(true);
const regionName = store.region?.name || selectedRegion?.name || existing?.regionName || '';
return [
...prev,
{
storeId,
storeName: store.name || store.storeName || `Store ${storeId}`,
regionId: String(store.region?.id || selectedRegionId || existing?.regionId || ''),
regionName,
lastTeamSearchStatus: existing?.lastTeamSearchStatus ?? null
}
];
}
if (!existing) {
return prev;
}
setDirty(true);
return prev.filter((entry) => entry.storeId !== storeId);
});
},
[selectedRegion, selectedRegionId]
);
const handleRemoveWatch = useCallback((storeId) => {
setWatchList((prev) => {
const next = prev.filter((entry) => entry.storeId !== storeId);
if (next.length !== prev.length) {
setDirty(true);
}
return next;
});
}, []);
const tableData = useMemo(
() =>
regionStores.map((store) => {
const membership = membershipMap.has(String(store.id));
const lat = Number(store.location?.lat);
const lon = Number(store.location?.lon);
const distance =
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,
isOpen
};
}),
[regionStores, membershipMap, userLocation]
);
const columns = useMemo(
() => [
columnHelper.accessor('name', {
header: ({ column }) => (
<div>
<button
type="button"
className="flex w-full items-center justify-between text-left font-semibold"
onClick={column.getToggleSortingHandler()}
>
<span>Betrieb</span>
{column.getIsSorted() ? (
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</button>
<ColumnTextFilter column={column} placeholder="Name / ID" />
</div>
),
cell: ({ row }) => (
<div>
<p className="font-medium text-gray-900">{row.original.name}</p>
<p className="text-xs text-gray-500">#{row.original.id}</p>
</div>
),
sortingFn: 'alphanumeric',
enableColumnFilter: true,
filterFn: 'includesString'
}),
columnHelper.accessor((row) => row.city || '', {
id: 'city',
header: ({ column }) => (
<div>
<button
type="button"
className="flex w-full items-center justify-between text-left font-semibold"
onClick={column.getToggleSortingHandler()}
>
<span>Ort</span>
{column.getIsSorted() ? (
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</button>
<ColumnTextFilter column={column} placeholder="Ort / PLZ" />
</div>
),
cell: ({ row }) => (
<div>
<p className="text-gray-800 text-sm">{row.original.city || 'unbekannt'}</p>
<p className="text-xs text-gray-500">{row.original.street || ''}</p>
</div>
),
sortingFn: 'alphanumeric',
filterFn: 'includesString'
}),
columnHelper.accessor('createdAt', {
header: ({ column }) => (
<button
type="button"
className="flex w-full items-center justify-between text-left font-semibold"
onClick={column.getToggleSortingHandler()}
>
<span>Kooperation seit</span>
{column.getIsSorted() ? (
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</button>
),
cell: ({ getValue }) => {
const value = getValue();
return (
<span className="text-sm text-gray-600">
{value ? new Date(value).toLocaleDateString('de-DE') : 'unbekannt'}
</span>
);
},
sortingFn: (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId);
const b = rowB.getValue(columnId);
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>
<button
type="button"
className="flex w-full items-center justify-between text-left font-semibold"
onClick={column.getToggleSortingHandler()}
>
<span>Mitglied</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('distanceKm', {
header: ({ column }) => (
<div>
<button
type="button"
className="flex w-full items-center justify-between text-left font-semibold disabled:cursor-not-allowed disabled:text-gray-400"
onClick={userLocation ? column.getToggleSortingHandler() : undefined}
disabled={!userLocation}
>
<span>Entfernung</span>
{column.getIsSorted() ? (
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</button>
{!userLocation && <p className="mt-1 text-xs text-gray-500">Standort erforderlich</p>}
</div>
),
cell: ({ getValue }) => {
const value = getValue();
if (!value && value !== 0) {
return <span className="text-sm text-gray-500"></span>;
}
return <span className="text-sm text-gray-800">{value.toFixed(2)} km</span>;
},
sortingFn: (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId);
const b = rowB.getValue(columnId);
if (a === null || a === undefined) {
return 1;
}
if (b === null || b === undefined) {
return -1;
}
return a - b;
},
enableColumnFilter: false,
enableSorting: !!userLocation
}),
columnHelper.display({
id: 'watch',
header: () => <span>Überwachen</span>,
cell: ({ row }) => {
const store = row.original;
const checked = watchedIds.has(String(store.id));
return (
<div className="text-center">
<input
type="checkbox"
className="h-5 w-5"
checked={checked}
onChange={(event) => handleToggleStore(store, event.target.checked)}
/>
</div>
);
}
})
],
[handleToggleStore, watchedIds, userLocation]
);
const table = useReactTable({
data: tableData,
columns,
state: {
sorting,
columnFilters
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
});
const loadRegions = useCallback(async () => {
if (!authorizedFetch) {
return;
}
setRegionLoading(true);
setError('');
try {
const response = await authorizedFetch('/api/store-watch/regions');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const normalized = Array.isArray(data.regions) ? data.regions : [];
setRegions(normalized);
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 {
setRegionLoading(false);
}
}, [authorizedFetch, selectedRegionId]);
const loadSubscriptions = useCallback(async () => {
if (!authorizedFetch) {
return;
}
setSubscriptionsLoading(true);
setError('');
try {
const response = await authorizedFetch('/api/store-watch/subscriptions');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const normalized = Array.isArray(data.stores) ? data.stores : [];
setWatchList(normalized);
setDirty(false);
} catch (err) {
setError(`Überwachte Betriebe konnten nicht geladen werden: ${err.message}`);
} finally {
setSubscriptionsLoading(false);
}
}, [authorizedFetch]);
const fetchStoresForRegion = useCallback(
async (regionId, { force, silent } = {}) => {
if (!authorizedFetch || !regionId) {
return;
}
if (!force && storesByRegion[String(regionId)]) {
return;
}
if (!silent) {
setStoresLoading(true);
}
setError('');
try {
const endpoint = force
? `/api/store-watch/regions/${regionId}/stores?force=1`
: `/api/store-watch/regions/${regionId}/stores`;
const response = await authorizedFetch(endpoint);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setStoresByRegion((prev) => ({
...prev,
[String(regionId)]: {
total: Number(data.total) || 0,
stores: Array.isArray(data.stores) ? data.stores : [],
fetchedAt: Date.now()
}
}));
} catch (err) {
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
} finally {
if (!silent) {
setStoresLoading(false);
}
}
},
[authorizedFetch, storesByRegion]
);
const fetchAllRegions = useCallback(
async ({ force } = {}) => {
if (!authorizedFetch || regions.length === 0) {
return;
}
const targets = regions.filter(
(region) => force || !storesByRegion[String(region.id)]
);
if (targets.length === 0) {
return;
}
setStoresLoading(true);
setError('');
try {
for (const region of targets) {
await fetchStoresForRegion(region.id, { force, silent: true });
}
} catch (err) {
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
} finally {
setStoresLoading(false);
}
},
[authorizedFetch, regions, storesByRegion, fetchStoresForRegion]
);
useEffect(() => {
loadRegions();
loadSubscriptions();
}, [loadRegions, loadSubscriptions]);
useEffect(() => {
if (!selectedRegionId) {
return;
}
if (selectedRegionId === 'all') {
fetchAllRegions({ force: false });
} else {
fetchStoresForRegion(selectedRegionId);
}
}, [selectedRegionId, fetchStoresForRegion, fetchAllRegions]);
const handleSave = useCallback(async () => {
if (!authorizedFetch || saving || !dirty) {
return;
}
setSaving(true);
setStatus('');
setError('');
try {
const response = await authorizedFetch('/api/store-watch/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stores: watchList })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setWatchList(Array.isArray(data.stores) ? data.stores : []);
setDirty(false);
setStatus('Überwachung gespeichert.');
setTimeout(() => setStatus(''), 4000);
} catch (err) {
setError(`Speichern fehlgeschlagen: ${err.message}`);
} finally {
setSaving(false);
}
}, [authorizedFetch, dirty, saving, watchList]);
const handleReset = useCallback(() => {
loadSubscriptions();
}, [loadSubscriptions]);
if (!authorizedFetch) {
return (
<div className="p-4 max-w-4xl mx-auto">
<p className="text-red-600">Keine Session aktiv.</p>
</div>
);
}
return (
<div className="p-4 max-w-5xl mx-auto bg-white shadow rounded-lg mt-4">
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold text-blue-900">Betriebs-Monitoring</h1>
<p className="text-gray-600 text-sm">
Wähle Betriebe aus, die bei offenem Team-Status automatisch gemeldet werden sollen.
</p>
</div>
{(error || status) && (
<div className="mb-4 space-y-2">
{error && (
<div className="bg-red-100 border border-red-300 text-red-700 px-4 py-2 rounded">{error}</div>
)}
{status && (
<div className="bg-green-100 border border-green-300 text-green-700 px-4 py-2 rounded">
{status}
</div>
)}
</div>
)}
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
<div className="flex flex-col md:flex-row md:items-end gap-3">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="region-select">
Region
</label>
<select
id="region-select"
value={selectedRegionId}
onChange={(event) => setSelectedRegionId(event.target.value || 'all')}
className="border rounded-md p-2 w-full"
disabled={regionLoading}
>
<option value="all">Alle Regionen</option>
{regions.map((region) => (
<option key={region.id} value={region.id}>
{region.name}
</option>
))}
</select>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => loadRegions()}
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
disabled={regionLoading}
>
Regionen neu laden
</button>
<button
type="button"
onClick={() =>
selectedRegionId === 'all'
? fetchAllRegions({ force: true })
: fetchStoresForRegion(selectedRegionId, { force: true })
}
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
disabled={regionLoading || storesLoading || (selectedRegionId === 'all' && regions.length === 0)}
>
Betriebe aktualisieren
</button>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">
Es werden nur Regionen angezeigt, in denen du Mitglied mit Klassifikation 1 bist.
</p>
</div>
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-gray-800">Betriebe in {activeRegionLabel}</h2>
{lastUpdatedAt && (
<span className="text-xs text-gray-500">
Aktualisiert: {new Date(lastUpdatedAt).toLocaleTimeString('de-DE')}
</span>
)}
</div>
{storesLoading && <p className="text-sm text-gray-600">Lade Betriebe...</p>}
{!storesLoading && table.getRowModel().rows.length === 0 && (
<p className="text-sm text-gray-500">
Keine Betriebe gefunden. Prüfe Filter oder sortiere anders.
</p>
)}
{!storesLoading && table.getRowModel().rows.length > 0 && (
<div className="overflow-x-auto border border-gray-200 rounded-lg">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-100">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-4 py-2 text-left align-top text-sm font-semibold">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="bg-white">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-2 align-middle">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-800">
Überwachte Betriebe ({watchList.length})
</h2>
<div className="flex gap-2">
<button
type="button"
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
onClick={handleReset}
disabled={subscriptionsLoading}
>
Änderungen verwerfen
</button>
<button
type="button"
className="px-4 py-2 rounded-md text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60"
onClick={handleSave}
disabled={!dirty || saving}
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
{subscriptionsLoading && <p className="text-sm text-gray-600">Lade aktuelle Auswahl...</p>}
{!subscriptionsLoading && watchList.length === 0 && (
<p className="text-sm text-gray-500">Noch keine Betriebe ausgewählt.</p>
)}
{watchList.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{watchList.map((entry) => (
<div key={entry.storeId} className="border border-gray-200 rounded-lg p-3 bg-gray-50">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{entry.storeName}</p>
<p className="text-xs text-gray-500">
#{entry.storeId} {entry.regionName || 'Region unbekannt'}
</p>
</div>
<button
type="button"
className="text-xs text-red-600 hover:underline"
onClick={() => handleRemoveWatch(entry.storeId)}
>
Entfernen
</button>
</div>
<p className="text-xs text-gray-600 mt-2">
Letzter Status:{' '}
{entry.lastTeamSearchStatus === 1
? 'Suchend'
: entry.lastTeamSearchStatus === 0
? 'Nicht suchend'
: 'Unbekannt'}
</p>
</div>
))}
</div>
)}
</div>
{dirty && (
<p className="text-xs text-amber-600">
Es gibt ungespeicherte Änderungen. Bitte "Speichern" klicken.
</p>
)}
</div>
);
};
export default StoreWatchPage;