aktueller stand
This commit is contained in:
@@ -784,7 +784,7 @@ function App() {
|
||||
<Router>
|
||||
<>
|
||||
<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} />
|
||||
<Routes>
|
||||
<Route path="/" element={dashboardContent} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
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>
|
||||
<p className="text-gray-600 mb-4">Dieser Bereich ist nur für Administratoren verfügbar.</p>
|
||||
<Link
|
||||
|
||||
@@ -49,7 +49,7 @@ const AdminSettingsPanel = ({
|
||||
onSave
|
||||
}) => {
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-purple-900">Admin-Einstellungen</h1>
|
||||
@@ -224,6 +224,22 @@ const AdminSettingsPanel = ({
|
||||
placeholder="z. B. 1000"
|
||||
/>
|
||||
</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
|
||||
@@ -233,23 +249,50 @@ const AdminSettingsPanel = ({
|
||||
{(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && (
|
||||
<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) => (
|
||||
(() => {
|
||||
const storeIdMissing = !slot.storeId;
|
||||
const slotNameMissing = !slot.slotName;
|
||||
const showErrors = storeIdMissing || slotNameMissing;
|
||||
return (
|
||||
<div
|
||||
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
|
||||
type="text"
|
||||
value={slot.storeId}
|
||||
onChange={(event) => onIgnoredSlotChange(index, 'storeId', event.target.value)}
|
||||
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
|
||||
type="text"
|
||||
value={slot.description}
|
||||
onChange={(event) => onIgnoredSlotChange(index, 'description', event.target.value)}
|
||||
placeholder="Beschreibung (optional)"
|
||||
value={slot.slotName}
|
||||
onChange={(event) => onIgnoredSlotChange(index, 'slotName', event.target.value)}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
@@ -259,7 +302,14 @@ const AdminSettingsPanel = ({
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
{showErrors && (
|
||||
<p className="md:col-span-6 text-xs text-red-600">
|
||||
Store-ID und Slotname sind Pflichtfelder.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -335,7 +335,7 @@ const DashboardView = ({
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'skipDormantCheck',
|
||||
header: () => <span>Ruhe-Prüfung</span>,
|
||||
header: () => <span>Inaktivitätswarnung</span>,
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="text-center"
|
||||
@@ -348,9 +348,9 @@ const DashboardView = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-5 w-5"
|
||||
checked={!!row.original.skipDormantCheck}
|
||||
checked={!row.original.skipDormantCheck}
|
||||
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>
|
||||
),
|
||||
@@ -670,11 +670,11 @@ const DashboardView = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={!!entry?.skipDormantCheck}
|
||||
checked={!entry?.skipDormantCheck}
|
||||
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>
|
||||
<button
|
||||
onClick={() => onStoreSelect(store)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const tabs = [
|
||||
{ to: '/', label: 'Slots buchen' },
|
||||
@@ -28,25 +30,99 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
|
||||
proceed();
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="mb-4 flex gap-2" aria-label="Navigation">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = location.pathname === tab.to;
|
||||
return (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
onClick={(event) => handleClick(event, tab.to)}
|
||||
className={`px-4 py-2 rounded-md border transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-500 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const renderLink = (tab, className, options = {}) => {
|
||||
const isActive = location.pathname === tab.to;
|
||||
const combinedClassName = typeof className === 'function' ? className(isActive) : className;
|
||||
return (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
onClick={(event) => handleClick(event, tab.to)}
|
||||
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"
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{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
|
||||
? 'bg-blue-500 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
|
||||
}`
|
||||
)
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1167,14 +1167,14 @@ const StoreWatchPage = ({
|
||||
|
||||
if (!authorizedFetch) {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<h1 className="text-2xl font-bold text-blue-900">Betriebs-Monitoring</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
|
||||
@@ -126,7 +126,7 @@ const useAdminSettings = ({ session, authorizedFetch, setStatus, setError }) =>
|
||||
}
|
||||
return {
|
||||
...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) {
|
||||
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...');
|
||||
setError('');
|
||||
|
||||
|
||||
@@ -13,10 +13,12 @@ export const normalizeAdminSettings = (raw) => {
|
||||
storeWatchInitialDelayMinSeconds: raw.storeWatchInitialDelayMinSeconds ?? '',
|
||||
storeWatchInitialDelayMaxSeconds: raw.storeWatchInitialDelayMaxSeconds ?? '',
|
||||
storeWatchRequestDelayMs: raw.storeWatchRequestDelayMs ?? '',
|
||||
storeWatchStatusCacheMaxAgeMinutes: raw.storeWatchStatusCacheMaxAgeMinutes ?? '',
|
||||
ignoredSlots: Array.isArray(raw.ignoredSlots)
|
||||
? raw.ignoredSlots.map((slot) => ({
|
||||
storeId: slot?.storeId ? String(slot.storeId) : '',
|
||||
description: slot?.description || ''
|
||||
slotName: slot?.slotName || slot?.description || '',
|
||||
info: slot?.info || ''
|
||||
}))
|
||||
: [],
|
||||
notifications: {
|
||||
@@ -59,9 +61,11 @@ export const serializeAdminSettings = (adminSettings) => {
|
||||
storeWatchInitialDelayMinSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMinSeconds),
|
||||
storeWatchInitialDelayMaxSeconds: toNumberOrUndefined(adminSettings.storeWatchInitialDelayMaxSeconds),
|
||||
storeWatchRequestDelayMs: toNumberOrUndefined(adminSettings.storeWatchRequestDelayMs),
|
||||
storeWatchStatusCacheMaxAgeMinutes: toNumberOrUndefined(adminSettings.storeWatchStatusCacheMaxAgeMinutes),
|
||||
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
|
||||
storeId: slot.storeId || '',
|
||||
description: slot.description || ''
|
||||
slotName: slot.slotName || '',
|
||||
info: slot.info || ''
|
||||
})),
|
||||
notifications: {
|
||||
ntfy: {
|
||||
|
||||
Reference in New Issue
Block a user