1132 lines
42 KiB
JavaScript
1132 lines
42 KiB
JavaScript
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">×</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;
|