Files
Pickup-Config/src/App.js
2026-01-29 18:17:13 +01:00

902 lines
27 KiB
JavaScript

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 (
<>
<LoginView
credentials={credentials}
onCredentialsChange={setCredentials}
error={error}
loading={loading}
initializing={initializing}
onSubmit={handleLogin}
/>
<StoreSyncOverlay state={syncProgress} />
</>
);
}
if (initializing) {
return (
<>
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center p-8 bg-white rounded-lg shadow">
<p className="text-xl font-semibold text-gray-800 mb-2">Initialisiere...</p>
<p className="text-gray-500">Bitte warte, bis alle Betriebe geprüft wurden.</p>
</div>
</div>
<StoreSyncOverlay state={syncProgress} />
</>
);
}
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>
<StoreSyncOverlay state={syncProgress} />
</>
);
}
const dashboardContent = (
<DashboardView
session={session}
onRefresh={refreshStoresAndConfig}
onLogout={handleLogout}
notificationPanelOpen={notificationPanelOpen}
onToggleNotificationPanel={() => 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 ? (
<AdminSettingsPanel
adminSettings={adminSettings}
adminSettingsLoading={adminSettingsLoading}
status={status}
error={error}
onDismissError={() => setError('')}
onSettingChange={handleAdminSettingChange}
onIgnoredSlotChange={handleIgnoredSlotChange}
onAddIgnoredSlot={addIgnoredSlot}
onRemoveIgnoredSlot={removeIgnoredSlot}
onNotificationChange={handleAdminNotificationChange}
onSave={saveAdminSettings}
/>
) : (
<AdminAccessMessage />
);
return (
<Router>
<>
<div className="min-h-screen bg-gray-100 py-6">
<div className="max-w-6xl mx-auto px-4">
<NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} />
<Routes>
<Route path="/" element={dashboardContent} />
<Route
path="/store-watch"
element={
<StoreWatchPage
authorizedFetch={authorizedFetch}
knownStores={stores}
userLocation={userLocationWithLabel}
locationLoading={preferencesLoading}
locationError={preferencesError}
notificationPanelOpen={notificationPanelOpen}
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
notificationProps={sharedNotificationProps}
isAdmin={Boolean(session?.isAdmin)}
/>
}
/>
<Route
path="/journal"
element={<JournalPage authorizedFetch={authorizedFetch} stores={stores} />}
/>
<Route
path="/debug"
element={
session?.isAdmin ? <DebugPage authorizedFetch={authorizedFetch} /> : <AdminAccessMessage />
}
/>
<Route path="/admin" element={adminPageContent} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</div>
<StoreSyncOverlay state={syncProgress} />
<DirtyNavigationDialog
open={dirtyDialogState.open}
message={dirtyDialogState.message}
onSave={handleDirtySave}
onDiscard={handleDirtyDiscard}
onCancel={handleDirtyCancel}
saving={dirtyDialogState.saving}
/>
<ConfirmationDialog
open={!!confirmDialog.open}
title={confirmDialog.title}
message={confirmDialog.message}
confirmLabel={confirmDialog.confirmLabel}
cancelLabel={confirmDialog.cancelLabel}
confirmTone={confirmDialog.confirmTone}
onConfirm={() => handleConfirmDialog(true)}
onCancel={() => handleConfirmDialog(false)}
/>
<RangePickerModal
entry={activeRangeEntry}
minDate={minSelectableDate}
onSelectRange={(startDate, endDate) =>
activeRangeEntry ? handleDateRangeSelection(activeRangeEntry.id, startDate, endDate) : null
}
onResetRange={() => {
if (activeRangeEntry) {
handleDateRangeSelection(activeRangeEntry.id, null, null);
}
setActiveRangePicker(null);
}}
onClose={() => setActiveRangePicker(null)}
/>
</>
</Router>
);
}
export default App;