refactoring node 24
This commit is contained in:
1099
src/App.js
1099
src/App.js
File diff suppressed because it is too large
Load Diff
16
src/components/AdminAccessMessage.js
Normal file
16
src/components/AdminAccessMessage.js
Normal file
@@ -0,0 +1,16 @@
|
||||
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">
|
||||
<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
|
||||
to="/"
|
||||
className="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Zurück zur Konfiguration
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AdminAccessMessage;
|
||||
253
src/components/AdminSettingsPanel.js
Normal file
253
src/components/AdminSettingsPanel.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
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 Verzögerungen für die Abhol-Automation verwalten.</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"
|
||||
>
|
||||
Zur Konfiguration
|
||||
</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 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Cron-Ausdruck</label>
|
||||
<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. 0 * * * *"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Initiale Verzögerung (Sek.)</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prüfverzögerung (Sek.)</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verzögerung Store-Prüfung (ms)</label>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold text-purple-900">Ignorierte Slots</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddIgnoredSlot}
|
||||
className="text-sm text-purple-700 hover:text-purple-900 flex items-center gap-1"
|
||||
>
|
||||
+ Slot hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{(!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 mb-2 items-center">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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">Konfiguration für Systembenachrichtigungen via ntfy.</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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</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-Konfiguration für Telegram-Benachrichtigungen.</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>
|
||||
<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 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;
|
||||
44
src/components/ConfirmationDialog.js
Normal file
44
src/components/ConfirmationDialog.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const ConfirmationDialog = ({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
confirmTone = 'primary',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}) => {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const confirmClasses =
|
||||
confirmTone === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
|
||||
<div className="bg-white rounded-lg shadow-2xl max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">{title || 'Bitte bestätigen'}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{message || 'Bist du sicher?'}</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-300"
|
||||
>
|
||||
{cancelLabel || 'Abbrechen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`${confirmClasses} text-white px-4 py-2 rounded focus:outline-none focus:ring-2`}
|
||||
>
|
||||
{confirmLabel || 'Ja'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationDialog;
|
||||
299
src/components/DashboardView.js
Normal file
299
src/components/DashboardView.js
Normal file
@@ -0,0 +1,299 @@
|
||||
import NotificationPanel from './NotificationPanel';
|
||||
|
||||
const DashboardView = ({
|
||||
session,
|
||||
onRefresh,
|
||||
onLogout,
|
||||
notificationPanelOpen,
|
||||
onToggleNotificationPanel,
|
||||
notificationProps,
|
||||
stores,
|
||||
availableCollapsed,
|
||||
onToggleStores,
|
||||
onStoreSelect,
|
||||
configMap,
|
||||
error,
|
||||
onDismissError,
|
||||
status,
|
||||
visibleConfig,
|
||||
config,
|
||||
onToggleActive,
|
||||
onToggleProfileCheck,
|
||||
onToggleOnlyNotify,
|
||||
onWeekdayChange,
|
||||
weekdays,
|
||||
onRangePickerRequest,
|
||||
formatRangeLabel,
|
||||
onSaveConfig,
|
||||
onResetConfig
|
||||
}) => {
|
||||
const {
|
||||
error: notificationError,
|
||||
message: notificationMessage,
|
||||
settings: notificationSettings,
|
||||
capabilities: notificationCapabilities,
|
||||
loading: notificationLoading,
|
||||
dirty: notificationDirty,
|
||||
saving: notificationSaving,
|
||||
onReset: onNotificationReset,
|
||||
onSave: onNotificationSave,
|
||||
onFieldChange: onNotificationFieldChange,
|
||||
onSendTest: onNotificationTest,
|
||||
onCopyLink: onNotificationCopy,
|
||||
copyFeedback,
|
||||
ntfyPreviewUrl
|
||||
} = notificationProps;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-6xl mx-auto bg-white shadow-lg rounded-lg mt-4">
|
||||
<h1 className="text-2xl font-bold mb-4 text-center text-gray-800">Foodsharing Pickup Manager</h1>
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4 mb-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm uppercase text-blue-600 font-semibold">Angemeldet</p>
|
||||
<p className="text-lg font-medium text-gray-800">{session.profile.name}</p>
|
||||
<p className="text-gray-500 text-sm">Profil-ID: {session.profile.id}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<button
|
||||
onClick={() => onRefresh({ block: false })}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
Betriebe aktualisieren
|
||||
</button>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-800 py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-gray-400 transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleNotificationPanel}
|
||||
className={`flex items-center justify-center w-11 h-11 rounded-full border ${
|
||||
notificationPanelOpen ? 'border-blue-500 text-blue-600' : 'border-gray-300 text-gray-600'
|
||||
} hover:text-blue-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 transition`}
|
||||
title="Benachrichtigungen konfigurieren"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.89 3.31.877 2.42 2.42a1.724 1.724 0 0 0 1.065 2.572c1.757.426 1.757 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.89 1.543-.877 3.31-2.42 2.42a1.724 1.724 0 0 0-2.572 1.065c-.426 1.757-2.924 1.757-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.89-3.31-.877-2.42-2.42a1.724 1.724 0 0 0-1.065-2.572c-1.757-.426-1.757-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.89-1.543.877-3.31 2.42-2.42.996.575 2.273.155 2.573-1.065z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
{notificationLoading && <span className="text-sm text-gray-500 ml-1">Lade…</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notificationPanelOpen && (
|
||||
<NotificationPanel
|
||||
error={notificationError}
|
||||
message={notificationMessage}
|
||||
settings={notificationSettings}
|
||||
capabilities={notificationCapabilities}
|
||||
loading={notificationLoading}
|
||||
dirty={notificationDirty}
|
||||
saving={notificationSaving}
|
||||
onReset={onNotificationReset}
|
||||
onSave={onNotificationSave}
|
||||
onFieldChange={onNotificationFieldChange}
|
||||
onSendTest={onNotificationTest}
|
||||
onCopyLink={onNotificationCopy}
|
||||
copyFeedback={copyFeedback}
|
||||
ntfyPreviewUrl={ntfyPreviewUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-6 border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={onToggleStores}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
>
|
||||
<span className="font-semibold text-gray-800">Verfügbare Betriebe ({stores.length})</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`h-5 w-5 text-gray-600 transition-transform ${availableCollapsed ? '' : 'rotate-180'}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{!availableCollapsed && (
|
||||
<div className="max-h-72 overflow-y-auto divide-y divide-gray-100">
|
||||
{stores.map((store) => {
|
||||
const storeId = String(store.id);
|
||||
const entry = configMap.get(storeId);
|
||||
const isVisible = entry && !entry.hidden;
|
||||
const blockedByNoPickups = store.hasPickupSlots === false;
|
||||
let statusLabel = 'Hinzufügen';
|
||||
let statusClass = 'text-blue-600';
|
||||
if (isVisible) {
|
||||
statusLabel = 'Bereits in Konfiguration';
|
||||
statusClass = 'text-gray-500';
|
||||
} else if (entry) {
|
||||
statusLabel = 'Ausgeblendet – erneut hinzufügen';
|
||||
statusClass = 'text-amber-600';
|
||||
} else if (blockedByNoPickups) {
|
||||
statusLabel = 'Keine Pickups – automatisch verborgen';
|
||||
statusClass = 'text-red-600';
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={storeId}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition cursor-default"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">{store.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{store.zip} {store.city}
|
||||
</p>
|
||||
<p className={`text-xs mt-2 ${statusClass}`}>{statusLabel}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onStoreSelect(store)}
|
||||
disabled={blockedByNoPickups}
|
||||
className="bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200 px-3 py-1.5 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-300 disabled:opacity-60"
|
||||
>
|
||||
{isVisible ? 'Zur Liste springen' : 'Hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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="overflow-x-auto">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="px-4 py-2">Aktiv</th>
|
||||
<th className="px-4 py-2">Geschäft</th>
|
||||
<th className="px-4 py-2">Profil prüfen</th>
|
||||
<th className="px-4 py-2">Nur benachrichtigen</th>
|
||||
<th className="px-4 py-2">Wochentag</th>
|
||||
<th className="px-4 py-2">Datum / Zeitraum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleConfig.map((item, index) => {
|
||||
const normalizedRange = item.desiredDateRange
|
||||
? { ...item.desiredDateRange }
|
||||
: item.desiredDate
|
||||
? { start: item.desiredDate, end: item.desiredDate }
|
||||
: null;
|
||||
const rangeStart = normalizedRange?.start || '';
|
||||
const rangeEnd = normalizedRange?.end || '';
|
||||
const hasDateRange = Boolean(rangeStart || rangeEnd);
|
||||
return (
|
||||
<tr key={item.id || index} className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'}>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.active}
|
||||
onChange={() => onToggleActive(item.id)}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
{item.hidden && <span className="ml-2 text-xs text-gray-400">(ausgeblendet)</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checkProfileId}
|
||||
onChange={() => onToggleProfileCheck(item.id)}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.onlyNotify}
|
||||
onChange={() => onToggleOnlyNotify(item.id)}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<select
|
||||
value={item.desiredWeekday || ''}
|
||||
onChange={(event) => onWeekdayChange(item.id, event.target.value)}
|
||||
className="border rounded p-1 w-full"
|
||||
disabled={hasDateRange}
|
||||
>
|
||||
<option value="">Kein Wochentag</option>
|
||||
{weekdays.map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{day}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.desiredWeekday) {
|
||||
return;
|
||||
}
|
||||
onRangePickerRequest(item.id);
|
||||
}}
|
||||
disabled={Boolean(item.desiredWeekday)}
|
||||
className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
item.desiredWeekday
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-white hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span>
|
||||
<span className="block text-xs text-gray-500">Klicke zum Auswählen</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-between">
|
||||
<button onClick={onResetConfig} className="bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded">
|
||||
Zurücksetzen
|
||||
</button>
|
||||
<button onClick={onSaveConfig} className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded">
|
||||
In ioBroker speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 border rounded bg-gray-50">
|
||||
<h2 className="text-lg font-bold mb-2">Aktuelle JSON-Konfiguration:</h2>
|
||||
<pre className="bg-gray-100 p-4 rounded overflow-x-auto">{JSON.stringify(config, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardView;
|
||||
39
src/components/DirtyNavigationDialog.js
Normal file
39
src/components/DirtyNavigationDialog.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const DirtyNavigationDialog = ({ open, message, onSave, onDiscard, onCancel, saving }) => {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
|
||||
<div className="bg-white rounded-lg shadow-2xl max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">Änderungen nicht gespeichert</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{message || 'Es gibt ungespeicherte Änderungen. Wie möchtest du fortfahren?'}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-70"
|
||||
>
|
||||
{saving ? 'Speichere...' : 'Speichern & fortfahren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDiscard}
|
||||
className="bg-white border border-gray-300 hover:bg-gray-50 text-gray-800 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
>
|
||||
Ohne Speichern fortfahren
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirtyNavigationDialog;
|
||||
73
src/components/LoginView.js
Normal file
73
src/components/LoginView.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const LoginView = ({ credentials, onCredentialsChange, error, loading, initializing, onSubmit }) => {
|
||||
const handleChange = (field) => (event) => {
|
||||
onCredentialsChange({ ...credentials, [field]: event.target.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<form
|
||||
className="bg-white shadow-lg rounded-lg p-8 w-full max-w-md space-y-6"
|
||||
onSubmit={(event) => {
|
||||
if (initializing) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onSubmit(event);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 text-center text-gray-800">Pickup Config Login</h1>
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
Die Anwendung authentifiziert sich direkt bei foodsharing.de. Bitte verwende deine persönlichen Login-Daten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
value={credentials.email}
|
||||
onChange={handleChange('email')}
|
||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="password">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
value={credentials.password}
|
||||
onChange={handleChange('password')}
|
||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || initializing}
|
||||
className="w-full bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
|
||||
>
|
||||
{loading || initializing ? 'Anmeldung läuft...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginView;
|
||||
49
src/components/NavigationTabs.js
Normal file
49
src/components/NavigationTabs.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ to: '/', label: 'Konfiguration' },
|
||||
{ to: '/admin', label: 'Admin' }
|
||||
];
|
||||
|
||||
const handleClick = (event, to) => {
|
||||
event.preventDefault();
|
||||
if (to === location.pathname) {
|
||||
return;
|
||||
}
|
||||
onProtectedNavigate(`zur Seite "${tabs.find((tab) => tab.to === to)?.label || ''}" zu wechseln`, () =>
|
||||
navigate(to)
|
||||
);
|
||||
};
|
||||
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationTabs;
|
||||
196
src/components/NotificationPanel.js
Normal file
196
src/components/NotificationPanel.js
Normal file
@@ -0,0 +1,196 @@
|
||||
const NotificationPanel = ({
|
||||
error,
|
||||
message,
|
||||
settings,
|
||||
capabilities,
|
||||
loading,
|
||||
dirty,
|
||||
saving,
|
||||
onReset,
|
||||
onSave,
|
||||
onFieldChange,
|
||||
onSendTest,
|
||||
onCopyLink,
|
||||
copyFeedback,
|
||||
ntfyPreviewUrl
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-100 border border-red-300 text-red-700 px-4 py-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className="mb-4 bg-green-100 border border-green-300 text-green-700 px-4 py-2 rounded">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid 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 aufs Handy über die ntfy-App oder Browser (Themenkanal notwendig).
|
||||
</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.ntfy.enabled}
|
||||
onChange={(event) => onFieldChange('ntfy', 'enabled', event.target.checked)}
|
||||
disabled={!capabilities.ntfy.enabled}
|
||||
className="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span>Aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!capabilities.ntfy.enabled ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
Diese Option wurde vom Admin deaktiviert. Bitte frage nach, wenn du ntfy nutzen möchtest.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">Topic / Kanal</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ntfy.topic}
|
||||
onChange={(event) => onFieldChange('ntfy', 'topic', event.target.value)}
|
||||
placeholder="z. B. mein-pickup-topic"
|
||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={!settings.ntfy.enabled}
|
||||
/>
|
||||
</div>
|
||||
{capabilities.ntfy.serverUrl && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Server: {capabilities.ntfy.serverUrl} (vom Admin festgelegt)
|
||||
</p>
|
||||
)}
|
||||
{capabilities.ntfy.topicPrefix && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Präfix: {capabilities.ntfy.topicPrefix} (Bindestrich wird automatisch ergänzt)
|
||||
</p>
|
||||
)}
|
||||
{ntfyPreviewUrl && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<a
|
||||
href={ntfyPreviewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 break-all"
|
||||
>
|
||||
{ntfyPreviewUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopyLink(ntfyPreviewUrl)}
|
||||
className="border border-gray-300 rounded px-2 py-0.5 text-gray-600 hover:text-blue-700 hover:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
title="In Zwischenablage kopieren"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{copyFeedback && <p className="text-xs text-green-600">{copyFeedback}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSendTest('ntfy')}
|
||||
disabled={!settings.ntfy.enabled || saving}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Test senden
|
||||
</button>
|
||||
</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">
|
||||
Nutze den Bot des Admins, um Nachrichten direkt in Telegram zu erhalten.
|
||||
</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center space-x-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.telegram.enabled}
|
||||
onChange={(event) => onFieldChange('telegram', 'enabled', event.target.checked)}
|
||||
disabled={!capabilities.telegram.enabled}
|
||||
className="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span>Aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!capabilities.telegram.enabled ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
Telegram-Benachrichtigungen sind derzeit deaktiviert oder der Bot ist nicht konfiguriert.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">Chat-ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.telegram.chatId}
|
||||
onChange={(event) => onFieldChange('telegram', 'chatId', event.target.value)}
|
||||
placeholder="z. B. 123456789"
|
||||
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={!settings.telegram.enabled}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Tipp: Schreibe dem Telegram-Bot und nutze{' '}
|
||||
<a
|
||||
href="https://t.me/userinfobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
@userinfobot
|
||||
</a>{' '}
|
||||
oder ein Chat-ID-Tool.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSendTest('telegram')}
|
||||
disabled={!settings.telegram.enabled || saving}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Test senden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end mt-4 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="text-sm text-gray-600 hover:text-gray-900"
|
||||
disabled={loading}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={!dirty || saving}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Speichere...' : 'Benachrichtigungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationPanel;
|
||||
45
src/components/StoreSyncOverlay.js
Normal file
45
src/components/StoreSyncOverlay.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const formatEta = (seconds) => {
|
||||
if (seconds == null || seconds === Infinity) {
|
||||
return null;
|
||||
}
|
||||
const clamped = Math.max(0, seconds);
|
||||
const mins = Math.floor(clamped / 60);
|
||||
const secs = clamped % 60;
|
||||
if (mins > 0) {
|
||||
return `${mins}m ${secs.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
const StoreSyncOverlay = ({ state }) => {
|
||||
if (!state?.active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percent = Math.round(state.percent || 0);
|
||||
const backgroundColor = state.block ? 'rgba(255,255,255,0.95)' : 'rgba(15,23,42,0.4)';
|
||||
const etaLabel = formatEta(state.etaSeconds);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center px-4" style={{ backgroundColor }}>
|
||||
<div className="bg-white shadow-2xl rounded-lg p-6 w-full max-w-md">
|
||||
<p className="text-gray-800 font-semibold mb-3">{state.message || 'Synchronisiere...'}</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="h-3 bg-blue-600 transition-all duration-300 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 text-sm text-gray-500">
|
||||
<span>{percent}%</span>
|
||||
<span>{etaLabel ? `ETA ~ ${etaLabel}` : 'Bitte warten...'}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Die Verzögerung schützt vor Rate-Limits während die Betriebe geprüft werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoreSyncOverlay;
|
||||
Reference in New Issue
Block a user