button zum prüfen
This commit is contained in:
@@ -13,6 +13,31 @@ 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();
|
||||
|
||||
@@ -21,6 +46,52 @@ const DEFAULT_TABLE_STATE = {
|
||||
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;
|
||||
@@ -120,9 +191,14 @@ const StoreWatchPage = ({
|
||||
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) => {
|
||||
@@ -173,6 +249,44 @@ const StoreWatchPage = ({
|
||||
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))),
|
||||
@@ -187,6 +301,7 @@ const StoreWatchPage = ({
|
||||
}, [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') {
|
||||
@@ -854,6 +969,201 @@ const StoreWatchPage = ({
|
||||
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 (
|
||||
@@ -985,121 +1295,66 @@ const StoreWatchPage = ({
|
||||
</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>
|
||||
{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>
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
Letzter Status:{' '}
|
||||
{entry.lastTeamSearchStatus === 1
|
||||
? 'Suchend'
|
||||
: entry.lastTeamSearchStatus === 0
|
||||
? 'Nicht suchend'
|
||||
: 'Unbekannt'}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{dirty && (
|
||||
<p className="text-xs text-amber-600">
|
||||
|
||||
Reference in New Issue
Block a user