902 lines
27 KiB
JavaScript
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;
|