1369 lines
48 KiB
JavaScript
1369 lines
48 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';
|
||
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;
|