button zum prüfen
This commit is contained in:
36
server.js
36
server.js
@@ -7,7 +7,7 @@ const sessionStore = require('./services/sessionStore');
|
||||
const credentialStore = require('./services/credentialStore');
|
||||
const { readConfig, writeConfig } = require('./services/configStore');
|
||||
const foodsharingClient = require('./services/foodsharingClient');
|
||||
const { scheduleConfig } = require('./services/pickupScheduler');
|
||||
const { scheduleConfig, runStoreWatchCheck } = require('./services/pickupScheduler');
|
||||
const adminConfig = require('./services/adminConfig');
|
||||
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
|
||||
const notificationService = require('./services/notificationService');
|
||||
@@ -295,6 +295,7 @@ async function notifyWatchersForStatusChanges(changes = [], storeInfoMap = new M
|
||||
});
|
||||
}
|
||||
watcher.lastTeamSearchStatus = change.newStatus;
|
||||
watcher.lastStatusCheckAt = change.fetchedAt || Date.now();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -789,15 +790,16 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
|
||||
if (!storeId || !regionId) {
|
||||
return;
|
||||
}
|
||||
const entry = {
|
||||
storeId: String(storeId),
|
||||
storeName: store?.storeName || store?.name || `Store ${storeId}`,
|
||||
regionId: String(regionId),
|
||||
regionName: store?.regionName || store?.region?.name || '',
|
||||
lastTeamSearchStatus: previousMap.get(String(storeId))?.lastTeamSearchStatus ?? null
|
||||
};
|
||||
normalized.push(entry);
|
||||
});
|
||||
const entry = {
|
||||
storeId: String(storeId),
|
||||
storeName: store?.storeName || store?.name || `Store ${storeId}`,
|
||||
regionId: String(regionId),
|
||||
regionName: store?.regionName || store?.region?.name || '',
|
||||
lastTeamSearchStatus: previousMap.get(String(storeId))?.lastTeamSearchStatus ?? null,
|
||||
lastStatusCheckAt: previousMap.get(String(storeId))?.lastStatusCheckAt ?? null
|
||||
};
|
||||
normalized.push(entry);
|
||||
});
|
||||
|
||||
const persisted = writeStoreWatch(req.session.profile.id, normalized);
|
||||
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 });
|
||||
});
|
||||
|
||||
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) => {
|
||||
const preferences = readPreferences(req.session.profile.id);
|
||||
let location = preferences.location;
|
||||
|
||||
@@ -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 }) {
|
||||
const storeLink = storeId ? `https://foodsharing.de/store/${storeId}` : null;
|
||||
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 }) {
|
||||
if (!profileId) {
|
||||
return;
|
||||
@@ -165,6 +202,7 @@ async function sendTestNotification(profileId, channel) {
|
||||
module.exports = {
|
||||
sendSlotNotification,
|
||||
sendStoreWatchNotification,
|
||||
sendStoreWatchSummaryNotification,
|
||||
sendTestNotification,
|
||||
sendDesiredWindowMissedNotification
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
if (!session?.profile?.id) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
const watchers = readStoreWatch(session.profile.id);
|
||||
if (!Array.isArray(watchers) || watchers.length === 0) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
const ready = await ensureSession(session);
|
||||
if (!ready) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
const perRequestDelay = Math.max(0, Number(settings?.storeWatchRequestDelayMs) || 0);
|
||||
let changed = false;
|
||||
const summary = [];
|
||||
for (let index = 0; index < watchers.length; index += 1) {
|
||||
const watcher = watchers[index];
|
||||
try {
|
||||
const details = await foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader);
|
||||
const status = details?.teamSearchStatus === 1 ? 1 : 0;
|
||||
const checkedAt = Date.now();
|
||||
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
|
||||
await notificationService.sendStoreWatchNotification({
|
||||
profileId: session.profile.id,
|
||||
@@ -445,8 +447,25 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS) {
|
||||
watcher.lastTeamSearchStatus = status;
|
||||
changed = true;
|
||||
}
|
||||
watcher.lastStatusCheckAt = checkedAt;
|
||||
changed = true;
|
||||
summary.push({
|
||||
storeId: watcher.storeId,
|
||||
storeName: watcher.storeName,
|
||||
regionName: watcher.regionName,
|
||||
status,
|
||||
checkedAt
|
||||
});
|
||||
} catch (error) {
|
||||
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 {
|
||||
const hasNext = index < watchers.length - 1;
|
||||
if (hasNext && perRequestDelay > 0) {
|
||||
@@ -458,6 +477,18 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS) {
|
||||
if (changed) {
|
||||
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) {
|
||||
@@ -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 = {
|
||||
scheduleConfig
|
||||
scheduleConfig,
|
||||
runStoreWatchCheck
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ function sanitizeEntry(entry) {
|
||||
if (!entry || !entry.storeId) {
|
||||
return null;
|
||||
}
|
||||
const parsedLastCheck = Number(entry.lastStatusCheckAt);
|
||||
const normalized = {
|
||||
storeId: String(entry.storeId),
|
||||
storeName: entry.storeName ? String(entry.storeName).trim() : `Store ${entry.storeId}`,
|
||||
@@ -27,7 +28,8 @@ function sanitizeEntry(entry) {
|
||||
? 1
|
||||
: entry.lastTeamSearchStatus === 0
|
||||
? 0
|
||||
: null
|
||||
: null,
|
||||
lastStatusCheckAt: Number.isFinite(parsedLastCheck) ? parsedLastCheck : null
|
||||
};
|
||||
if (!normalized.regionId) {
|
||||
return null;
|
||||
|
||||
@@ -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