aktueller stand
This commit is contained in:
@@ -14,11 +14,13 @@ const DEFAULT_SETTINGS = {
|
|||||||
storeWatchInitialDelayMinSeconds: 10,
|
storeWatchInitialDelayMinSeconds: 10,
|
||||||
storeWatchInitialDelayMaxSeconds: 60,
|
storeWatchInitialDelayMaxSeconds: 60,
|
||||||
storeWatchRequestDelayMs: 1000,
|
storeWatchRequestDelayMs: 1000,
|
||||||
|
storeWatchStatusCacheMaxAgeMinutes: 120,
|
||||||
storePickupCheckDelayMs: 400,
|
storePickupCheckDelayMs: 400,
|
||||||
ignoredSlots: [
|
ignoredSlots: [
|
||||||
{
|
{
|
||||||
storeId: '51450',
|
storeId: '51450',
|
||||||
description: 'TVS'
|
slotName: 'TVS',
|
||||||
|
info: ''
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
notifications: {
|
notifications: {
|
||||||
@@ -56,11 +58,15 @@ function sanitizeIgnoredSlots(slots = []) {
|
|||||||
return DEFAULT_SETTINGS.ignoredSlots;
|
return DEFAULT_SETTINGS.ignoredSlots;
|
||||||
}
|
}
|
||||||
return slots
|
return slots
|
||||||
.map((slot) => ({
|
.map((slot) => {
|
||||||
|
const slotName = slot?.slotName ?? slot?.description;
|
||||||
|
return {
|
||||||
storeId: slot?.storeId ? String(slot.storeId) : '',
|
storeId: slot?.storeId ? String(slot.storeId) : '',
|
||||||
description: slot?.description ? String(slot.description) : ''
|
slotName: slotName ? String(slotName) : '',
|
||||||
}))
|
info: slot?.info ? String(slot.info) : ''
|
||||||
.filter((slot) => slot.storeId);
|
};
|
||||||
|
})
|
||||||
|
.filter((slot) => slot.storeId && slot.slotName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeString(value) {
|
function sanitizeString(value) {
|
||||||
@@ -120,6 +126,10 @@ function readSettings() {
|
|||||||
parsed.storeWatchRequestDelayMs,
|
parsed.storeWatchRequestDelayMs,
|
||||||
DEFAULT_SETTINGS.storeWatchRequestDelayMs
|
DEFAULT_SETTINGS.storeWatchRequestDelayMs
|
||||||
),
|
),
|
||||||
|
storeWatchStatusCacheMaxAgeMinutes: sanitizeNumber(
|
||||||
|
parsed.storeWatchStatusCacheMaxAgeMinutes,
|
||||||
|
DEFAULT_SETTINGS.storeWatchStatusCacheMaxAgeMinutes
|
||||||
|
),
|
||||||
storePickupCheckDelayMs: sanitizeNumber(
|
storePickupCheckDelayMs: sanitizeNumber(
|
||||||
parsed.storePickupCheckDelayMs,
|
parsed.storePickupCheckDelayMs,
|
||||||
DEFAULT_SETTINGS.storePickupCheckDelayMs
|
DEFAULT_SETTINGS.storePickupCheckDelayMs
|
||||||
@@ -154,6 +164,10 @@ function writeSettings(patch = {}) {
|
|||||||
patch.storeWatchRequestDelayMs,
|
patch.storeWatchRequestDelayMs,
|
||||||
current.storeWatchRequestDelayMs
|
current.storeWatchRequestDelayMs
|
||||||
),
|
),
|
||||||
|
storeWatchStatusCacheMaxAgeMinutes: sanitizeNumber(
|
||||||
|
patch.storeWatchStatusCacheMaxAgeMinutes,
|
||||||
|
current.storeWatchStatusCacheMaxAgeMinutes
|
||||||
|
),
|
||||||
storePickupCheckDelayMs: sanitizeNumber(
|
storePickupCheckDelayMs: sanitizeNumber(
|
||||||
patch.storePickupCheckDelayMs,
|
patch.storePickupCheckDelayMs,
|
||||||
current.storePickupCheckDelayMs
|
current.storePickupCheckDelayMs
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const notificationService = require('./notificationService');
|
|||||||
const { readConfig, writeConfig } = require('./configStore');
|
const { readConfig, writeConfig } = require('./configStore');
|
||||||
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
|
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
|
||||||
const { readJournal, writeJournal } = require('./journalStore');
|
const { readJournal, writeJournal } = require('./journalStore');
|
||||||
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
const { getStoreStatus, setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
||||||
const { sendDormantPickupWarning, sendJournalReminderNotification } = require('./notificationService');
|
const { sendDormantPickupWarning, sendJournalReminderNotification } = require('./notificationService');
|
||||||
const { ensureSession, withSessionRetry } = require('./sessionRefresh');
|
const { ensureSession, withSessionRetry } = require('./sessionRefresh');
|
||||||
|
|
||||||
@@ -17,6 +17,53 @@ function wait(ms) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES = 120;
|
||||||
|
const storeWatchInFlight = new Map();
|
||||||
|
|
||||||
|
async function fetchSharedStoreStatus(session, storeId, { forceRefresh = false, maxAgeMs } = {}) {
|
||||||
|
if (!storeId) {
|
||||||
|
return { status: null, fetchedAt: null, fromCache: false };
|
||||||
|
}
|
||||||
|
const cacheEntry = getStoreStatus(storeId);
|
||||||
|
const cachedStatus = cacheEntry?.teamSearchStatus;
|
||||||
|
const hasCachedStatus = cachedStatus === 0 || cachedStatus === 1;
|
||||||
|
const cachedAt = Number(cacheEntry?.fetchedAt) || 0;
|
||||||
|
const effectiveMaxAge =
|
||||||
|
Number.isFinite(maxAgeMs) && maxAgeMs >= 0
|
||||||
|
? maxAgeMs
|
||||||
|
: DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES * 60 * 1000;
|
||||||
|
const cacheFresh = hasCachedStatus && Date.now() - cachedAt <= effectiveMaxAge;
|
||||||
|
if (cacheFresh && !forceRefresh) {
|
||||||
|
return { status: cachedStatus, fetchedAt: cachedAt, fromCache: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = String(storeId);
|
||||||
|
if (!forceRefresh && storeWatchInFlight.has(key)) {
|
||||||
|
return storeWatchInFlight.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPromise = (async () => {
|
||||||
|
const details = await withSessionRetry(
|
||||||
|
session,
|
||||||
|
() => foodsharingClient.fetchStoreDetails(storeId, session.cookieHeader, session),
|
||||||
|
{ label: 'fetchStoreDetails' }
|
||||||
|
);
|
||||||
|
const status = details?.teamSearchStatus === 1 ? 1 : 0;
|
||||||
|
const fetchedAt = Date.now();
|
||||||
|
setStoreStatus(storeId, { teamSearchStatus: status, fetchedAt });
|
||||||
|
return { status, fetchedAt, fromCache: false };
|
||||||
|
})();
|
||||||
|
|
||||||
|
storeWatchInFlight.set(key, fetchPromise);
|
||||||
|
try {
|
||||||
|
return await fetchPromise;
|
||||||
|
} finally {
|
||||||
|
if (storeWatchInFlight.get(key) === fetchPromise) {
|
||||||
|
storeWatchInFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const weekdayMap = {
|
const weekdayMap = {
|
||||||
Montag: 'Monday',
|
Montag: 'Monday',
|
||||||
Dienstag: 'Tuesday',
|
Dienstag: 'Tuesday',
|
||||||
@@ -103,6 +150,9 @@ function resolveSettings(settings) {
|
|||||||
storeWatchRequestDelayMs: Number.isFinite(settings.storeWatchRequestDelayMs)
|
storeWatchRequestDelayMs: Number.isFinite(settings.storeWatchRequestDelayMs)
|
||||||
? settings.storeWatchRequestDelayMs
|
? settings.storeWatchRequestDelayMs
|
||||||
: DEFAULT_SETTINGS.storeWatchRequestDelayMs,
|
: DEFAULT_SETTINGS.storeWatchRequestDelayMs,
|
||||||
|
storeWatchStatusCacheMaxAgeMinutes: Number.isFinite(settings.storeWatchStatusCacheMaxAgeMinutes)
|
||||||
|
? settings.storeWatchStatusCacheMaxAgeMinutes
|
||||||
|
: DEFAULT_SETTINGS.storeWatchStatusCacheMaxAgeMinutes,
|
||||||
ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots,
|
ignoredSlots: Array.isArray(settings.ignoredSlots) ? settings.ignoredSlots : DEFAULT_SETTINGS.ignoredSlots,
|
||||||
notifications: {
|
notifications: {
|
||||||
ntfy: {
|
ntfy: {
|
||||||
@@ -305,10 +355,8 @@ function shouldIgnoreSlot(entry, pickup, settings) {
|
|||||||
if (String(rule.storeId) !== entry.id) {
|
if (String(rule.storeId) !== entry.id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (rule.description) {
|
const slotName = rule.slotName || rule.description;
|
||||||
return pickup.description === rule.description;
|
return slotName ? pickup.description === slotName : true;
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,12 +517,13 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
|
|||||||
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 withSessionRetry(
|
const cacheMaxAgeMs = Math.max(
|
||||||
session,
|
0,
|
||||||
() => foodsharingClient.fetchStoreDetails(watcher.storeId, session.cookieHeader, session),
|
Number(settings?.storeWatchStatusCacheMaxAgeMinutes ?? DEFAULT_STORE_WATCH_STATUS_MAX_AGE_MINUTES)
|
||||||
{ label: 'fetchStoreDetails' }
|
) * 60 * 1000;
|
||||||
);
|
const { status, fromCache } = await fetchSharedStoreStatus(session, watcher.storeId, {
|
||||||
const status = details?.teamSearchStatus === 1 ? 1 : 0;
|
maxAgeMs: cacheMaxAgeMs
|
||||||
|
});
|
||||||
const checkedAt = Date.now();
|
const checkedAt = Date.now();
|
||||||
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
|
if (status === 1 && watcher.lastTeamSearchStatus !== 1) {
|
||||||
await notificationService.sendStoreWatchNotification({
|
await notificationService.sendStoreWatchNotification({
|
||||||
@@ -490,8 +539,9 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
|
|||||||
}
|
}
|
||||||
watcher.lastStatusCheckAt = checkedAt;
|
watcher.lastStatusCheckAt = checkedAt;
|
||||||
changed = true;
|
changed = true;
|
||||||
setStoreStatus(watcher.storeId, { teamSearchStatus: status, fetchedAt: checkedAt });
|
if (!fromCache) {
|
||||||
statusCacheUpdated = true;
|
statusCacheUpdated = true;
|
||||||
|
}
|
||||||
summary.push({
|
summary.push({
|
||||||
storeId: watcher.storeId,
|
storeId: watcher.storeId,
|
||||||
storeName: watcher.storeName,
|
storeName: watcher.storeName,
|
||||||
|
|||||||
@@ -784,7 +784,7 @@ function App() {
|
|||||||
<Router>
|
<Router>
|
||||||
<>
|
<>
|
||||||
<div className="min-h-screen bg-gray-100 py-6">
|
<div className="min-h-screen bg-gray-100 py-6">
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
<NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} />
|
<NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={dashboardContent} />
|
<Route path="/" element={dashboardContent} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const AdminAccessMessage = () => (
|
const AdminAccessMessage = () => (
|
||||||
<div className="p-6 max-w-lg mx-auto bg-white shadow rounded-lg mt-8 text-center">
|
<div className="p-6 max-w-6xl w-full mx-auto bg-white shadow rounded-lg mt-8 text-center">
|
||||||
<h1 className="text-2xl font-semibold text-gray-800 mb-2">Kein Zugriff</h1>
|
<h1 className="text-2xl font-semibold text-gray-800 mb-2">Kein Zugriff</h1>
|
||||||
<p className="text-gray-600 mb-4">Dieser Bereich ist nur für Administratoren verfügbar.</p>
|
<p className="text-gray-600 mb-4">Dieser Bereich ist nur für Administratoren verfügbar.</p>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const AdminSettingsPanel = ({
|
|||||||
onSave
|
onSave
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-4xl mx-auto bg-white shadow-lg rounded-lg mt-4">
|
<div className="p-4 max-w-6xl w-full mx-auto bg-white shadow-lg rounded-lg mt-4">
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-purple-900">Admin-Einstellungen</h1>
|
<h1 className="text-2xl font-bold text-purple-900">Admin-Einstellungen</h1>
|
||||||
@@ -224,6 +224,22 @@ const AdminSettingsPanel = ({
|
|||||||
placeholder="z. B. 1000"
|
placeholder="z. B. 1000"
|
||||||
/>
|
/>
|
||||||
</SettingField>
|
</SettingField>
|
||||||
|
|
||||||
|
<SettingField
|
||||||
|
label="Store-Watch Status-Cache (Minuten)"
|
||||||
|
description="Wie lange Ergebnisse pro Store wiederverwendet werden, bevor erneut abgefragt wird."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={adminSettings.storeWatchStatusCacheMaxAgeMinutes}
|
||||||
|
onChange={(event) =>
|
||||||
|
onSettingChange('storeWatchStatusCacheMaxAgeMinutes', event.target.value, true)
|
||||||
|
}
|
||||||
|
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
|
placeholder="z. B. 120"
|
||||||
|
/>
|
||||||
|
</SettingField>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<SettingSection
|
<SettingSection
|
||||||
@@ -233,23 +249,50 @@ const AdminSettingsPanel = ({
|
|||||||
{(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && (
|
{(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && (
|
||||||
<p className="text-sm text-gray-500">Keine Regeln definiert.</p>
|
<p className="text-sm text-gray-500">Keine Regeln definiert.</p>
|
||||||
)}
|
)}
|
||||||
|
{adminSettings.ignoredSlots?.length > 0 && (
|
||||||
|
<div className="hidden md:grid md:grid-cols-6 gap-2 text-xs font-semibold text-gray-500 px-1">
|
||||||
|
<span>Store-ID</span>
|
||||||
|
<span className="md:col-span-2">Slotname</span>
|
||||||
|
<span className="md:col-span-2">Info</span>
|
||||||
|
<span>Aktion</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{adminSettings.ignoredSlots?.map((slot, index) => (
|
{adminSettings.ignoredSlots?.map((slot, index) => (
|
||||||
|
(() => {
|
||||||
|
const storeIdMissing = !slot.storeId;
|
||||||
|
const slotNameMissing = !slot.slotName;
|
||||||
|
const showErrors = storeIdMissing || slotNameMissing;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${index}-${slot.storeId}`}
|
key={`${index}-${slot.storeId}`}
|
||||||
className="grid grid-cols-1 md:grid-cols-5 gap-2 items-center border border-purple-100 rounded p-3"
|
className={`grid grid-cols-1 md:grid-cols-6 gap-2 items-center rounded p-3 ${
|
||||||
|
showErrors ? 'border border-red-200 bg-red-50/30' : 'border border-purple-100'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={slot.storeId}
|
value={slot.storeId}
|
||||||
onChange={(event) => onIgnoredSlotChange(index, 'storeId', event.target.value)}
|
onChange={(event) => onIgnoredSlotChange(index, 'storeId', event.target.value)}
|
||||||
placeholder="Store-ID"
|
placeholder="Store-ID"
|
||||||
className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
className={`border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 ${
|
||||||
|
storeIdMissing ? 'border-red-400 focus:ring-red-500 focus:border-red-500' : ''
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={slot.description}
|
value={slot.slotName}
|
||||||
onChange={(event) => onIgnoredSlotChange(index, 'description', event.target.value)}
|
onChange={(event) => onIgnoredSlotChange(index, 'slotName', event.target.value)}
|
||||||
placeholder="Beschreibung (optional)"
|
placeholder="Slotname"
|
||||||
|
required
|
||||||
|
className={`md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 ${
|
||||||
|
slotNameMissing ? 'border-red-400 focus:ring-red-500 focus:border-red-500' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slot.info}
|
||||||
|
onChange={(event) => onIgnoredSlotChange(index, 'info', event.target.value)}
|
||||||
|
placeholder="Info (optional)"
|
||||||
className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -259,7 +302,14 @@ const AdminSettingsPanel = ({
|
|||||||
>
|
>
|
||||||
Entfernen
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
|
{showErrors && (
|
||||||
|
<p className="md:col-span-6 text-xs text-red-600">
|
||||||
|
Store-ID und Slotname sind Pflichtfelder.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ const DashboardView = ({
|
|||||||
}),
|
}),
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: 'skipDormantCheck',
|
id: 'skipDormantCheck',
|
||||||
header: () => <span>Ruhe-Prüfung</span>,
|
header: () => <span>Inaktivitätswarnung</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div
|
<div
|
||||||
className="text-center"
|
className="text-center"
|
||||||
@@ -348,9 +348,9 @@ const DashboardView = ({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
checked={!!row.original.skipDormantCheck}
|
checked={!row.original.skipDormantCheck}
|
||||||
onChange={() => onToggleDormantSkip(row.original.id)}
|
onChange={() => onToggleDormantSkip(row.original.id)}
|
||||||
title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb deaktivieren"
|
title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb aktivieren"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -670,11 +670,11 @@ const DashboardView = ({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
checked={!!entry?.skipDormantCheck}
|
checked={!entry?.skipDormantCheck}
|
||||||
onChange={() => onToggleDormantSkip(storeId)}
|
onChange={() => onToggleDormantSkip(storeId)}
|
||||||
title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb deaktivieren"
|
title="Warnung bei ausbleibenden Abholungen/Hygiene für diesen Betrieb aktivieren"
|
||||||
/>
|
/>
|
||||||
<span>Ruhe-Prüfung</span>
|
<span>Inaktivitätswarnung</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => onStoreSelect(store)}
|
onClick={() => onStoreSelect(store)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
|
const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ to: '/', label: 'Slots buchen' },
|
{ to: '/', label: 'Slots buchen' },
|
||||||
@@ -28,25 +30,99 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
|
|||||||
proceed();
|
proceed();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<nav className="mb-4 flex gap-2" aria-label="Navigation">
|
setMobileOpen(false);
|
||||||
{tabs.map((tab) => {
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const renderLink = (tab, className, options = {}) => {
|
||||||
const isActive = location.pathname === tab.to;
|
const isActive = location.pathname === tab.to;
|
||||||
|
const combinedClassName = typeof className === 'function' ? className(isActive) : className;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={tab.to}
|
key={tab.to}
|
||||||
to={tab.to}
|
to={tab.to}
|
||||||
onClick={(event) => handleClick(event, tab.to)}
|
onClick={(event) => handleClick(event, tab.to)}
|
||||||
className={`px-4 py-2 rounded-md border transition-colors ${
|
className={combinedClassName}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span className="relative z-10">{tab.label}</span>
|
||||||
|
{options.showUnderline ? (
|
||||||
|
<span
|
||||||
|
className={`absolute inset-x-3 -bottom-1 h-0.5 rounded-full transition-all ${
|
||||||
|
isActive ? 'bg-blue-500 opacity-100' : 'bg-blue-200 opacity-0 group-hover:opacity-60'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="mb-4" aria-label="Navigation">
|
||||||
|
<div className="flex items-center justify-between sm:hidden">
|
||||||
|
<span className="text-sm font-semibold text-gray-600">Navigation</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-700 shadow-sm"
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
aria-controls="mobile-navigation"
|
||||||
|
onClick={() => setMobileOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{mobileOpen ? (
|
||||||
|
<>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="mobile-navigation"
|
||||||
|
className={`mt-3 flex flex-col gap-2 sm:hidden ${mobileOpen ? '' : 'hidden'}`}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) =>
|
||||||
|
renderLink(
|
||||||
|
tab,
|
||||||
|
(isActive) =>
|
||||||
|
`px-4 py-3 rounded-md border text-left transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blue-500 text-white border-blue-600'
|
? 'bg-blue-500 text-white border-blue-600'
|
||||||
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
|
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
|
||||||
}`}
|
}`
|
||||||
>
|
)
|
||||||
{tab.label}
|
)}
|
||||||
</Link>
|
</div>
|
||||||
);
|
<div className="hidden items-center gap-6 rounded-xl border border-gray-200 bg-white/80 px-3 py-2 shadow-sm sm:flex">
|
||||||
})}
|
{tabs.map((tab) =>
|
||||||
|
renderLink(
|
||||||
|
tab,
|
||||||
|
(isActive) =>
|
||||||
|
`group relative rounded-md px-2 pb-2 pt-1 text-sm font-semibold tracking-tight transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'text-blue-600 bg-blue-50/70'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100/80 hover:text-gray-900'
|
||||||
|
}`,
|
||||||
|
{ showUnderline: true }
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1167,14 +1167,14 @@ const StoreWatchPage = ({
|
|||||||
|
|
||||||
if (!authorizedFetch) {
|
if (!authorizedFetch) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-4xl mx-auto">
|
<div className="p-4 max-w-6xl w-full mx-auto">
|
||||||
<p className="text-red-600">Keine Session aktiv.</p>
|
<p className="text-red-600">Keine Session aktiv.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-5xl mx-auto bg-white shadow rounded-lg mt-4">
|
<div className="p-4 max-w-6xl w-full mx-auto bg-white shadow rounded-lg mt-4">
|
||||||
<div className="flex flex-col gap-2 mb-4">
|
<div className="flex flex-col gap-2 mb-4">
|
||||||
<h1 className="text-2xl font-bold text-blue-900">Betriebs-Monitoring</h1>
|
<h1 className="text-2xl font-bold text-blue-900">Betriebs-Monitoring</h1>
|
||||||
<p className="text-gray-600 text-sm">
|
<p className="text-gray-600 text-sm">
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const useAdminSettings = ({ session, authorizedFetch, setStatus, setError }) =>
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
ignoredSlots: [...(prev.ignoredSlots || []), { storeId: '', description: '' }]
|
ignoredSlots: [...(prev.ignoredSlots || []), { storeId: '', slotName: '', info: '' }]
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -149,6 +149,13 @@ const useAdminSettings = ({ session, authorizedFetch, setStatus, setError }) =>
|
|||||||
if (!session?.token || !session.isAdmin || !adminSettings) {
|
if (!session?.token || !session.isAdmin || !adminSettings) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const invalidIgnoredSlot = (adminSettings.ignoredSlots || []).find(
|
||||||
|
(slot) => !slot?.storeId || !slot?.slotName
|
||||||
|
);
|
||||||
|
if (invalidIgnoredSlot) {
|
||||||
|
setError('Ignorierte Slots: Store-ID und Slotname sind Pflichtfelder.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatus('Admin-Einstellungen werden gespeichert...');
|
setStatus('Admin-Einstellungen werden gespeichert...');
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ export const normalizeAdminSettings = (raw) => {
|
|||||||
storeWatchInitialDelayMinSeconds: raw.storeWatchInitialDelayMinSeconds ?? '',
|
storeWatchInitialDelayMinSeconds: raw.storeWatchInitialDelayMinSeconds ?? '',
|
||||||
storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '',
|
storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '',
|
||||||
storeWatchRequestDelayMs: raw.storeWatchRequestDelayMs ?? '',
|
storeWatchRequestDelayMs: raw.storeWatchRequestDelayMs ?? '',
|
||||||
|
storeWatchStatusCacheMaxAgeMinutes: raw.storeWatchStatusCacheMaxAgeMinutes ?? '',
|
||||||
ignoredSlots: Array.isArray(raw.ignoredSlots)
|
ignoredSlots: Array.isArray(raw.ignoredSlots)
|
||||||
? raw.ignoredSlots.map((slot) => ({
|
? raw.ignoredSlots.map((slot) => ({
|
||||||
storeId: slot?.storeId ? String(slot.storeId) : '',
|
storeId: slot?.storeId ? String(slot.storeId) : '',
|
||||||
description: slot?.description || ''
|
slotName: slot?.slotName || slot?.description || '',
|
||||||
|
info: slot?.info || ''
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
notifications: {
|
notifications: {
|
||||||
@@ -59,9 +61,11 @@ export const serializeAdminSettings = (adminSettings) => {
|
|||||||
storeWatchInitialDelayMinSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMinSeconds),
|
storeWatchInitialDelayMinSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMinSeconds),
|
||||||
storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds),
|
storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds),
|
||||||
storeWatchRequestDelayMs: toNumberOrUndefined(adminSettings.storeWatchRequestDelayMs),
|
storeWatchRequestDelayMs: toNumberOrUndefined(adminSettings.storeWatchRequestDelayMs),
|
||||||
|
storeWatchStatusCacheMaxAgeMinutes: toNumberOrUndefined(adminSettings.storeWatchStatusCacheMaxAgeMinutes),
|
||||||
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
|
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
|
||||||
storeId: slot.storeId || '',
|
storeId: slot.storeId || '',
|
||||||
description: slot.description || ''
|
slotName: slot.slotName || '',
|
||||||
|
info: slot.info || ''
|
||||||
})),
|
})),
|
||||||
notifications: {
|
notifications: {
|
||||||
ntfy: {
|
ntfy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user