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'; 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 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, handleUnauthorized, 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, saving: locationSaving, error: preferencesError, updateLocation } = useUserPreferences({ authorizedFetch, sessionToken: session?.token }); const { fetchConfig, syncStoresWithProgress, refreshStoresAndConfig } = useStoreSync({ sessionToken: session?.token, authorizedFetch, setStatus, setError, setLoading, setStores, setConfig, normalizeConfigEntries, setIsDirty, startSyncProgress, updateSyncProgress, finishSyncProgress }); 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 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]); 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 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 separator = prefix && sanitizedTopic ? '-' : ''; const slug = `${prefix}${separator}${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, hidden: false } ]; }, message, { autoCommit: true } ); setFocusedStoreId(storeId); }; 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={{ 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 }} stores={stores} availableCollapsed={availableCollapsed} onToggleStores={() => setAvailableCollapsed((prev) => !prev)} onStoreSelect={handleStoreSelection} configMap={configMap} error={error} onDismissError={() => setError('')} status={status} visibleConfig={visibleConfig} config={config} onToggleActive={handleToggleActive} onToggleProfileCheck={handleToggleProfileCheck} onToggleOnlyNotify={handleToggleOnlyNotify} 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={preferences?.location || null} locationLoading={preferencesLoading} locationSaving={locationSaving} locationError={preferencesError} onUpdateLocation={updateLocation} /> ); const adminPageContent = session?.isAdmin ? ( setError('')} onSettingChange={handleAdminSettingChange} onIgnoredSlotChange={handleIgnoredSlotChange} onAddIgnoredSlot={addIgnoredSlot} onRemoveIgnoredSlot={removeIgnoredSlot} onNotificationChange={handleAdminNotificationChange} onSave={saveAdminSettings} /> ) : ( ); return ( <>
} /> } />
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;