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 (
<>
Initialisiere...
Bitte warte, bis alle Betriebe geprüft wurden.
Lade Daten...