Files
Pickup-Config/src/components/StoreWatchPage.js
2025-11-17 21:37:22 +01:00

1369 lines
48 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';
import { inferLocationLabel } from '../utils/locationLabel';
import NotificationPanel from './NotificationPanel';
const REGION_STORAGE_KEY = 'storeWatchRegionSelection';
const WATCH_TABLE_STATE_KEY = 'storeWatchTableState';
const PANEL_STORAGE_KEY = 'storeWatchPanels';
const PANEL_IDS = ['stores', 'watch'];
function createDefaultPanelState() {
return {
order: [...PANEL_IDS],
collapsed: PANEL_IDS.reduce(
(acc, panelId) => ({
...acc,
[panelId]: false
}),
{}
)
};
}
function formatWatchStatusLabel(status) {
if (status === 1) {
return 'Suchend';
}
if (status === 0) {
return 'Nicht suchend';
}
return 'Unbekannt';
}
const columnHelper = createColumnHelper();
const DEFAULT_TABLE_STATE = {
sorting: [{ id: 'distanceKm', desc: false }],
columnFilters: [{ id: 'membership', value: 'false' }]
};
function normalizePanelLayout(state) {
const fallback = createDefaultPanelState();
if (!state || typeof state !== 'object') {
return fallback;
}
const rawOrder = Array.isArray(state.order) ? state.order : [];
const normalizedOrder = rawOrder.filter((panelId) => PANEL_IDS.includes(panelId));
const dedupedOrder = [...new Set([...normalizedOrder, ...PANEL_IDS])];
const collapsed = { ...fallback.collapsed };
PANEL_IDS.forEach((panelId) => {
collapsed[panelId] = Boolean(state?.collapsed?.[panelId]);
});
return {
order: dedupedOrder,
collapsed
};
}
function readPanelLayout() {
if (typeof window === 'undefined') {
return createDefaultPanelState();
}
try {
const raw = window.localStorage.getItem(PANEL_STORAGE_KEY);
if (!raw) {
return createDefaultPanelState();
}
const parsed = JSON.parse(raw);
return normalizePanelLayout(parsed);
} catch {
return createDefaultPanelState();
}
}
function persistPanelLayout(state) {
if (typeof window === 'undefined') {
return;
}
try {
const normalized = normalizePanelLayout(state);
window.localStorage.setItem(PANEL_STORAGE_KEY, JSON.stringify(normalized));
} catch {
/* ignore */
}
}
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 DEFAULT_TABLE_STATE;
}
try {
const raw = window.localStorage.getItem(WATCH_TABLE_STATE_KEY);
if (!raw) {
return DEFAULT_TABLE_STATE;
}
const parsed = JSON.parse(raw);
return {
sorting: Array.isArray(parsed.sorting) && parsed.sorting.length > 0 ? parsed.sorting : DEFAULT_TABLE_STATE.sorting,
columnFilters:
Array.isArray(parsed.columnFilters) && parsed.columnFilters.length > 0
? parsed.columnFilters
: DEFAULT_TABLE_STATE.columnFilters
};
} catch {
return DEFAULT_TABLE_STATE;
}
}
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,
locationLoading = false,
locationError = '',
notificationPanelOpen = false,
onToggleNotificationPanel = () => {},
notificationProps,
isAdmin = false
}) => {
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 [adhocChecking, setAdhocChecking] = useState(false);
const [lastAdhocCheck, setLastAdhocCheck] = useState(null);
const initialTableState = useMemo(() => readWatchTableState(), []);
const [sorting, setSorting] = useState(initialTableState.sorting);
const [columnFilters, setColumnFilters] = useState(initialTableState.columnFilters);
const [panelLayout, setPanelLayout] = useState(() => readPanelLayout());
const normalizedPanelLayout = useMemo(() => normalizePanelLayout(panelLayout), [panelLayout]);
const panelOrder = normalizedPanelLayout.order;
const aggregatedRegionStores = useMemo(() => {
const list = [];
Object.values(storesByRegion).forEach((entry) => {
if (Array.isArray(entry?.stores)) {
entry.stores.forEach((store) => {
if (store) {
list.push(store);
}
});
}
});
return list;
}, [storesByRegion]);
const proximityLabel = useMemo(() => {
if (!userLocation) {
return null;
}
return inferLocationLabel(userLocation, aggregatedRegionStores);
}, [userLocation, aggregatedRegionStores]);
const displayLocation = useMemo(() => {
if (!userLocation) {
return null;
}
if (userLocation.label) {
return userLocation;
}
if (proximityLabel) {
return {
...userLocation,
label: proximityLabel.label,
labelDistanceKm: proximityLabel.distanceKm
};
}
return userLocation;
}, [userLocation, proximityLabel]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(REGION_STORAGE_KEY, selectedRegionId || 'all');
} catch {
/* ignore */
}
}, [selectedRegionId]);
useEffect(() => {
persistWatchTableState({ sorting, columnFilters });
}, [sorting, columnFilters]);
useEffect(() => {
persistPanelLayout(panelLayout);
}, [panelLayout]);
const togglePanelCollapsed = useCallback((panelId) => {
if (!PANEL_IDS.includes(panelId)) {
return;
}
setPanelLayout((prev) => {
const normalized = normalizePanelLayout(prev);
return {
...normalized,
collapsed: {
...normalized.collapsed,
[panelId]: !normalized.collapsed[panelId]
}
};
});
}, []);
const movePanel = useCallback((panelId, direction) => {
if (!PANEL_IDS.includes(panelId) || !direction) {
return;
}
setPanelLayout((prev) => {
const normalized = normalizePanelLayout(prev);
const order = [...normalized.order];
const currentIndex = order.indexOf(panelId);
const delta = direction === 'up' ? -1 : 1;
const nextIndex = currentIndex + delta;
if (currentIndex === -1 || nextIndex < 0 || nextIndex >= order.length) {
return normalized;
}
[order[currentIndex], order[nextIndex]] = [order[nextIndex], order[currentIndex]];
return {
...normalized,
order
};
});
}, []);
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 storesPanelTitle = selectedRegionId === 'all' ? 'Betriebe' : `Betriebe in ${activeRegionLabel}`;
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 (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 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 className="flex items-start justify-between gap-3">
<div>
<p className="font-medium text-gray-900 flex items-center gap-2">
{row.original.name}
</p>
<p className="text-xs text-gray-500">#{row.original.id}</p>
</div>
<a
href={`https://foodsharing.de/store/${row.original.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-blue-600"
title="Store öffnen"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<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>
</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 className="flex items-start justify-between gap-2">
<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>
{(() => {
const hasUserLocation =
userLocation &&
Number.isFinite(userLocation.lat) &&
Number.isFinite(userLocation.lon);
const storeLat = Number(row.original.location?.lat);
const storeLon = Number(row.original.location?.lon);
const hasStoreLocation = Number.isFinite(storeLat) && Number.isFinite(storeLon);
if (!hasUserLocation || !hasStoreLocation) {
return null;
}
const origin = encodeURIComponent(`${userLocation.lat},${userLocation.lon}`);
const storeLabel = row.original.name || row.original.city || `Store ${row.original.id}`;
const destination = encodeURIComponent(`${storeLabel}@${storeLat},${storeLon}`);
const mapsUrl = `https://www.google.com/maps/dir/?api=1&origin=${origin}&destination=${destination}&travelmode=driving`;
return (
<a
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:text-blue-600"
title="Route zu diesem Betrieb in Google Maps öffnen"
>
<span className="sr-only">Route in Google Maps</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
<path d="M12 2a6 6 0 00-6 6c0 4.2 6 12 6 12s6-7.8 6-12a6 6 0 00-6-6zm0 8a2 2 0 110-4 2 2 0 010 4z" />
<path d="M5 20.5a1 1 0 001.447.894l4.946-2.355a1 1 0 01.894 0l4.946 2.355A1 1 0 0019.68 20l-5.078-10.156" stroke="currentColor" strokeWidth="1.2" fill="none"/>
</svg>
</a>
);
})()}
</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 text-xs text-gray-700 tracking-wide">{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));
const disabled = store.isOpen;
return (
<div className="text-center">
<input
type="checkbox"
className="h-5 w-5"
checked={checked}
onChange={(event) => handleToggleStore(store, event.target.checked)}
disabled={disabled}
title={
disabled ? 'Store ist bereits offen Überwachung nicht verfügbar.' : undefined
}
/>
</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]);
const handleAdhocWatchCheck = useCallback(async () => {
if (!authorizedFetch || adhocChecking || watchList.length === 0) {
return;
}
setAdhocChecking(true);
setError('');
try {
const response = await authorizedFetch('/api/store-watch/check', { method: 'POST' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const summary = Array.isArray(data.stores) ? data.stores : [];
setLastAdhocCheck({
checkedAt: Date.now(),
stores: summary
});
setStatus('Ad-hoc-Prüfung abgeschlossen. Zusammenfassung versendet.');
setTimeout(() => setStatus(''), 4000);
await loadSubscriptions();
} catch (err) {
setError(`Ad-hoc-Prüfung fehlgeschlagen: ${err.message}`);
} finally {
setAdhocChecking(false);
}
}, [authorizedFetch, adhocChecking, watchList.length, loadSubscriptions]);
const panelTitles = {
stores: storesPanelTitle,
watch: `Überwachte Betriebe (${watchList.length})`
};
const renderPanelRightContent = (panelId) => {
if (panelId === 'stores') {
if (!lastUpdatedAt) {
return null;
}
return (
<span className="text-xs text-gray-500">
Aktualisiert: {new Date(lastUpdatedAt).toLocaleTimeString('de-DE')}
</span>
);
}
if (panelId === 'watch') {
return (
<div className="flex flex-wrap gap-2">
<button
type="button"
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100 disabled:opacity-60"
onClick={handleAdhocWatchCheck}
disabled={adhocChecking || subscriptionsLoading || watchList.length === 0}
>
{adhocChecking ? 'Prüfe...' : 'Ad-hoc prüfen'}
</button>
<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>
);
}
return null;
};
const renderPanelContent = (panelId) => {
if (panelId === 'stores') {
return (
<>
{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>
)}
</>
);
}
if (panelId === 'watch') {
return (
<>
{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:{' '}
{formatWatchStatusLabel(entry.lastTeamSearchStatus)}{' '}
{entry.lastStatusCheckAt
? `(geprüft am ${new Date(entry.lastStatusCheckAt).toLocaleString('de-DE')})`
: '(noch nicht geprüft)'}
</p>
</div>
))}
</div>
)}
{lastAdhocCheck?.stores?.length > 0 && (
<div className="mt-4 rounded-lg border border-blue-100 bg-blue-50 p-3 text-sm text-blue-900">
<p className="text-xs font-semibold text-blue-800">
Letzte Ad-hoc-Prüfung:{' '}
{new Date(lastAdhocCheck.checkedAt).toLocaleString('de-DE')}
</p>
<ul className="mt-2 space-y-1 text-xs">
{lastAdhocCheck.stores.map((entry, index) => {
const statusLabel = formatWatchStatusLabel(entry.status);
const timestamp = entry.checkedAt
? new Date(entry.checkedAt).toLocaleString('de-DE')
: null;
return (
<li key={`${entry.storeId}-${index}`}>
<span className="font-semibold">
{entry.storeName || `#${entry.storeId}`}
</span>
<span className="text-blue-800">
{entry.regionName ? ` (${entry.regionName})` : ''}
</span>
: {statusLabel}
{timestamp ? `${timestamp}` : ''}
{entry.error ? ` — Fehler: ${entry.error}` : ''}
</li>
);
})}
</ul>
</div>
)}
</>
);
}
return null;
};
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>
<div className="mb-4 flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={onToggleNotificationPanel}
className={`flex items-center justify-center w-11 h-11 rounded-full border ${
notificationPanelOpen ? 'border-blue-500 text-blue-600' : 'border-gray-300 text-gray-600'
} hover:text-blue-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 transition`}
title="Benachrichtigungen konfigurieren"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.89 3.31.877 2.42 2.42a1.724 1.724 0 0 0 1.065 2.572c1.757.426 1.757 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.89 1.543-.877 3.31-2.42 2.42a1.724 1.724 0 0 0-2.572 1.065c-.426 1.757-2.924 1.757-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.89-3.31-.877-2.42-2.42a1.724 1.724 0 0 0-1.065-2.572c-1.757-.426-1.757-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.89-1.543.877-3.31 2.42-2.42.996.575 2.273.155 2.573-1.065z"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
</svg>
</button>
{notificationProps?.loading && <span className="text-sm text-gray-500">Lade</span>}
</div>
{notificationPanelOpen && notificationProps && (
<NotificationPanel
{...notificationProps}
location={displayLocation}
locationLoading={locationLoading}
locationError={locationError}
/>
)}
{(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>
)}
{!userLocation && !locationLoading && (
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
<p>
Standortdaten für die Entfernungssortierung stammen automatisch aus deinem Foodsharing-Profil. Ergänze
dort deine Koordinaten, falls sie fehlen.
</p>
</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>
{isAdmin && (
<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>
{panelOrder.map((panelId) => {
const collapsed = Boolean(normalizedPanelLayout.collapsed?.[panelId]);
const title = panelTitles[panelId] || panelId;
const panelIndex = panelOrder.indexOf(panelId);
const canMoveUp = panelIndex > 0;
const canMoveDown = panelIndex < panelOrder.length - 1;
const rightContent = renderPanelRightContent(panelId);
const showRightColumn = Boolean(rightContent) || (collapsed && panelOrder.length > 1);
return (
<section key={panelId} className="mb-6 border border-gray-200 rounded-lg overflow-hidden bg-white">
<div className="flex flex-col gap-2 border-b border-gray-200 bg-gray-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
className="flex items-center gap-2 text-left text-gray-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded"
onClick={() => togglePanelCollapsed(panelId)}
aria-expanded={!collapsed}
>
<span className="text-sm text-gray-500">{collapsed ? '▶' : '▼'}</span>
<span className="text-lg font-semibold">{title}</span>
</button>
{showRightColumn && (
<div className="flex flex-wrap items-center gap-2 justify-end">
{rightContent}
{collapsed && panelOrder.length > 1 && (
<div className="flex items-center gap-1 text-xs text-gray-500">
<span className="hidden sm:inline">Reihenfolge:</span>
<div className="flex gap-1">
<button
type="button"
className="px-2 py-1 border rounded disabled:opacity-40"
onClick={() => movePanel(panelId, 'up')}
disabled={!canMoveUp}
title="Nach oben"
>
</button>
<button
type="button"
className="px-2 py-1 border rounded disabled:opacity-40"
onClick={() => movePanel(panelId, 'down')}
disabled={!canMoveDown}
title="Nach unten"
>
</button>
</div>
</div>
)}
</div>
)}
</div>
{!collapsed && <div className="p-4">{renderPanelContent(panelId)}</div>}
{collapsed && (
<div className="px-4 py-3 text-xs text-gray-500">
Bereich ist eingeklappt. Über die Pfeile kann die Reihenfolge angepasst werden.
</div>
)}
</section>
);
})}
{dirty && (
<p className="text-xs text-amber-600">
Es gibt ungespeicherte Änderungen. Bitte "Speichern" klicken.
</p>
)}
</div>
);
};
export default StoreWatchPage;