973 lines
33 KiB
JavaScript
973 lines
33 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
createColumnHelper,
|
||
flexRender,
|
||
getCoreRowModel,
|
||
getFilteredRowModel,
|
||
getSortedRowModel,
|
||
useReactTable
|
||
} from '@tanstack/react-table';
|
||
import { haversineDistanceKm } from '../utils/distance';
|
||
|
||
const REGION_STORAGE_KEY = 'storeWatchRegionSelection';
|
||
const WATCH_TABLE_STATE_KEY = 'storeWatchTableState';
|
||
|
||
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>
|
||
);
|
||
};
|
||
|
||
function readWatchTableState() {
|
||
if (typeof window === 'undefined') {
|
||
return {
|
||
sorting: [],
|
||
columnFilters: [{ id: 'isOpen', value: 'true' }]
|
||
};
|
||
}
|
||
try {
|
||
const raw = window.localStorage.getItem(WATCH_TABLE_STATE_KEY);
|
||
if (!raw) {
|
||
return {
|
||
sorting: [],
|
||
columnFilters: [{ id: 'isOpen', value: 'true' }]
|
||
};
|
||
}
|
||
const parsed = JSON.parse(raw);
|
||
return {
|
||
sorting: Array.isArray(parsed.sorting) ? parsed.sorting : [],
|
||
columnFilters: Array.isArray(parsed.columnFilters) && parsed.columnFilters.length > 0
|
||
? parsed.columnFilters
|
||
: [{ id: 'isOpen', value: 'true' }]
|
||
};
|
||
} catch {
|
||
return {
|
||
sorting: [],
|
||
columnFilters: [{ id: 'isOpen', value: 'true' }]
|
||
};
|
||
}
|
||
}
|
||
|
||
function persistWatchTableState(state) {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
try {
|
||
window.localStorage.setItem(WATCH_TABLE_STATE_KEY, JSON.stringify(state));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => {
|
||
const [regions, setRegions] = useState([]);
|
||
const [selectedRegionId, setSelectedRegionId] = useState(() => {
|
||
if (typeof window === 'undefined') {
|
||
return 'all';
|
||
}
|
||
try {
|
||
return window.localStorage.getItem(REGION_STORAGE_KEY) || 'all';
|
||
} catch {
|
||
return 'all';
|
||
}
|
||
});
|
||
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 initialTableState = useMemo(() => readWatchTableState(), []);
|
||
const [sorting, setSorting] = useState(initialTableState.sorting);
|
||
const [columnFilters, setColumnFilters] = useState(initialTableState.columnFilters);
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
try {
|
||
window.localStorage.setItem(REGION_STORAGE_KEY, selectedRegionId || 'all');
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, [selectedRegionId]);
|
||
|
||
useEffect(() => {
|
||
persistWatchTableState({ sorting, columnFilters });
|
||
}, [sorting, columnFilters]);
|
||
|
||
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 selectedStatusMeta = useMemo(() => {
|
||
if (selectedRegionId === 'all') {
|
||
const metas = regions
|
||
.map((region) => storesByRegion[String(region.id)]?.statusMeta)
|
||
.filter(Boolean);
|
||
if (metas.length === 0) {
|
||
return null;
|
||
}
|
||
const aggregated = metas.reduce(
|
||
(acc, meta) => ({
|
||
total: acc.total + (meta.total || 0),
|
||
refreshed: acc.refreshed + (meta.refreshed || 0),
|
||
fromCache: acc.fromCache + (meta.fromCache || 0),
|
||
missing: acc.missing + (meta.missing || 0),
|
||
generatedAt: Math.max(acc.generatedAt, meta.generatedAt || 0)
|
||
}),
|
||
{ total: 0, refreshed: 0, fromCache: 0, missing: 0, generatedAt: 0 }
|
||
);
|
||
return aggregated;
|
||
}
|
||
return storesByRegion[String(selectedRegionId)]?.statusMeta || null;
|
||
}, [selectedRegionId, storesByRegion, regions]);
|
||
|
||
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.filter((store) => Number(store.cooperationStatus) === 5),
|
||
[currentStores]
|
||
);
|
||
|
||
const statusSummary = useMemo(() => {
|
||
if (!selectedStatusMeta) {
|
||
return 'Team-Status noch nicht geladen.';
|
||
}
|
||
const parts = [
|
||
`${selectedStatusMeta.refreshed || 0} aktualisiert`,
|
||
`${selectedStatusMeta.fromCache || 0} aus Cache`
|
||
];
|
||
if (selectedStatusMeta.missing) {
|
||
parts.push(`${selectedStatusMeta.missing} ohne Daten`);
|
||
}
|
||
const timestamp = selectedStatusMeta.generatedAt
|
||
? new Date(selectedStatusMeta.generatedAt).toLocaleString('de-DE')
|
||
: null;
|
||
return `Team-Status: ${parts.join(', ')}${timestamp ? ` (Stand ${timestamp})` : ''}`;
|
||
}, [selectedStatusMeta]);
|
||
|
||
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 (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 statusValue = store.teamSearchStatus === null || store.teamSearchStatus === undefined
|
||
? null
|
||
: Number(store.teamSearchStatus);
|
||
const distance =
|
||
userLocation && Number.isFinite(lat) && Number.isFinite(lon)
|
||
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
|
||
: null;
|
||
const isOpen = statusValue === 1;
|
||
return {
|
||
...store,
|
||
membership,
|
||
distanceKm: distance,
|
||
teamStatusUpdatedAt: store.teamStatusUpdatedAt || null,
|
||
teamSearchStatus: statusValue,
|
||
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();
|
||
const label = value
|
||
? new Date(value).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||
: '–';
|
||
return <div className="text-center font-mono text-xs text-gray-600">{label}</div>;
|
||
},
|
||
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: ({ row, getValue }) => {
|
||
const value = getValue();
|
||
const updatedAt = row.original.teamStatusUpdatedAt
|
||
? new Date(row.original.teamStatusUpdatedAt).toLocaleDateString('de-DE')
|
||
: null;
|
||
if (value === null) {
|
||
return <span className="text-sm text-gray-500">–</span>;
|
||
}
|
||
return (
|
||
<div className="text-center">
|
||
<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>
|
||
{updatedAt && <p className="text-[10px] text-gray-500 mt-0.5">{updatedAt}</p>}
|
||
</div>
|
||
);
|
||
},
|
||
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]);
|
||
|
||
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, forceStatus } = {}) => {
|
||
if (!authorizedFetch || !regionId) {
|
||
return;
|
||
}
|
||
if (!force && !forceStatus && storesByRegion[String(regionId)]) {
|
||
return;
|
||
}
|
||
if (!silent) {
|
||
setStoresLoading(true);
|
||
}
|
||
setError('');
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (force) {
|
||
params.append('force', '1');
|
||
}
|
||
if (forceStatus) {
|
||
params.append('forceStatus', '1');
|
||
}
|
||
const qs = params.toString();
|
||
const endpoint = `/api/store-watch/regions/${regionId}/stores${qs ? `?${qs}` : ''}`;
|
||
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(),
|
||
statusMeta: data.statusMeta || null
|
||
}
|
||
}));
|
||
} catch (err) {
|
||
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
|
||
} finally {
|
||
if (!silent) {
|
||
setStoresLoading(false);
|
||
}
|
||
}
|
||
},
|
||
[authorizedFetch, storesByRegion]
|
||
);
|
||
|
||
const fetchAllRegions = useCallback(
|
||
async ({ force, forceStatus } = {}) => {
|
||
if (!authorizedFetch || regions.length === 0) {
|
||
return;
|
||
}
|
||
const targets = regions.filter(
|
||
(region) => force || forceStatus || !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, forceStatus });
|
||
}
|
||
} 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]);
|
||
|
||
const handleStatusRefresh = useCallback(() => {
|
||
if (selectedRegionId === 'all') {
|
||
fetchAllRegions({ force: true, forceStatus: true });
|
||
} else if (selectedRegionId) {
|
||
fetchStoresForRegion(selectedRegionId, { forceStatus: true });
|
||
}
|
||
}, [selectedRegionId, fetchAllRegions, fetchStoresForRegion]);
|
||
|
||
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 flex-wrap 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>
|
||
<button
|
||
type="button"
|
||
onClick={handleStatusRefresh}
|
||
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
|
||
disabled={regionLoading || (selectedRegionId === 'all' && regions.length === 0)}
|
||
>
|
||
Team-Status aktualisieren
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mt-2 text-xs text-gray-500">
|
||
<span>Es werden nur Regionen angezeigt, in denen du Mitglied mit Klassifikation 1 bist.</span>
|
||
<span>{statusSummary}</span>
|
||
</div>
|
||
</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>
|
||
))}
|
||
{table.getRowModel().rows.length === 0 && (
|
||
<tr>
|
||
<td
|
||
className="px-4 py-6 text-center text-sm text-gray-500"
|
||
colSpan={table.getHeaderGroups()?.[0]?.headers.length || 1}
|
||
>
|
||
Keine Betriebe entsprechen den aktuellen Filtern.
|
||
</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;
|