Files
Pickup-Config/src/components/StoreWatchPage.js
2025-11-10 18:27:09 +01:00

973 lines
33 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 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;