aktueller stand

This commit is contained in:
2026-01-29 17:00:24 +01:00
parent 132b571798
commit 916ca1dbc2
10 changed files with 256 additions and 55 deletions

View File

@@ -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} />

View File

@@ -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

View File

@@ -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"

View File

@@ -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)}

View File

@@ -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>
);
};

View File

@@ -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">

View File

@@ -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('');

View File

@@ -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: {