Files
Pickup-Config/src/App.js
2025-11-09 13:50:17 +01:00

1132 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback, useMemo } from 'react';
import './App.css';
const emptyEntry = {
id: '',
label: '',
active: false,
checkProfileId: true,
onlyNotify: false
};
const TOKEN_STORAGE_KEY = 'pickupConfigToken';
function App() {
const [session, setSession] = useState(null);
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [config, setConfig] = useState([]);
const [stores, setStores] = useState([]);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState('');
const [error, setError] = useState('');
const [newEntry, setNewEntry] = useState(emptyEntry);
const [showNewEntryForm, setShowNewEntryForm] = useState(false);
const [availableCollapsed, setAvailableCollapsed] = useState(true);
const [adminSettings, setAdminSettings] = useState(null);
const [adminSettingsLoading, setAdminSettingsLoading] = useState(false);
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
const normalizeAdminSettings = useCallback((raw) => {
if (!raw) {
return null;
}
return {
scheduleCron: raw.scheduleCron || '',
randomDelayMinSeconds: raw.randomDelayMinSeconds ?? '',
randomDelayMaxSeconds: raw.randomDelayMaxSeconds ?? '',
initialDelayMinSeconds: raw.initialDelayMinSeconds ?? '',
initialDelayMaxSeconds: raw.initialDelayMaxSeconds ?? '',
ignoredSlots: Array.isArray(raw.ignoredSlots)
? raw.ignoredSlots.map((slot) => ({
storeId: slot?.storeId ? String(slot.storeId) : '',
description: slot?.description || ''
}))
: []
};
}, []);
const resetSessionState = useCallback(() => {
setSession(null);
setConfig([]);
setStores([]);
setStatus('');
setError('');
setShowNewEntryForm(false);
setNewEntry(emptyEntry);
setAdminSettings(null);
setAdminSettingsLoading(false);
setAvailableCollapsed(true);
}, []);
const handleUnauthorized = useCallback(() => {
resetSessionState();
try {
localStorage.removeItem(TOKEN_STORAGE_KEY);
} catch (storageError) {
console.warn('Konnte Token nicht aus dem Speicher entfernen:', storageError);
}
}, [resetSessionState]);
const bootstrapSession = useCallback(
async (token) => {
setLoading(true);
setError('');
try {
const response = await fetch('/api/auth/session', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.status === 401) {
handleUnauthorized();
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setSession({ token, profile: data.profile, isAdmin: data.isAdmin });
setStores(Array.isArray(data.stores) ? data.stores : []);
setAdminSettings(data.isAdmin ? normalizeAdminSettings(data.adminSettings) : null);
const configResponse = await fetch('/api/config', {
headers: { Authorization: `Bearer ${token}` }
});
if (configResponse.status === 401) {
handleUnauthorized();
return;
}
if (!configResponse.ok) {
throw new Error(`HTTP ${configResponse.status}`);
}
const configData = await configResponse.json();
setConfig(Array.isArray(configData) ? configData : []);
} catch (err) {
setError(`Session konnte nicht wiederhergestellt werden: ${err.message}`);
} finally {
setLoading(false);
}
},
[handleUnauthorized, normalizeAdminSettings]
);
useEffect(() => {
try {
const storedToken = localStorage.getItem(TOKEN_STORAGE_KEY);
if (storedToken) {
bootstrapSession(storedToken);
}
} catch (err) {
console.warn('Konnte gespeicherten Token nicht lesen:', err);
}
}, [bootstrapSession]);
const authorizedFetch = useCallback(
async (url, options = {}, tokenOverride) => {
const activeToken = tokenOverride || session?.token;
if (!activeToken) {
throw new Error('Keine aktive Session');
}
const headers = {
Authorization: `Bearer ${activeToken}`,
...(options.headers || {})
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
handleUnauthorized();
throw new Error('Nicht autorisiert');
}
return response;
},
[handleUnauthorized, session?.token]
);
useEffect(() => {
if (!session?.token || !session.isAdmin) {
setAdminSettings(null);
setAdminSettingsLoading(false);
return;
}
let cancelled = false;
setAdminSettingsLoading(true);
(async () => {
try {
const response = await authorizedFetch('/api/admin/settings');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (!cancelled) {
setAdminSettings(normalizeAdminSettings(data));
}
} catch (err) {
if (!cancelled) {
setError(`Admin-Einstellungen konnten nicht geladen werden: ${err.message}`);
}
} finally {
if (!cancelled) {
setAdminSettingsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [session?.token, session?.isAdmin, authorizedFetch, normalizeAdminSettings]);
const handleLogin = async (event) => {
event.preventDefault();
setLoading(true);
setError('');
setStatus('');
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
try {
localStorage.setItem(TOKEN_STORAGE_KEY, data.token);
} catch (storageError) {
console.warn('Konnte Token nicht speichern:', storageError);
}
setSession({ token: data.token, profile: data.profile, isAdmin: data.isAdmin });
setConfig(Array.isArray(data.config) ? data.config : []);
setStores(Array.isArray(data.stores) ? data.stores : []);
setAdminSettings(data.isAdmin ? normalizeAdminSettings(data.adminSettings) : null);
setStatus('Anmeldung erfolgreich. Konfiguration geladen.');
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setError(`Login fehlgeschlagen: ${err.message}`);
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
if (!session?.token) {
handleUnauthorized();
return;
}
try {
await authorizedFetch('/api/auth/logout', { method: 'POST' });
} catch (err) {
console.warn('Logout fehlgeschlagen:', err);
} finally {
handleUnauthorized();
}
};
const fetchConfig = async (tokenOverride, { silent = false } = {}) => {
const tokenToUse = tokenOverride || session?.token;
if (!tokenToUse) {
return;
}
if (!silent) {
setStatus('');
}
setLoading(true);
setError('');
try {
const response = await authorizedFetch('/api/config', {}, tokenToUse);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setConfig(Array.isArray(data) ? data : []);
if (!silent) {
setStatus('Konfiguration aktualisiert.');
setTimeout(() => setStatus(''), 3000);
}
} catch (err) {
setError(`Fehler beim Laden der Konfiguration: ${err.message}`);
} finally {
setLoading(false);
}
};
const fetchStoresList = async () => {
if (!session?.token) {
return;
}
setStatus('');
setError('');
try {
const response = await authorizedFetch('/api/stores');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setStores(Array.isArray(data) ? data : []);
await fetchConfig(undefined, { silent: true });
setStatus('Betriebe aktualisiert.');
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setError(`Fehler beim Laden der Betriebe: ${err.message}`);
}
};
const saveConfig = async () => {
if (!session?.token) {
return;
}
setStatus('Speichere...');
setError('');
try {
const response = await authorizedFetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.success) {
setStatus('Konfiguration erfolgreich gespeichert!');
setTimeout(() => setStatus(''), 3000);
} else {
throw new Error(result.error || 'Unbekannter Fehler beim Speichern');
}
} catch (err) {
setError(`Fehler beim Speichern: ${err.message}`);
setStatus('');
}
};
const persistConfigUpdate = async (updater, successMessage) => {
if (!session?.token) {
return;
}
let nextConfigState;
setConfig((prev) => {
nextConfigState = typeof updater === 'function' ? updater(prev) : updater;
return nextConfigState;
});
if (!nextConfigState) {
return;
}
setStatus('Speichere...');
setError('');
try {
const response = await authorizedFetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nextConfigState)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Unbekannter Fehler beim Speichern');
}
const message = successMessage || 'Konfiguration gespeichert.';
setStatus(message);
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setError(`Fehler beim Speichern: ${err.message}`);
}
};
const addEntry = () => {
if (!newEntry.id || !newEntry.label) {
setError('ID und Bezeichnung müssen ausgefüllt werden!');
return;
}
const normalized = {
...newEntry,
id: String(newEntry.id),
hidden: false
};
const updatedConfig = [...config.filter((item) => item.id !== normalized.id), normalized];
setConfig(updatedConfig);
setNewEntry(emptyEntry);
setShowNewEntryForm(false);
setStatus('Neuer Eintrag hinzugefügt!');
setTimeout(() => setStatus(''), 3000);
};
const deleteEntry = (entryId) => {
if (window.confirm('Soll dieser Eintrag dauerhaft gelöscht werden?')) {
const updatedConfig = config.filter((item) => item.id !== entryId);
setConfig(updatedConfig);
setStatus('Eintrag gelöscht!');
setTimeout(() => setStatus(''), 3000);
}
};
const hideEntry = async (entryId) => {
if (!window.confirm('Soll dieser Betrieb ausgeblendet werden?')) {
return;
}
await persistConfigUpdate(
(prev) => prev.map((item) => (item.id === entryId ? { ...item, hidden: true } : item)),
'Betrieb ausgeblendet.'
);
};
const handleToggleActive = (entryId) => {
setConfig((prev) =>
prev.map((item) =>
item.id === entryId ? { ...item, active: !item.active } : item
)
);
};
const handleToggleProfileCheck = (entryId) => {
setConfig((prev) =>
prev.map((item) =>
item.id === entryId ? { ...item, checkProfileId: !item.checkProfileId } : item
)
);
};
const handleToggleOnlyNotify = (entryId) => {
setConfig((prev) =>
prev.map((item) =>
item.id === entryId ? { ...item, onlyNotify: !item.onlyNotify } : item
)
);
};
const handleWeekdayChange = (entryId, value) => {
setConfig((prev) =>
prev.map((item) => {
if (item.id !== entryId) {
return item;
}
const updated = { ...item, desiredWeekday: value || null };
if (value && updated.desiredDate) {
delete updated.desiredDate;
}
return updated;
})
);
};
const handleDateChange = (entryId, value) => {
setConfig((prev) =>
prev.map((item) => {
if (item.id !== entryId) {
return item;
}
const updated = { ...item, desiredDate: value || null };
if (value && updated.desiredWeekday) {
delete updated.desiredWeekday;
}
return updated;
})
);
};
const handleNewEntryChange = (event) => {
const { name, value, type, checked } = event.target;
setNewEntry({
...newEntry,
[name]: type === 'checkbox' ? checked : value
});
};
const configMap = useMemo(() => {
const map = new Map();
config.forEach((item) => {
if (item?.id) {
map.set(String(item.id), item);
}
});
return map;
}, [config]);
const visibleConfig = useMemo(() => config.filter((item) => !item.hidden), [config]);
const handleStoreSelection = async (store) => {
const storeId = String(store.id);
const existing = configMap.get(storeId);
if (existing && !existing.hidden) {
return;
}
if (!window.confirm(`Soll der Betrieb "${store.name}" zur Liste hinzugefügt werden?`)) {
return;
}
const message = existing ? 'Betrieb wieder eingeblendet.' : 'Betrieb zur Liste hinzugefügt.';
await persistConfigUpdate(
(prev) => {
const already = prev.find((item) => item.id === storeId);
if (already) {
return prev.map((item) =>
item.id === storeId
? {
...item,
hidden: false,
label: item.label || store.name || `Store ${storeId}`
}
: item
);
}
return [
...prev,
{
id: storeId,
label: store.name || `Store ${storeId}`,
active: false,
checkProfileId: true,
onlyNotify: false,
hidden: false
}
];
},
message
);
};
const handleAdminSettingChange = (field, value, isNumber = false) => {
setAdminSettings((prev) => {
if (!prev) {
return prev;
}
let nextValue = value;
if (isNumber) {
nextValue = value === '' ? '' : Number(value);
}
return {
...prev,
[field]: nextValue
};
});
};
const handleIgnoredSlotChange = (index, field, value) => {
setAdminSettings((prev) => {
if (!prev) {
return prev;
}
const slots = [...(prev.ignoredSlots || [])];
slots[index] = {
...slots[index],
[field]: field === 'storeId' ? value : value
};
return {
...prev,
ignoredSlots: slots
};
});
};
const addIgnoredSlot = () => {
setAdminSettings((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
ignoredSlots: [...(prev.ignoredSlots || []), { storeId: '', description: '' }]
};
});
};
const removeIgnoredSlot = (index) => {
setAdminSettings((prev) => {
if (!prev) {
return prev;
}
const slots = [...(prev.ignoredSlots || [])];
slots.splice(index, 1);
return {
...prev,
ignoredSlots: slots
};
});
};
const saveAdminSettings = async () => {
if (!session?.token || !session.isAdmin || !adminSettings) {
return;
}
setStatus('Admin-Einstellungen werden gespeichert...');
setError('');
const toNumber = (value) => {
if (value === '' || value === null || value === undefined) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
try {
const payload = {
scheduleCron: adminSettings.scheduleCron,
randomDelayMinSeconds: toNumber(adminSettings.randomDelayMinSeconds),
randomDelayMaxSeconds: toNumber(adminSettings.randomDelayMaxSeconds),
initialDelayMinSeconds: toNumber(adminSettings.initialDelayMinSeconds),
initialDelayMaxSeconds: toNumber(adminSettings.initialDelayMaxSeconds),
ignoredSlots: (adminSettings.ignoredSlots || []).map((slot) => ({
storeId: slot.storeId || '',
description: slot.description || ''
}))
};
const response = await authorizedFetch('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setAdminSettings(normalizeAdminSettings(data));
setStatus('Admin-Einstellungen gespeichert.');
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setError(`Speichern der Admin-Einstellungen fehlgeschlagen: ${err.message}`);
}
};
if (!session?.token) {
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={handleLogin}>
<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={(e) => setCredentials({ ...credentials, email: e.target.value })}
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={(e) => setCredentials({ ...credentials, password: e.target.value })}
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}
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 ? 'Prüfe Zugang...' : 'Anmelden'}
</button>
</form>
</div>
);
}
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-100">
<div className="text-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-xl">Lade Daten...</p>
</div>
</div>
);
}
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">
<button
onClick={fetchStoresList}
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={handleLogout}
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>
</div>
</div>
</div>
<div className="mb-6 border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setAvailableCollapsed((prev) => !prev)}
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-96 overflow-y-auto p-4 grid grid-cols-1 md:grid-cols-2 gap-3 bg-white">
{stores.length === 0 && (
<div className="text-sm text-gray-500">Noch keine Betriebe geladen. Aktualisiere nach dem Login.</div>
)}
{stores.map((store) => {
const storeId = String(store.id);
const entry = configMap.get(storeId);
const isVisible = entry && !entry.hidden;
const needsRestore = entry && entry.hidden;
let statusLabel = 'Hinzufügen';
if (isVisible) {
statusLabel = 'Bereits in Konfiguration';
} else if (needsRestore) {
statusLabel = 'Ausgeblendet erneut hinzufügen';
}
return (
<button
key={storeId}
onClick={() => handleStoreSelection(store)}
disabled={isVisible}
className={`text-left border rounded p-3 shadow-sm transition focus:outline-none focus:ring-2 focus:ring-blue-300 ${
isVisible ? 'bg-gray-50 cursor-not-allowed opacity-70 border-gray-200' : 'bg-white hover:border-blue-400'
}`}
>
<p className="font-semibold text-gray-800">{store.name}</p>
<p className="text-xs text-gray-500">ID: {storeId}</p>
{store.city && (
<p className="text-xs text-gray-500">
{store.zip || ''} {store.city}
</p>
)}
<p className={`text-xs mt-2 ${isVisible ? 'text-gray-500' : 'text-blue-600'}`}>{statusLabel}</p>
</button>
);
})}
</div>
)}
</div>
<div className="flex justify-center mb-4 space-x-4">
<a
href="https://foodsharing.de/?page=dashboard"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Foodsharing Dashboard
</a>
</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={() => setError('')}>
<span className="text-xl">&times;</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 mb-6">
<table className="min-w-full bg-white border border-gray-200 rounded-lg overflow-hidden">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-2 border-b">Aktiv</th>
<th className="px-4 py-2 border-b">Geschäft</th>
<th className="px-4 py-2 border-b">Profil prüfen</th>
<th className="px-4 py-2 border-b">Nur benachrichtigen</th>
<th className="px-4 py-2 border-b">Wochentag</th>
<th className="px-4 py-2 border-b">Spezifisches Datum</th>
<th className="px-4 py-2 border-b">Aktionen</th>
</tr>
</thead>
<tbody>
{visibleConfig.length === 0 && (
<tr>
<td colSpan="7" className="px-4 py-6 text-center text-sm text-gray-500">
Keine sichtbaren Einträge. Nutze Verfügbare Betriebe, um Betriebe hinzuzufügen oder ausgeblendete Einträge zurückzuholen.
</td>
</tr>
)}
{visibleConfig.map((item, index) => (
<tr key={`${item.id}-${index}`} className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'}>
<td className="px-4 py-2 border-b text-center">
<input
type="checkbox"
checked={item.active || false}
onChange={() => handleToggleActive(item.id)}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
</td>
<td className="px-4 py-2 border-b">
<div>
<span className="font-medium">{item.label}</span>
<br />
<span className="text-sm text-gray-500">ID: {item.id}</span>
</div>
</td>
<td className="px-4 py-2 border-b text-center">
<input
type="checkbox"
checked={item.checkProfileId || false}
onChange={() => handleToggleProfileCheck(item.id)}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
</td>
<td className="px-4 py-2 border-b text-center">
<input
type="checkbox"
checked={item.onlyNotify || false}
onChange={() => handleToggleOnlyNotify(item.id)}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
/>
</td>
<td className="px-4 py-2 border-b">
<select
value={item.desiredWeekday || ''}
onChange={(e) => handleWeekdayChange(item.id, e.target.value)}
className="border rounded p-2 w-full bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={item.desiredDate}
>
<option value="">Kein Wochentag</option>
{weekdays.map((day) => (
<option key={day} value={day}>
{day}
</option>
))}
</select>
</td>
<td className="px-4 py-2 border-b">
<input
type="date"
value={item.desiredDate || ''}
onChange={(e) => handleDateChange(item.id, e.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={item.desiredWeekday}
/>
</td>
<td className="px-4 py-2 border-b">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => hideEntry(item.id)}
className="bg-yellow-100 hover:bg-yellow-200 text-yellow-800 rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-yellow-400"
title="Ausblenden"
>
<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="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.03-11-7 1.148-3.008 4.514-6 9-6 .824 0 1.627.087 2.4.252M15 12a3 3 0 11-6 0 3 3 0 016 0zm6.121 5.121L4.879 4.879" />
</svg>
</button>
<button
onClick={() => deleteEntry(item.id)}
className="bg-red-500 hover:bg-red-600 text-white rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-red-500"
title="Löschen"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{session?.isAdmin && (
<div className="mb-6 border border-purple-200 rounded-lg p-4 bg-purple-50">
<h2 className="text-lg font-semibold text-purple-900 mb-4">Admin-Einstellungen</h2>
{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={(e) => handleAdminSettingChange('scheduleCron', e.target.value)}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</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={(e) => handleAdminSettingChange('initialDelayMinSeconds', e.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={(e) => handleAdminSettingChange('initialDelayMaxSeconds', e.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={(e) => handleAdminSettingChange('randomDelayMinSeconds', e.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={(e) => handleAdminSettingChange('randomDelayMaxSeconds', e.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>
<div className="border border-purple-200 rounded-lg bg-white p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-purple-900">Ignorierte Slots</h3>
<button
type="button"
onClick={addIgnoredSlot}
className="text-sm bg-purple-600 hover:bg-purple-700 text-white px-3 py-1 rounded focus:outline-none focus:ring-2 focus:ring-purple-400"
>
Regel 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={(e) => handleIgnoredSlotChange(index, 'storeId', e.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={(e) => handleIgnoredSlotChange(index, 'description', e.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={() => removeIgnoredSlot(index)}
className="text-sm text-red-600 hover:text-red-800 focus:outline-none"
>
Entfernen
</button>
</div>
))}
</div>
<div className="flex justify-end mt-4">
<button
type="button"
onClick={saveAdminSettings}
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>
)}
{showNewEntryForm ? (
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 mb-6">
<h2 className="text-lg font-semibold mb-4">Neuen Eintrag hinzufügen</h2>
<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">ID *</label>
<input
type="text"
name="id"
value={newEntry.id}
onChange={handleNewEntryChange}
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">Bezeichnung *</label>
<input
type="text"
name="label"
value={newEntry.label}
onChange={handleNewEntryChange}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
</div>
<div className="flex flex-wrap gap-6 mb-4">
<label className="flex items-center">
<input
type="checkbox"
name="active"
checked={newEntry.active}
onChange={handleNewEntryChange}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500 mr-2"
/>
<span className="text-sm font-medium text-gray-700">Aktiv</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
name="checkProfileId"
checked={newEntry.checkProfileId}
onChange={handleNewEntryChange}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500 mr-2"
/>
<span className="text-sm font-medium text-gray-700">Profil prüfen</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
name="onlyNotify"
checked={newEntry.onlyNotify}
onChange={handleNewEntryChange}
className="h-5 w-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500 mr-2"
/>
<span className="text-sm font-medium text-gray-700">Nur benachrichtigen</span>
</label>
</div>
<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">Wochentag</label>
<select
name="desiredWeekday"
value={newEntry.desiredWeekday || ''}
onChange={handleNewEntryChange}
className="border rounded p-2 w-full bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Kein Wochentag</option>
{weekdays.map((day) => (
<option key={day} value={day}>
{day}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Spezifisches Datum</label>
<input
type="date"
name="desiredDate"
value={newEntry.desiredDate || ''}
onChange={handleNewEntryChange}
className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={newEntry.desiredWeekday}
/>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
onClick={() => setShowNewEntryForm(false)}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Abbrechen
</button>
<button
onClick={addEntry}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Hinzufügen
</button>
</div>
</div>
) : (
<button
onClick={() => setShowNewEntryForm(true)}
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded mb-6 focus:outline-none focus:ring-2 focus:ring-green-500"
>
Neuen Eintrag hinzufügen
</button>
)}
<div className="flex justify-between mb-6">
<button
onClick={fetchConfig}
className="bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors"
>
Aktualisieren
</button>
<button
onClick={saveConfig}
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"
>
Konfiguration speichern
</button>
</div>
</div>
);
}
export default App;