button zum prüfen
This commit is contained in:
20
server.js
20
server.js
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -794,7 +795,8 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
|
|||||||
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);
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
|
||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
|
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={handleReset}
|
onClick={() => togglePanelCollapsed(panelId)}
|
||||||
disabled={subscriptionsLoading}
|
aria-expanded={!collapsed}
|
||||||
>
|
>
|
||||||
Änderungen verwerfen
|
<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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-4 py-2 rounded-md text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60"
|
className="px-2 py-1 border rounded disabled:opacity-40"
|
||||||
onClick={handleSave}
|
onClick={() => movePanel(panelId, 'down')}
|
||||||
disabled={!dirty || saving}
|
disabled={!canMoveDown}
|
||||||
|
title="Nach unten"
|
||||||
>
|
>
|
||||||
{saving ? 'Speichere...' : 'Speichern'}
|
↓
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
<p className="text-xs text-gray-600 mt-2">
|
|
||||||
Letzter Status:{' '}
|
|
||||||
{entry.lastTeamSearchStatus === 1
|
|
||||||
? 'Suchend'
|
|
||||||
: entry.lastTeamSearchStatus === 0
|
|
||||||
? 'Nicht suchend'
|
|
||||||
: 'Unbekannt'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</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 && (
|
{dirty && (
|
||||||
<p className="text-xs text-amber-600">
|
<p className="text-xs text-amber-600">
|
||||||
|
|||||||
Reference in New Issue
Block a user