button zum prüfen

This commit is contained in:
2025-11-17 21:37:22 +01:00
parent 8e308b0d99
commit cad72232d9
5 changed files with 477 additions and 129 deletions

View File

@@ -7,7 +7,7 @@ const sessionStore = require('./services/sessionStore');
const credentialStore = require('./services/credentialStore'); const credentialStore = require('./services/credentialStore');
const { readConfig, writeConfig } = require('./services/configStore'); const { readConfig, writeConfig } = require('./services/configStore');
const foodsharingClient = require('./services/foodsharingClient'); const foodsharingClient = require('./services/foodsharingClient');
const { scheduleConfig } = require('./services/pickupScheduler'); const { scheduleConfig, runStoreWatchCheck } = require('./services/pickupScheduler');
const adminConfig = require('./services/adminConfig'); const adminConfig = require('./services/adminConfig');
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore'); const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
const notificationService = require('./services/notificationService'); const notificationService = require('./services/notificationService');
@@ -295,6 +295,7 @@ async function notifyWatchersForStatusChanges(changes = [], storeInfoMap = new M
}); });
} }
watcher.lastTeamSearchStatus = change.newStatus; watcher.lastTeamSearchStatus = change.newStatus;
watcher.lastStatusCheckAt = change.fetchedAt || Date.now();
changed = true; changed = true;
} }
} }
@@ -789,15 +790,16 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
if (!storeId || !regionId) { if (!storeId || !regionId) {
return; return;
} }
const entry = { const entry = {
storeId: String(storeId), storeId: String(storeId),
storeName: store?.storeName || store?.name || `Store ${storeId}`, storeName: store?.storeName || store?.name || `Store ${storeId}`,
regionId: String(regionId), regionId: String(regionId),
regionName: store?.regionName || store?.region?.name || '', regionName: store?.regionName || store?.region?.name || '',
lastTeamSearchStatus: previousMap.get(String(storeId))?.lastTeamSearchStatus ?? null lastTeamSearchStatus: previousMap.get(String(storeId))?.lastTeamSearchStatus ?? null,
}; lastStatusCheckAt: previousMap.get(String(storeId))?.lastStatusCheckAt ?? null
normalized.push(entry); };
}); normalized.push(entry);
});
const persisted = writeStoreWatch(req.session.profile.id, normalized); const persisted = writeStoreWatch(req.session.profile.id, normalized);
const config = readConfig(req.session.profile.id); const config = readConfig(req.session.profile.id);
@@ -805,6 +807,20 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
res.json({ success: true, stores: persisted }); res.json({ success: true, stores: persisted });
}); });
app.post('/api/store-watch/check', requireAuth, async (req, res) => {
try {
const settings = adminConfig.readSettings();
const summary = await runStoreWatchCheck(req.session.id, settings, {
sendSummary: true,
triggeredBy: 'manual'
});
res.json({ success: true, stores: Array.isArray(summary) ? summary : [] });
} catch (error) {
console.error('[STORE-WATCH] Ad-hoc-Prüfung fehlgeschlagen:', error.message);
res.status(500).json({ error: 'Ad-hoc-Prüfung fehlgeschlagen' });
}
});
app.get('/api/user/preferences', requireAuth, async (req, res) => { app.get('/api/user/preferences', requireAuth, async (req, res) => {
const preferences = readPreferences(req.session.profile.id); const preferences = readPreferences(req.session.profile.id);
let location = preferences.location; let location = preferences.location;

View File

@@ -98,6 +98,16 @@ async function sendSlotNotification({ profileId, storeName, pickupDate, onlyNoti
}); });
} }
function formatStoreWatchStatus(status) {
if (status === 1) {
return 'Suchend';
}
if (status === 0) {
return 'Nicht suchend';
}
return 'Status unbekannt';
}
async function sendStoreWatchNotification({ profileId, storeName, storeId, regionName }) { async function sendStoreWatchNotification({ profileId, storeName, storeId, regionName }) {
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null; const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
const title = `Team sucht Verstärkung: ${storeName}`; const title = `Team sucht Verstärkung: ${storeName}`;
@@ -112,6 +122,33 @@ async function sendStoreWatchNotification({ profileId, storeName, storeId, regio
}); });
} }
async function sendStoreWatchSummaryNotification({ profileId, entries = [], triggeredBy = 'manual' }) {
if (!profileId || !Array.isArray(entries) || entries.length === 0) {
return;
}
const lines = entries
.map((entry) => {
const regionSuffix = entry.regionName ? ` (${entry.regionName})` : '';
const statusLabel = formatStoreWatchStatus(entry.status);
const timestamp = entry.checkedAt ? ` Stand ${formatDateLabel(entry.checkedAt)}` : '';
const errorLabel = entry.error ? ` Fehler: ${entry.error}` : '';
return `${entry.storeName || `Store ${entry.storeId}`}${regionSuffix}: ${statusLabel}${timestamp}${errorLabel}`;
})
.join('\n');
const prefix =
triggeredBy === 'manual'
? 'Manuell angestoßene Store-Watch-Prüfung abgeschlossen:'
: 'Store-Watch-Prüfung abgeschlossen:';
const title =
triggeredBy === 'manual' ? 'Ad-hoc Store-Watch-Prüfung' : 'Store-Watch-Prüfung';
const message = `${prefix}\n${lines}`;
await notifyChannels(profileId, {
title,
message,
priority: 'default'
});
}
async function sendDesiredWindowMissedNotification({ profileId, storeName, desiredWindowLabel }) { async function sendDesiredWindowMissedNotification({ profileId, storeName, desiredWindowLabel }) {
if (!profileId) { if (!profileId) {
return; return;
@@ -165,6 +202,7 @@ async function sendTestNotification(profileId, channel) {
module.exports = { module.exports = {
sendSlotNotification, sendSlotNotification,
sendStoreWatchNotification, sendStoreWatchNotification,
sendStoreWatchSummaryNotification,
sendTestNotification, sendTestNotification,
sendDesiredWindowMissedNotification sendDesiredWindowMissedNotification
}; };

View File

@@ -411,28 +411,30 @@ async function checkEntry(sessionId, entry, settings) {
} }
} }
async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS) { async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, options = {}) {
const session = sessionStore.get(sessionId); const session = sessionStore.get(sessionId);
if (!session?.profile?.id) { if (!session?.profile?.id) {
return; return [];
} }
const watchers = readStoreWatch(session.profile.id); const watchers = readStoreWatch(session.profile.id);
if (!Array.isArray(watchers) || watchers.length === 0) { if (!Array.isArray(watchers) || watchers.length === 0) {
return; return [];
} }
const ready = await ensureSession(session); const ready = await ensureSession(session);
if (!ready) { if (!ready) {
return; return [];
} }
const perRequestDelay = Math.max(0, Number(settings?.storeWatchRequestDelayMs) || 0); const perRequestDelay = Math.max(0, Number(settings?.storeWatchRequestDelayMs) || 0);
let changed = false; let changed = false;
const summary = [];
for (let index = 0; index < watchers.length; index += 1) { for (let index = 0; index < watchers.length; index += 1) {
const watcher = watchers[index]; const watcher = watchers[index];
try { try {
const details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader); const details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader);
const status = details?.teamSearchStatus === 1 ? 1 : 0; const status = details?.teamSearchStatus === 1 ? 1 : 0;
const checkedAt = Date.now();
if (status === 1 && watcher.lastTeamSearchStatus !== 1) { if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
await notificationService.sendStoreWatchNotification({ await notificationService.sendStoreWatchNotification({
profileId: session.profile.id, profileId: session.profile.id,
@@ -445,8 +447,25 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS) {
watcher.lastTeamSearchStatus = status; watcher.lastTeamSearchStatus = status;
changed = true; changed = true;
} }
watcher.lastStatusCheckAt = checkedAt;
changed = true;
summary.push({
storeId: watcher.storeId,
storeName: watcher.storeName,
regionName: watcher.regionName,
status,
checkedAt
});
} catch (error) { } catch (error) {
console.error(`[WATCH] Prüfung für Store ${watcher.storeId} fehlgeschlagen:`, error.message); console.error(`[WATCH] Prüfung für Store ${watcher.storeId} fehlgeschlagen:`, error.message);
summary.push({
storeId: watcher.storeId,
storeName: watcher.storeName,
regionName: watcher.regionName,
status: null,
checkedAt: Date.now(),
error: error.message || 'Unbekannter Fehler'
});
} finally { } finally {
const hasNext = index < watchers.length - 1; const hasNext = index < watchers.length - 1;
if (hasNext && perRequestDelay > 0) { if (hasNext && perRequestDelay > 0) {
@@ -458,6 +477,18 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS) {
if (changed) { if (changed) {
writeStoreWatch(session.profile.id, watchers); writeStoreWatch(session.profile.id, watchers);
} }
if (options.sendSummary && summary.length > 0) {
try {
await notificationService.sendStoreWatchSummaryNotification({
profileId: session.profile.id,
entries: summary,
triggeredBy: options.triggeredBy || 'manual'
});
} catch (error) {
console.error('[WATCH] Zusammenfassung konnte nicht versendet werden:', error.message);
}
}
return summary;
} }
function scheduleStoreWatchers(sessionId, settings) { function scheduleStoreWatchers(sessionId, settings) {
@@ -538,6 +569,12 @@ function scheduleConfig(sessionId, config, settings) {
); );
} }
async function runStoreWatchCheck(sessionId, settings, options = {}) {
const resolvedSettings = resolveSettings(settings);
return checkWatchedStores(sessionId, resolvedSettings, options);
}
module.exports = { module.exports = {
scheduleConfig scheduleConfig,
runStoreWatchCheck
}; };

View File

@@ -17,6 +17,7 @@ function sanitizeEntry(entry) {
if (!entry || !entry.storeId) { if (!entry || !entry.storeId) {
return null; return null;
} }
const parsedLastCheck = Number(entry.lastStatusCheckAt);
const normalized = { const normalized = {
storeId: String(entry.storeId), storeId: String(entry.storeId),
storeName: entry.storeName ? String(entry.storeName).trim() : `Store ${entry.storeId}`, storeName: entry.storeName ? String(entry.storeName).trim() : `Store ${entry.storeId}`,
@@ -27,7 +28,8 @@ function sanitizeEntry(entry) {
? 1 ? 1
: entry.lastTeamSearchStatus === 0 : entry.lastTeamSearchStatus === 0
? 0 ? 0
: null : null,
lastStatusCheckAt: Number.isFinite(parsedLastCheck) ? parsedLastCheck : null
}; };
if (!normalized.regionId) { if (!normalized.regionId) {
return null; return null;

View File

@@ -13,6 +13,31 @@ import NotificationPanel from './NotificationPanel';
const REGION_STORAGE_KEY = 'storeWatchRegionSelection'; const REGION_STORAGE_KEY = 'storeWatchRegionSelection';
const WATCH_TABLE_STATE_KEY = 'storeWatchTableState'; 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 columnHelper = createColumnHelper();
@@ -21,6 +46,52 @@ const DEFAULT_TABLE_STATE = {
columnFilters: [{ id: 'membership', value: '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 }) => { const ColumnTextFilter = ({ column, placeholder }) => {
if (!column.getCanFilter()) { if (!column.getCanFilter()) {
return null; return null;
@@ -120,9 +191,14 @@ const StoreWatchPage = ({
const [error, setError] = useState(''); const [error, setError] = useState('');
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [adhocChecking, setAdhocChecking] = useState(false);
const [lastAdhocCheck, setLastAdhocCheck] = useState(null);
const initialTableState = useMemo(() => readWatchTableState(), []); const initialTableState = useMemo(() => readWatchTableState(), []);
const [sorting, setSorting] = useState(initialTableState.sorting); const [sorting, setSorting] = useState(initialTableState.sorting);
const [columnFilters, setColumnFilters] = useState(initialTableState.columnFilters); 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 aggregatedRegionStores = useMemo(() => {
const list = []; const list = [];
Object.values(storesByRegion).forEach((entry) => { Object.values(storesByRegion).forEach((entry) => {
@@ -173,6 +249,44 @@ const StoreWatchPage = ({
useEffect(() => { useEffect(() => {
persistWatchTableState({ sorting, columnFilters }); persistWatchTableState({ sorting, columnFilters });
}, [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( const watchedIds = useMemo(
() => new Set(watchList.map((entry) => String(entry.storeId))), () => new Set(watchList.map((entry) => String(entry.storeId))),
@@ -187,6 +301,7 @@ const StoreWatchPage = ({
}, [regions, selectedRegionId]); }, [regions, selectedRegionId]);
const activeRegionLabel = selectedRegionId === 'all' ? 'allen Regionen' : selectedRegion?.name || 'dieser Region'; const activeRegionLabel = selectedRegionId === 'all' ? 'allen Regionen' : selectedRegion?.name || 'dieser Region';
const storesPanelTitle = selectedRegionId === 'all' ? 'Betriebe' : `Betriebe in ${activeRegionLabel}`;
const selectedStatusMeta = useMemo(() => { const selectedStatusMeta = useMemo(() => {
if (selectedRegionId === 'all') { if (selectedRegionId === 'all') {
@@ -854,6 +969,201 @@ const StoreWatchPage = ({
fetchStoresForRegion(selectedRegionId, { forceStatus: true }); fetchStoresForRegion(selectedRegionId, { forceStatus: true });
} }
}, [selectedRegionId, fetchAllRegions, fetchStoresForRegion]); }, [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) { if (!authorizedFetch) {
return ( return (
@@ -985,121 +1295,66 @@ const StoreWatchPage = ({
</div> </div>
</div> </div>
<div className="mb-6"> {panelOrder.map((panelId) => {
<div className="flex items-center justify-between mb-2"> const collapsed = Boolean(normalizedPanelLayout.collapsed?.[panelId]);
<h2 className="text-lg font-semibold text-gray-800">Betriebe in {activeRegionLabel}</h2> const title = panelTitles[panelId] || panelId;
{lastUpdatedAt && ( const panelIndex = panelOrder.indexOf(panelId);
<span className="text-xs text-gray-500"> const canMoveUp = panelIndex > 0;
Aktualisiert: {new Date(lastUpdatedAt).toLocaleTimeString('de-DE')} const canMoveDown = panelIndex < panelOrder.length - 1;
</span> const rightContent = renderPanelRightContent(panelId);
)} const showRightColumn = Boolean(rightContent) || (collapsed && panelOrder.length > 1);
</div> return (
{storesLoading && <p className="text-sm text-gray-600">Lade Betriebe...</p>} <section key={panelId} className="mb-6 border border-gray-200 rounded-lg overflow-hidden bg-white">
{!storesLoading && table.getRowModel().rows.length === 0 && ( <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">
<p className="text-sm text-gray-500"> <button
Keine Betriebe gefunden. Prüfe Filter oder sortiere anders. type="button"
</p> 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)}
{!storesLoading && table.getRowModel().rows.length > 0 && ( aria-expanded={!collapsed}
<div className="overflow-x-auto border border-gray-200 rounded-lg"> >
<table className="min-w-full divide-y divide-gray-200 text-sm"> <span className="text-sm text-gray-500">{collapsed ? '▶' : '▼'}</span>
<thead className="bg-gray-100"> <span className="text-lg font-semibold">{title}</span>
{table.getHeaderGroups().map((headerGroup) => ( </button>
<tr key={headerGroup.id}> {showRightColumn && (
{headerGroup.headers.map((header) => ( <div className="flex flex-wrap items-center gap-2 justify-end">
<th key={header.id} className="px-4 py-2 text-left align-top text-sm font-semibold"> {rightContent}
{flexRender(header.column.columnDef.header, header.getContext())} {collapsed && panelOrder.length > 1 && (
</th> <div className="flex items-center gap-1 text-xs text-gray-500">
))} <span className="hidden sm:inline">Reihenfolge:</span>
</tr> <div className="flex gap-1">
))} <button
</thead> type="button"
<tbody className="divide-y divide-gray-200"> className="px-2 py-1 border rounded disabled:opacity-40"
{table.getRowModel().rows.map((row) => ( onClick={() => movePanel(panelId, 'up')}
<tr key={row.id} className="bg-white"> disabled={!canMoveUp}
{row.getVisibleCells().map((cell) => ( title="Nach oben"
<td key={cell.id} className="px-4 py-2 align-middle"> >
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </button>
))} <button
</tr> type="button"
))} className="px-2 py-1 border rounded disabled:opacity-40"
{table.getRowModel().rows.length === 0 && ( onClick={() => movePanel(panelId, 'down')}
<tr> disabled={!canMoveDown}
<td title="Nach unten"
className="px-4 py-6 text-center text-sm text-gray-500" >
colSpan={table.getHeaderGroups()?.[0]?.headers.length || 1}
> </button>
Keine Betriebe entsprechen den aktuellen Filtern. </div>
</td> </div>
</tr> )}
)}
</tbody>
</table>
</div>
)}
</div>
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-800">
Überwachte Betriebe ({watchList.length})
</h2>
<div className="flex gap-2">
<button
type="button"
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
onClick={handleReset}
disabled={subscriptionsLoading}
>
Änderungen verwerfen
</button>
<button
type="button"
className="px-4 py-2 rounded-md text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60"
onClick={handleSave}
disabled={!dirty || saving}
>
{saving ? 'Speichere...' : 'Speichern'}
</button>
</div>
</div>
{subscriptionsLoading && <p className="text-sm text-gray-600">Lade aktuelle Auswahl...</p>}
{!subscriptionsLoading && watchList.length === 0 && (
<p className="text-sm text-gray-500">Noch keine Betriebe ausgewählt.</p>
)}
{watchList.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{watchList.map((entry) => (
<div key={entry.storeId} className="border border-gray-200 rounded-lg p-3 bg-gray-50">
<div className="flex justify-between items-start">
<div>
<p className="font-semibold text-gray-900">{entry.storeName}</p>
<p className="text-xs text-gray-500">
#{entry.storeId} {entry.regionName || 'Region unbekannt'}
</p>
</div>
<button
type="button"
className="text-xs text-red-600 hover:underline"
onClick={() => handleRemoveWatch(entry.storeId)}
>
Entfernen
</button>
</div> </div>
<p className="text-xs text-gray-600 mt-2"> )}
Letzter Status:{' '} </div>
{entry.lastTeamSearchStatus === 1 {!collapsed && <div className="p-4">{renderPanelContent(panelId)}</div>}
? 'Suchend' {collapsed && (
: entry.lastTeamSearchStatus === 0 <div className="px-4 py-3 text-xs text-gray-500">
? 'Nicht suchend' Bereich ist eingeklappt. Über die Pfeile kann die Reihenfolge angepasst werden.
: 'Unbekannt'}
</p>
</div> </div>
))} )}
</div> </section>
)} );
</div> })}
{dirty && ( {dirty && (
<p className="text-xs text-amber-600"> <p className="text-xs text-amber-600">