import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { startOfDay } from 'date-fns'; import './App.css'; import 'react-date-range/dist/styles.css'; import 'react-date-range/dist/theme/default.css'; import { formatDateValue, formatRangeLabel } from './utils/dateUtils'; import useSyncProgress from './hooks/useSyncProgress'; import useNotificationSettings from './hooks/useNotificationSettings'; import useConfigManager from './hooks/useConfigManager'; import useStoreSync from './hooks/useStoreSync'; import useDirtyNavigationGuard from './hooks/useDirtyNavigationGuard'; import useSessionManager from './hooks/useSessionManager'; import useAdminSettings from './hooks/useAdminSettings'; import useUserPreferences from './hooks/useUserPreferences'; import NavigationTabs from './components/NavigationTabs'; import LoginView from './components/LoginView'; import DashboardView from './components/DashboardView'; import AdminSettingsPanel from './components/AdminSettingsPanel'; import AdminAccessMessage from './components/AdminAccessMessage'; import DirtyNavigationDialog from './components/DirtyNavigationDialog'; import ConfirmationDialog from './components/ConfirmationDialog'; import StoreSyncOverlay from './components/StoreSyncOverlay'; import RangePickerModal from './components/RangePickerModal'; import StoreWatchPage from './components/StoreWatchPage'; import DebugPage from './components/DebugPage'; import JournalPage from './components/JournalPage'; function App() { const [credentials, setCredentials] = useState({ email: '', password: '' }); const [stores, setStores] = useState([]); const [loading, setLoading] = useState(false); const [status, setStatus] = useState(''); const [error, setError] = useState(''); const [availableCollapsed, setAvailableCollapsed] = useState(true); const [initializing, setInitializing] = useState(false); const [activeRangePicker, setActiveRangePicker] = useState(null); const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null }); const [notificationPanelOpen, setNotificationPanelOpen] = useState(false); const [focusedStoreId, setFocusedStoreId] = useState(null); const [nearestStoreLabel, setNearestStoreLabel] = useState(null); const [regularPickupMap, setRegularPickupMap] = useState({}); const minSelectableDate = useMemo(() => startOfDay(new Date()), []); const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']; const unauthorizedResetRef = useRef(() => {}); const notifyUnauthorized = useCallback(() => { unauthorizedResetRef.current?.(); }, []); const normalizeConfigEntries = useCallback((entries) => { if (!Array.isArray(entries)) { return []; } return entries.map((entry) => { if (!entry || typeof entry !== 'object') { return entry; } const normalized = { ...entry }; if (normalized.desiredDate) { if (normalized.desiredDate) { normalized.desiredDateRange = { start: normalized.desiredDate, end: normalized.desiredDate }; } delete normalized.desiredDate; } if (normalized.desiredDateRange) { const startValue = normalized.desiredDateRange.start || null; const endValue = normalized.desiredDateRange.end || null; const normalizedStart = startValue || endValue || null; const normalizedEnd = endValue || startValue || null; if (!normalizedStart && !normalizedEnd) { delete normalized.desiredDateRange; } else { normalized.desiredDateRange = { start: normalizedStart, end: normalizedEnd }; } } return normalized; }); }, []); const { syncProgress, startSyncProgress, updateSyncProgress, finishSyncProgress, nudgeSyncProgress } = useSyncProgress(); const { session, authorizedFetch, bootstrapSession, performLogout, storeToken, getStoredToken } = useSessionManager({ normalizeConfigEntries, onUnauthorized: notifyUnauthorized, setError, setLoading }); const { config, setConfig, isDirty, setIsDirty, persistConfigUpdate, saveConfig } = useConfigManager({ sessionToken: session?.token, authorizedFetch, setStatus, setError, setLoading }); const { preferences, loading: preferencesLoading, error: preferencesError } = useUserPreferences({ authorizedFetch, sessionToken: session?.token }); const { fetchConfig, syncStoresWithProgress, refreshStoresAndConfig } = useStoreSync({ sessionToken: session?.token, authorizedFetch, setStatus, setError, setLoading, setStores, setConfig, normalizeConfigEntries, setIsDirty, startSyncProgress, updateSyncProgress, finishSyncProgress }); useEffect(() => { let aborted = false; async function lookupNearestStore() { if ( !authorizedFetch || !preferences?.location || !Number.isFinite(preferences.location.lat) || !Number.isFinite(preferences.location.lon) ) { setNearestStoreLabel(null); return; } try { const params = new URLSearchParams({ lat: String(preferences.location.lat), lon: String(preferences.location.lon) }); const response = await authorizedFetch(`/api/location/nearest-store?${params.toString()}`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); if (!aborted) { setNearestStoreLabel( data.store ? { label: data.store.label, distanceKm: data.store.distanceKm } : null ); } } catch (error) { if (!aborted) { setNearestStoreLabel(null); console.error('Standortsuche fehlgeschlagen:', error.message); } } } lookupNearestStore(); return () => { aborted = true; }; }, [authorizedFetch, preferences?.location?.lat, preferences?.location?.lon]); const { adminSettings, adminSettingsLoading, setAdminSettingsSnapshot, clearAdminSettings, handleAdminSettingChange, handleAdminNotificationChange, handleIgnoredSlotChange, addIgnoredSlot, removeIgnoredSlot, saveAdminSettings } = useAdminSettings({ session, authorizedFetch, setStatus, setError }); const { requestNavigation, dialogState: dirtyDialogState, handleDirtySave, handleDirtyDiscard, handleDirtyCancel } = useDirtyNavigationGuard({ isDirty, saveConfig, onDiscard: () => setIsDirty(false) }); const applyBootstrapResult = useCallback( (result = {}) => { if (!result) { return {}; } setStores(Array.isArray(result.stores) ? result.stores : []); setAdminSettingsSnapshot(result.adminSettings ?? null); setConfig(Array.isArray(result.config) ? result.config : []); return result; }, [setStores, setAdminSettingsSnapshot, setConfig] ); const resetSessionState = useCallback(() => { setConfig([]); setStores([]); setStatus(''); setError(''); clearAdminSettings(); setAvailableCollapsed(true); setInitializing(false); }, [ setConfig, setStores, setStatus, setError, clearAdminSettings, setAvailableCollapsed, setInitializing ]); useEffect(() => { unauthorizedResetRef.current = resetSessionState; }, [resetSessionState]); const { notificationSettings, notificationCapabilities, notificationDirty, notificationLoading, notificationSaving, notificationMessage, notificationError, copyFeedback, loadNotificationSettings, handleNotificationFieldChange, saveNotificationSettings, sendNotificationTest, copyToClipboard } = useNotificationSettings({ authorizedFetch, sessionToken: session?.token }); const handleLogin = async (event) => { event.preventDefault(); setLoading(true); setError(''); setStatus(''); setInitializing(true); startSyncProgress('Anmeldung wird geprüft...', 5, true); const ticker = setInterval(() => nudgeSyncProgress('Anmeldung wird geprüft...', 2, 40), 1000); 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(); storeToken(data.token); clearInterval(ticker); updateSyncProgress('Zugang bestätigt. Session wird aufgebaut...', 45); const bootstrapResult = applyBootstrapResult( await bootstrapSession(data.token, { progress: { update: updateSyncProgress } }) ); const needsStoreSync = !bootstrapResult?.storesFresh || !!bootstrapResult?.storeRefreshJob; if (needsStoreSync) { await syncStoresWithProgress({ reason: 'login-auto', startJob: !bootstrapResult?.storeRefreshJob, reuseOverlay: true, block: true, tokenOverride: data.token }); } updateSyncProgress('Login abgeschlossen', 98); setStatus('Anmeldung erfolgreich. Konfiguration geladen.'); setTimeout(() => setStatus(''), 3000); } catch (err) { setError(`Login fehlgeschlagen: ${err.message}`); } finally { clearInterval(ticker); finishSyncProgress(); setInitializing(false); setLoading(false); } }; const handleLogout = () => { requestNavigation('dich abzumelden', performLogout); }; const askConfirmation = useCallback( (options = {}) => new Promise((resolve) => { setConfirmDialog({ open: true, title: options.title || 'Bitte bestätigen', message: options.message || 'Bist du sicher?', confirmLabel: options.confirmLabel || 'Ja', cancelLabel: options.cancelLabel || 'Abbrechen', confirmTone: options.confirmTone || 'primary', resolve }); }), [] ); const handleConfirmDialog = useCallback((result) => { setConfirmDialog((prev) => { if (prev?.resolve) { prev.resolve(result); } return { open: false }; }); }, []); useEffect(() => { let ticker; let cancelled = false; (async () => { const storedToken = getStoredToken(); if (!storedToken) { return; } setInitializing(true); try { startSyncProgress('Session wird wiederhergestellt...', 5, true); ticker = setInterval(() => nudgeSyncProgress('Session wird wiederhergestellt...', 1, 40), 1000); const result = applyBootstrapResult( await bootstrapSession(storedToken, { progress: { update: updateSyncProgress } }) ); if (ticker) { clearInterval(ticker); ticker = null; } if (!cancelled) { const needsStoreSync = !result?.storesFresh || !!result?.storeRefreshJob; if (needsStoreSync) { await syncStoresWithProgress({ reason: 'session-auto', startJob: !result?.storeRefreshJob, reuseOverlay: true, block: true, tokenOverride: storedToken }); } } finishSyncProgress(); } catch (err) { console.warn('Konnte Session nicht wiederherstellen:', err); finishSyncProgress(); } finally { setInitializing(false); } })(); return () => { cancelled = true; if (ticker) { clearInterval(ticker); } }; }, [ applyBootstrapResult, bootstrapSession, finishSyncProgress, getStoredToken, nudgeSyncProgress, startSyncProgress, syncStoresWithProgress, updateSyncProgress ]); useEffect(() => { const handleBeforeUnload = (event) => { if (!isDirty) { return; } event.preventDefault(); event.returnValue = ''; return ''; }; window.addEventListener('beforeunload', handleBeforeUnload); return () => { window.removeEventListener('beforeunload', handleBeforeUnload); }; }, [isDirty]); const deleteEntry = async (entryId) => { const confirmed = await askConfirmation({ title: 'Eintrag löschen', message: 'Soll dieser Eintrag dauerhaft gelöscht werden?', confirmLabel: 'Löschen', confirmTone: 'danger' }); if (!confirmed) { return; } await persistConfigUpdate( (prev) => prev.filter((item) => item.id !== entryId), 'Eintrag gelöscht!', { autoCommit: true } ); }; const hideEntry = async (entryId) => { const confirmed = await askConfirmation({ title: 'Betrieb ausblenden', message: 'Soll dieser Betrieb ausgeblendet werden?', confirmLabel: 'Ausblenden' }); if (!confirmed) { return; } await persistConfigUpdate( (prev) => prev.map((item) => (item.id === entryId ? { ...item, hidden: true } : item)), 'Betrieb ausgeblendet.', { autoCommit: true } ); }; const handleToggleActive = (entryId) => { setIsDirty(true); setConfig((prev) => prev.map((item) => item.id === entryId ? { ...item, active: !item.active } : item ) ); }; const handleToggleProfileCheck = (entryId) => { setIsDirty(true); setConfig((prev) => prev.map((item) => item.id === entryId ? { ...item, checkProfileId: !item.checkProfileId } : item ) ); }; const handleToggleOnlyNotify = (entryId) => { setIsDirty(true); setConfig((prev) => prev.map((item) => item.id === entryId ? { ...item, onlyNotify: !item.onlyNotify } : item ) ); }; const handleToggleDormantSkip = (entryId) => { setIsDirty(true); setConfig((prev) => prev.map((item) => item.id === entryId ? { ...item, skipDormantCheck: !item.skipDormantCheck } : item ) ); }; const handleWeekdayChange = (entryId, value) => { setIsDirty(true); setConfig((prev) => prev.map((item) => { if (item.id !== entryId) { return item; } return { ...item, desiredWeekday: value || null }; }) ); if (value) { setActiveRangePicker((prev) => (prev === entryId ? null : prev)); } }; const handleDateRangeSelection = useCallback( (entryId, startDate, endDate) => { setIsDirty(true); setConfig((prev) => prev.map((item) => { if (item.id !== entryId) { return item; } const updated = { ...item }; const startValue = formatDateValue(startDate); const endValue = formatDateValue(endDate); if (startValue || endValue) { updated.desiredDateRange = { start: startValue || endValue, end: endValue || startValue }; } else if (updated.desiredDateRange) { delete updated.desiredDateRange; } if (updated.desiredDate) { delete updated.desiredDate; } return updated; }) ); }, [setConfig, setIsDirty] ); 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]); useEffect(() => { let cancelled = false; if (!session?.token || !authorizedFetch || visibleConfig.length === 0) { return () => { cancelled = true; }; } const uniqueIds = Array.from(new Set(visibleConfig.map((item) => String(item.id)))); const missing = uniqueIds.filter((id) => regularPickupMap[id] === undefined); if (missing.length === 0) { return () => { cancelled = true; }; } const fetchSchedules = async () => { for (const id of missing) { try { const response = await authorizedFetch(`/api/stores/${id}/regular-pickup`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); const rules = Array.isArray(data) ? data : Array.isArray(data?.rules) ? data.rules : []; if (!cancelled) { setRegularPickupMap((prev) => ({ ...prev, [id]: rules })); } } catch (err) { if (!cancelled) { setRegularPickupMap((prev) => ({ ...prev, [id]: [] })); } } } }; fetchSchedules(); return () => { cancelled = true; }; }, [authorizedFetch, regularPickupMap, session?.token, visibleConfig]); const activeRangeEntry = useMemo(() => { if (!activeRangePicker) { return null; } return config.find((item) => item.id === activeRangePicker) || null; }, [activeRangePicker, config]); useEffect(() => { if (!activeRangePicker) { return; } if (!activeRangeEntry) { setActiveRangePicker(null); } }, [activeRangePicker, activeRangeEntry]); const ntfyPreviewUrl = useMemo(() => { if ( !notificationCapabilities.ntfy.enabled || !notificationCapabilities.ntfy.serverUrl || !notificationSettings.ntfy.topic ) { return null; } const server = notificationCapabilities.ntfy.serverUrl.replace(/\/+$/, ''); if (!server) { return null; } const sanitizedTopic = notificationSettings.ntfy.topic.trim().replace(/^-+|-+$/g, ''); if (!sanitizedTopic) { return null; } const prefix = (notificationCapabilities.ntfy.topicPrefix || '').replace(/^-+|-+$/g, ''); const slug = `${prefix}${sanitizedTopic}` || sanitizedTopic || prefix; if (!slug) { return null; } return `${server}/${slug}`; }, [ notificationCapabilities.ntfy.enabled, notificationCapabilities.ntfy.serverUrl, notificationCapabilities.ntfy.topicPrefix, notificationSettings.ntfy.topic ]); const handleStoreSelection = async (store) => { const storeId = String(store.id); const existing = configMap.get(storeId); if (existing && !existing.hidden) { setFocusedStoreId(storeId); return; } const confirmed = await askConfirmation({ title: 'Betrieb hinzufügen', message: `Soll der Betrieb "${store.name}" zur Liste hinzugefügt werden?`, confirmLabel: 'Hinzufügen' }); if (!confirmed) { 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, skipDormantCheck: false, hidden: false } ]; }, message, { autoCommit: true } ); setFocusedStoreId(storeId); }; const userLocationWithLabel = useMemo(() => { if (!preferences?.location) { return null; } if (preferences.location.label) { return preferences.location; } if (nearestStoreLabel) { return { ...preferences.location, label: nearestStoreLabel.label, labelDistanceKm: nearestStoreLabel.distanceKm }; } return { ...preferences.location }; }, [preferences?.location, nearestStoreLabel]); const sharedNotificationProps = { error: notificationError, message: notificationMessage, settings: notificationSettings, capabilities: notificationCapabilities, loading: notificationLoading, dirty: notificationDirty, saving: notificationSaving, onReset: loadNotificationSettings, onSave: saveNotificationSettings, onFieldChange: handleNotificationFieldChange, onSendTest: sendNotificationTest, onCopyLink: copyToClipboard, copyFeedback, ntfyPreviewUrl }; if (!session?.token) { return ( <> ); } if (initializing) { return ( <>

Initialisiere...

Bitte warte, bis alle Betriebe geprüft wurden.

); } if (loading) { return ( <>

Lade Daten...

); } const dashboardContent = ( setNotificationPanelOpen((prev) => !prev)} notificationProps={sharedNotificationProps} stores={stores} availableCollapsed={availableCollapsed} onToggleStores={() => setAvailableCollapsed((prev) => !prev)} onStoreSelect={handleStoreSelection} configMap={configMap} regularPickupMap={regularPickupMap} error={error} onDismissError={() => setError('')} status={status} visibleConfig={visibleConfig} config={config} onToggleActive={handleToggleActive} onToggleProfileCheck={handleToggleProfileCheck} onToggleOnlyNotify={handleToggleOnlyNotify} onToggleDormantSkip={handleToggleDormantSkip} onWeekdayChange={handleWeekdayChange} weekdays={weekdays} onRangePickerRequest={setActiveRangePicker} formatRangeLabel={formatRangeLabel} onSaveConfig={saveConfig} onResetConfig={() => fetchConfig()} onHideEntry={hideEntry} onDeleteEntry={deleteEntry} canDelete={Boolean(session?.isAdmin)} focusedStoreId={focusedStoreId} onClearFocus={() => setFocusedStoreId(null)} userLocation={userLocationWithLabel} locationLoading={preferencesLoading} locationError={preferencesError} /> ); const adminPageContent = session?.isAdmin ? ( setError('')} onSettingChange={handleAdminSettingChange} onIgnoredSlotChange={handleIgnoredSlotChange} onAddIgnoredSlot={addIgnoredSlot} onRemoveIgnoredSlot={removeIgnoredSlot} onNotificationChange={handleAdminNotificationChange} onSave={saveAdminSettings} /> ) : ( ); return ( <>
setNotificationPanelOpen((prev) => !prev)} notificationProps={sharedNotificationProps} isAdmin={Boolean(session?.isAdmin)} /> } /> } /> : } /> } />
handleConfirmDialog(true)} onCancel={() => handleConfirmDialog(false)} /> activeRangeEntry ? handleDateRangeSelection(activeRangeEntry.id, startDate, endDate) : null } onResetRange={() => { if (activeRangeEntry) { handleDateRangeSelection(activeRangeEntry.id, null, null); } setActiveRangePicker(null); }} onClose={() => setActiveRangePicker(null)} />
); } export default App;