372 lines
17 KiB
JavaScript
372 lines
17 KiB
JavaScript
import { useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
|
||
const SettingField = ({ label, description, children }) => {
|
||
const [showHelp, setShowHelp] = useState(false);
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
||
{description && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowHelp((prev) => !prev)}
|
||
className="text-xs text-purple-600 hover:text-purple-900 focus:outline-none"
|
||
>
|
||
{showHelp ? 'Hilfe ausblenden' : 'Hilfe anzeigen'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{children}
|
||
{showHelp && description && (
|
||
<p className="text-xs text-gray-600 bg-white border border-purple-100 rounded p-2">{description}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SettingSection = ({ title, subtitle, children }) => (
|
||
<section className="mb-6">
|
||
<div className="mb-3">
|
||
<h2 className="text-xl font-semibold text-purple-900">{title}</h2>
|
||
{subtitle && <p className="text-sm text-gray-600">{subtitle}</p>}
|
||
</div>
|
||
<div className="space-y-4 bg-white border border-purple-100 rounded-lg p-4 shadow-sm">{children}</div>
|
||
</section>
|
||
);
|
||
|
||
const AdminSettingsPanel = ({
|
||
adminSettings,
|
||
adminSettingsLoading,
|
||
status,
|
||
error,
|
||
onDismissError,
|
||
onSettingChange,
|
||
onIgnoredSlotChange,
|
||
onAddIgnoredSlot,
|
||
onRemoveIgnoredSlot,
|
||
onNotificationChange,
|
||
onSave
|
||
}) => {
|
||
return (
|
||
<div className="p-4 max-w-4xl 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>
|
||
<p className="text-sm text-gray-600">Globale Abläufe und Benachrichtigungen feinjustieren.</p>
|
||
</div>
|
||
<Link
|
||
to="/"
|
||
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border border-purple-200 rounded-md text-purple-700 hover:bg-purple-50 transition-colors"
|
||
>
|
||
Zu Slots buchen
|
||
</Link>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 relative">
|
||
<span className="block sm:inline">{error}</span>
|
||
<button className="absolute top-0 bottom-0 right-0 px-4 py-3" onClick={onDismissError}>
|
||
<span className="text-xl">×</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{status && (
|
||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4 relative">
|
||
<span className="block sm:inline">{status}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="border border-purple-200 rounded-lg p-4 bg-purple-50">
|
||
{adminSettingsLoading && <p className="text-sm text-purple-700">Lade Admin-Einstellungen...</p>}
|
||
{!adminSettingsLoading && !adminSettings && (
|
||
<p className="text-sm text-purple-700">Keine Admin-Einstellungen verfügbar.</p>
|
||
)}
|
||
|
||
{adminSettings && (
|
||
<>
|
||
<SettingSection
|
||
title="Scheduler für Slot-Suche"
|
||
subtitle="Bestimmt, wann der Bot freie Slots sucht und wie stark er Anfragen verteilt."
|
||
>
|
||
<SettingField
|
||
label="Cron-Ausdruck"
|
||
description="Legt fest, zu welchen Zeiten die reine Slot-Suche läuft. Nutzt die klassische Cron-Syntax (Serverzeit)."
|
||
>
|
||
<input
|
||
type="text"
|
||
value={adminSettings.scheduleCron}
|
||
onChange={(event) => onSettingChange('scheduleCron', event.target.value)}
|
||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||
placeholder="z. B. */10 7-22 * * *"
|
||
/>
|
||
</SettingField>
|
||
|
||
<SettingField
|
||
label="Initiale Verzögerung (Sek.)"
|
||
description="Zufällige Wartezeit beim Start einer Session. Hilft, die Last des ersten Laufs auf mehrere Sekunden zu verteilen."
|
||
>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={adminSettings.initialDelayMinSeconds}
|
||
onChange={(event) => onSettingChange('initialDelayMinSeconds', 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="Min"
|
||
/>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={adminSettings.initialDelayMaxSeconds}
|
||
onChange={(event) => onSettingChange('initialDelayMaxSeconds', 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="Max"
|
||
/>
|
||
</div>
|
||
</SettingField>
|
||
|
||
<SettingField
|
||
label="Verzögerung zwischen Jobs (Sek.)"
|
||
description="Pause zwischen Cron-Trigger und tatsächlicher Anfrage. Größere Intervalle = mehr Streuung zwischen Sessions."
|
||
>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={adminSettings.randomDelayMinSeconds}
|
||
onChange={(event) => onSettingChange('randomDelayMinSeconds', 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="Min"
|
||
/>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={adminSettings.randomDelayMaxSeconds}
|
||
onChange={(event) => onSettingChange('randomDelayMaxSeconds', 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="Max"
|
||
/>
|
||
</div>
|
||
</SettingField>
|
||
|
||
<SettingField
|
||
label="Verzögerung zwischen Store-Abfragen (ms)"
|
||
description="Drosselt den Sync der Foodsharing-Stores. Erhöhe den Wert bei Rate-Limit-Fehlern oder wenn der Server langsam reagiert."
|
||
>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={adminSettings.storePickupCheckDelayMs}
|
||
onChange={(event) => onSettingChange('storePickupCheckDelayMs', 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. 3000"
|
||
/>
|
||
</SettingField>
|
||
</SettingSection>
|
||
|
||
<SettingSection
|
||
title="Store-Watch Monitoring"
|
||
subtitle="Steuert die Team-Status-Abfragen für geschlossene Betriebe (teamSearchStatus)."
|
||
>
|
||
<SettingField
|
||
label="Store-Watch Cron"
|
||
description="Cron-Ausdruck, wann Team-Status-Prüfungen laufen. Bei sehr vielen Stores Werte etwas streuen (z. B. alle 30–60 Minuten)."
|
||
>
|
||
<input
|
||
type="text"
|
||
value={adminSettings.storeWatchCron || ''}
|
||
onChange={(event) => onSettingChange('storeWatchCron', event.target.value)}
|
||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||
placeholder="z. B. */30 * * * *"
|
||
/>
|
||
</SettingField>
|
||
|
||
<SettingField
|
||
label="Store-Watch Startverzögerung (Sek.)"
|
||
description="Randomisierte Wartezeit, bevor nach dem Cron-Signal die Statusprüfungen beginnen. Verhindert Traffic-Spitzen."
|
||
>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={adminSettings.storeWatchInitialDelayMinSeconds}
|
||
onChange={(event) =>
|
||
onSettingChange('storeWatchInitialDelayMinSeconds', 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="Min"
|
||
/>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={adminSettings.storeWatchInitialDelayMaxSeconds}
|
||
onChange={(event) =>
|
||
onSettingChange('storeWatchInitialDelayMaxSeconds', 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="Max"
|
||
/>
|
||
</div>
|
||
</SettingField>
|
||
|
||
<SettingField
|
||
label="Verzögerung zwischen Status-Abfragen (ms)"
|
||
description="Pausiert zwischen einzelnen Store-Requests, um das Foodsharing-API zu schonen."
|
||
>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={adminSettings.storeWatchRequestDelayMs}
|
||
onChange={(event) => onSettingChange('storeWatchRequestDelayMs', 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. 1000"
|
||
/>
|
||
</SettingField>
|
||
</SettingSection>
|
||
|
||
<SettingSection
|
||
title="Ignorierte Slots"
|
||
subtitle="Diese Einträge überspringt der Scheduler dauerhaft (z. B. TVS-Zeiten oder Test-Slots)."
|
||
>
|
||
{(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && (
|
||
<p className="text-sm text-gray-500">Keine Regeln definiert.</p>
|
||
)}
|
||
{adminSettings.ignoredSlots?.map((slot, index) => (
|
||
<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"
|
||
>
|
||
<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"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={slot.description}
|
||
onChange={(event) => onIgnoredSlotChange(index, 'description', event.target.value)}
|
||
placeholder="Beschreibung (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
|
||
type="button"
|
||
onClick={() => onRemoveIgnoredSlot(index)}
|
||
className="text-sm text-red-600 hover:text-red-800 focus:outline-none"
|
||
>
|
||
Entfernen
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={onAddIgnoredSlot}
|
||
className="text-sm text-purple-700 hover:text-purple-900 flex items-center gap-1"
|
||
>
|
||
+ Slot hinzufügen
|
||
</button>
|
||
</SettingSection>
|
||
|
||
<SettingSection
|
||
title="System-Benachrichtigungen"
|
||
subtitle="Aktiviere hier die Kanäle, über die Nutzer später personalisierte Benachrichtigungen empfangen können."
|
||
>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-800">ntfy</h3>
|
||
<p className="text-xs text-gray-500">Push-Nachrichten via ntfy-Server.</p>
|
||
</div>
|
||
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
|
||
<input
|
||
type="checkbox"
|
||
checked={adminSettings.notifications?.ntfy?.enabled || false}
|
||
onChange={(event) => onNotificationChange('ntfy', 'enabled', event.target.checked)}
|
||
className="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
|
||
/>
|
||
<span>Aktiv</span>
|
||
</label>
|
||
</div>
|
||
<div className="space-y-3">
|
||
<input
|
||
type="text"
|
||
value={adminSettings.notifications?.ntfy?.serverUrl || ''}
|
||
onChange={(event) => onNotificationChange('ntfy', 'serverUrl', event.target.value)}
|
||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||
placeholder="Server-URL"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={adminSettings.notifications?.ntfy?.topicPrefix || ''}
|
||
onChange={(event) => onNotificationChange('ntfy', 'topicPrefix', event.target.value)}
|
||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||
placeholder="Themenpräfix"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={adminSettings.notifications?.ntfy?.username || ''}
|
||
onChange={(event) => onNotificationChange('ntfy', 'username', event.target.value)}
|
||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||
placeholder="Benutzername (optional)"
|
||
/>
|
||
<input
|
||
type="password"
|
||
value={adminSettings.notifications?.ntfy?.password || ''}
|
||
onChange={(event) => onNotificationChange('ntfy', 'password', event.target.value)}
|
||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||
placeholder="Passwort (optional)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-800">Telegram</h3>
|
||
<p className="text-xs text-gray-500">Bot-Einstellungen für Telegram-Nachrichten.</p>
|
||
</div>
|
||
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
|
||
<input
|
||
type="checkbox"
|
||
checked={adminSettings.notifications?.telegram?.enabled || false}
|
||
onChange={(event) => onNotificationChange('telegram', 'enabled', event.target.checked)}
|
||
className="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
|
||
/>
|
||
<span>Aktiv</span>
|
||
</label>
|
||
</div>
|
||
<div className="space-y-3">
|
||
<input
|
||
type="text"
|
||
value={adminSettings.notifications?.telegram?.botToken || ''}
|
||
onChange={(event) => onNotificationChange('telegram', 'botToken', event.target.value)}
|
||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||
placeholder="Bot-Token"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</SettingSection>
|
||
|
||
<div className="flex justify-end mt-4">
|
||
<button
|
||
type="button"
|
||
onClick={onSave}
|
||
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
>
|
||
Admin-Einstellungen speichern
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminSettingsPanel;
|