refactoring

This commit is contained in:
2025-11-10 13:30:25 +01:00
parent 2e27395ed0
commit 5341a2e4ba
4 changed files with 454 additions and 325 deletions

View File

@@ -7,6 +7,9 @@ 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 NavigationTabs from './components/NavigationTabs';
import LoginView from './components/LoginView';
import DashboardView from './components/DashboardView';
@@ -22,7 +25,6 @@ const TOKEN_STORAGE_KEY = 'pickupConfigToken';
function App() {
const [session, setSession] = useState(null);
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [config, setConfig] = useState([]);
const [stores, setStores] = useState([]);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState('');
@@ -31,19 +33,10 @@ function App() {
const [adminSettings, setAdminSettings] = useState(null);
const [adminSettingsLoading, setAdminSettingsLoading] = useState(false);
const [initializing, setInitializing] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [dirtyDialogOpen, setDirtyDialogOpen] = useState(false);
const [dirtyDialogMessage, setDirtyDialogMessage] = useState('');
const [pendingNavigation, setPendingNavigation] = useState(null);
const [dirtyDialogSaving, setDirtyDialogSaving] = useState(false);
const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null });
const [activeRangePicker, setActiveRangePicker] = useState(null);
const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null });
const [notificationPanelOpen, setNotificationPanelOpen] = useState(false);
const [focusedStoreId, setFocusedStoreId] = useState(null);
const configRef = useRef(config);
useEffect(() => {
configRef.current = config;
}, [config]);
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
@@ -83,8 +76,6 @@ function App() {
return normalized;
});
}, []);
const delay = useCallback((ms) => new Promise((resolve) => setTimeout(resolve, ms)), []);
const {
syncProgress,
startSyncProgress,
@@ -126,6 +117,74 @@ function App() {
};
}, []);
const handleUnauthorizedRef = useRef(() => {});
const authorizedFetch = useCallback(
async (url, options = {}, tokenOverride) => {
const activeToken = tokenOverride || session?.token;
if (!activeToken) {
throw new Error('Keine aktive Session');
}
const headers = {
Authorization: `Bearer ${activeToken}`,
...(options.headers || {})
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
handleUnauthorizedRef.current();
throw new Error('Nicht autorisiert');
}
return response;
},
[session?.token]
);
const {
config,
setConfig,
isDirty,
setIsDirty,
persistConfigUpdate,
saveConfig
} = useConfigManager({
sessionToken: session?.token,
authorizedFetch,
setStatus,
setError,
setLoading
});
const {
fetchConfig,
syncStoresWithProgress,
refreshStoresAndConfig
} = useStoreSync({
sessionToken: session?.token,
authorizedFetch,
setStatus,
setError,
setLoading,
setStores,
setConfig,
normalizeConfigEntries,
setIsDirty,
startSyncProgress,
updateSyncProgress,
finishSyncProgress
});
const {
requestNavigation,
dialogState: dirtyDialogState,
handleDirtySave,
handleDirtyDiscard,
handleDirtyCancel
} = useDirtyNavigationGuard({
isDirty,
saveConfig,
onDiscard: () => setIsDirty(false)
});
const resetSessionState = useCallback(() => {
setSession(null);
setConfig([]);
@@ -136,7 +195,7 @@ function App() {
setAdminSettingsLoading(false);
setAvailableCollapsed(true);
setInitializing(false);
}, []);
}, [setConfig]);
const handleUnauthorized = useCallback(() => {
resetSessionState();
@@ -147,6 +206,10 @@ function App() {
}
}, [resetSessionState]);
useEffect(() => {
handleUnauthorizedRef.current = handleUnauthorized;
}, [handleUnauthorized]);
const bootstrapSession = useCallback(
async (token, { progress } = {}) => {
if (!token) {
@@ -197,27 +260,7 @@ function App() {
}
return {};
},
[handleUnauthorized, normalizeAdminSettings, normalizeConfigEntries]
);
const authorizedFetch = useCallback(
async (url, options = {}, tokenOverride) => {
const activeToken = tokenOverride || session?.token;
if (!activeToken) {
throw new Error('Keine aktive Session');
}
const headers = {
Authorization: `Bearer ${activeToken}`,
...(options.headers || {})
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
handleUnauthorized();
throw new Error('Nicht autorisiert');
}
return response;
},
[handleUnauthorized, session?.token]
[handleUnauthorized, normalizeAdminSettings, normalizeConfigEntries, setConfig]
);
const {
@@ -345,249 +388,6 @@ function App() {
requestNavigation('dich abzumelden', performLogout);
};
const fetchConfig = useCallback(async (tokenOverride, { silent = false } = {}) => {
const tokenToUse = tokenOverride || session?.token;
if (!tokenToUse) {
return;
}
if (!silent) {
setStatus('');
}
setLoading(true);
setError('');
try {
const response = await authorizedFetch('/api/config', {}, tokenToUse);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setConfig(normalizeConfigEntries(Array.isArray(data) ? data : []));
if (!silent) {
setStatus('Konfiguration aktualisiert.');
setTimeout(() => setStatus(''), 3000);
}
} catch (err) {
setError(`Fehler beim Laden der Konfiguration: ${err.message}`);
} finally {
setLoading(false);
}
}, [session?.token, authorizedFetch, normalizeConfigEntries]);
const fetchStoresList = useCallback(async (tokenOverride, { silent = false } = {}) => {
const tokenToUse = tokenOverride || session?.token;
if (!tokenToUse) {
return;
}
if (!silent) {
setStatus('');
}
setError('');
try {
const response = await authorizedFetch('/api/stores', {}, tokenToUse);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setStores(Array.isArray(data) ? data : []);
if (!silent) {
setStatus('Betriebe aktualisiert.');
setTimeout(() => setStatus(''), 3000);
}
} catch (err) {
setError(`Fehler beim Laden der Betriebe: ${err.message}`);
}
}, [session?.token, authorizedFetch]);
const syncStoresWithProgress = useCallback(
async ({ block = false, reason = 'manual', startJob = true, reuseOverlay = false, tokenOverride } = {}) => {
const effectiveToken = tokenOverride || session?.token;
if (!effectiveToken) {
return;
}
if (!reuseOverlay) {
startSyncProgress('Betriebe werden geprüft...', 5, block);
} else {
updateSyncProgress('Betriebe werden geprüft...', 35);
}
try {
let jobStarted = false;
const jobStartedAt = Date.now();
const triggerRefresh = async () => {
const response = await authorizedFetch('/api/stores/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: true, reason })
}, effectiveToken);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await response.json();
jobStarted = true;
};
if (startJob) {
await triggerRefresh();
}
let completed = false;
while (!completed) {
const statusResp = await authorizedFetch('/api/stores/refresh/status', {}, effectiveToken);
if (!statusResp.ok) {
throw new Error(`HTTP ${statusResp.status}`);
}
const statusData = await statusResp.json();
const job = statusData.job;
if (job?.status === 'running') {
const total = job.total || 0;
const processed = job.processed || 0;
let percent;
if (total > 0) {
const ratio = Math.max(0, Math.min(1, processed / total));
percent = Math.min(99, 5 + Math.round(ratio * 90));
}
let etaSeconds = null;
if (total > 0 && processed > 0) {
const elapsedSeconds = Math.max(1, (Date.now() - jobStartedAt) / 1000);
const rate = processed / elapsedSeconds;
if (rate > 0) {
const remaining = Math.max(0, total - processed);
etaSeconds = Math.round(remaining / rate);
}
}
const message = job.currentStore
? `Prüfe ${job.currentStore} (${processed}/${total || '?'})`
: 'Betriebe werden geprüft...';
updateSyncProgress(message, percent, { etaSeconds });
} else if (!job) {
if (statusData.storesFresh) {
updateSyncProgress('Betriebe aktuell.', 99, { etaSeconds: null });
completed = true;
} else if (!jobStarted) {
await triggerRefresh();
await delay(500);
} else {
updateSyncProgress('Warte auf Rückmeldung...', undefined, { etaSeconds: null });
}
} else if (job.status === 'done') {
updateSyncProgress('Synchronisierung abgeschlossen', 100, { etaSeconds: null });
completed = true;
} else if (job.status === 'error') {
throw new Error(job.error || 'Unbekannter Fehler beim Prüfen der Betriebe.');
}
if (!completed) {
await delay(1000);
}
}
await fetchStoresList(effectiveToken, { silent: reason !== 'manual' });
await fetchConfig(effectiveToken, { silent: true });
setIsDirty(false);
setStatus('Betriebe aktualisiert.');
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setError(`Aktualisieren der Betriebe fehlgeschlagen: ${err.message}`);
} finally {
if (!reuseOverlay) {
finishSyncProgress();
}
}
},
[
session?.token,
authorizedFetch,
startSyncProgress,
updateSyncProgress,
finishSyncProgress,
delay,
fetchStoresList,
fetchConfig,
setError,
setStatus
]
);
const saveConfig = useCallback(async () => {
if (!session?.token) {
return false;
}
setStatus('Speichere...');
setError('');
try {
const response = await authorizedFetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.success) {
setStatus('Konfiguration erfolgreich gespeichert!');
setTimeout(() => setStatus(''), 3000);
setIsDirty(false);
return true;
}
throw new Error(result.error || 'Unbekannter Fehler beim Speichern');
} catch (err) {
setError(`Fehler beim Speichern: ${err.message}`);
setStatus('');
return false;
} finally {
setLoading(false);
}
}, [session?.token, authorizedFetch, config]);
const refreshStoresAndConfig = useCallback(
({ block = false } = {}) => syncStoresWithProgress({ block, reason: 'manual', startJob: true }),
[syncStoresWithProgress]
);
const requestNavigation = useCallback(
(message, action) => {
if (!isDirty) {
action();
return;
}
setDirtyDialogMessage(message || 'Änderungen wurden noch nicht gespeichert.');
setPendingNavigation(() => action);
setDirtyDialogOpen(true);
},
[isDirty]
);
const handleDirtySave = useCallback(async () => {
if (!pendingNavigation) {
setDirtyDialogOpen(false);
return;
}
setDirtyDialogSaving(true);
const success = await saveConfig();
setDirtyDialogSaving(false);
if (!success) {
return;
}
const action = pendingNavigation;
setPendingNavigation(null);
setDirtyDialogOpen(false);
action();
}, [pendingNavigation, saveConfig]);
const handleDirtyDiscard = useCallback(() => {
const action = pendingNavigation;
setPendingNavigation(null);
setDirtyDialogOpen(false);
setIsDirty(false);
if (action) {
action();
}
}, [pendingNavigation]);
const handleDirtyCancel = useCallback(() => {
setDirtyDialogOpen(false);
setPendingNavigation(null);
}, []);
const askConfirmation = useCallback(
(options = {}) =>
new Promise((resolve) => {
@@ -685,50 +485,6 @@ function App() {
};
}, [isDirty]);
const persistConfigUpdate = useCallback(
async (updater, successMessage, { autoCommit = false } = {}) => {
const baseConfig = configRef.current;
const nextConfigState = typeof updater === 'function' ? updater(baseConfig) : updater;
if (!nextConfigState) {
return;
}
setConfig(nextConfigState);
if (!session?.token) {
setIsDirty(true);
return;
}
if (!autoCommit) {
setIsDirty(true);
}
setStatus('Speichere...');
setError('');
try {
const response = await authorizedFetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nextConfigState)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Unbekannter Fehler beim Speichern');
}
const message = successMessage || 'Konfiguration gespeichert.';
setStatus(message);
setTimeout(() => setStatus(''), 3000);
setIsDirty(false);
} catch (err) {
setError(`Fehler beim Speichern: ${err.message}`);
if (autoCommit) {
setIsDirty(true);
}
}
},
[authorizedFetch, session?.token]
);
const deleteEntry = async (entryId) => {
const confirmed = await askConfirmation({
title: 'Eintrag löschen',
@@ -1204,12 +960,12 @@ function App() {
</div>
<StoreSyncOverlay state={syncProgress} />
<DirtyNavigationDialog
open={dirtyDialogOpen}
message={dirtyDialogMessage}
open={dirtyDialogState.open}
message={dirtyDialogState.message}
onSave={handleDirtySave}
onDiscard={handleDirtyDiscard}
onCancel={handleDirtyCancel}
saving={dirtyDialogSaving}
saving={dirtyDialogState.saving}
/>
<ConfirmationDialog
open={!!confirmDialog.open}

View File

@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const CONFIG_ENDPOINT = '/api/config';
const useConfigManager = ({ sessionToken, authorizedFetch, setStatus, setError, setLoading }) => {
const [config, setConfig] = useState([]);
const [isDirty, setIsDirty] = useState(false);
const configRef = useRef(config);
useEffect(() => {
configRef.current = config;
}, [config]);
const persistConfigUpdate = useCallback(
async (updater, successMessage, { autoCommit = false } = {}) => {
const baseConfig = configRef.current;
const nextConfigState = typeof updater === 'function' ? updater(baseConfig) : updater;
if (!nextConfigState) {
return;
}
setConfig(nextConfigState);
if (!sessionToken) {
setIsDirty(true);
return;
}
if (!autoCommit) {
setIsDirty(true);
}
setStatus('Speichere...');
setError('');
try {
const response = await authorizedFetch(CONFIG_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nextConfigState)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Unbekannter Fehler beim Speichern');
}
setStatus(successMessage || 'Konfiguration gespeichert.');
setTimeout(() => setStatus(''), 3000);
setIsDirty(false);
} catch (error) {
setError(`Fehler beim Speichern: ${error.message}`);
if (autoCommit) {
setIsDirty(true);
}
}
},
[authorizedFetch, sessionToken, setError, setStatus]
);
const saveConfig = useCallback(async () => {
if (!sessionToken) {
return false;
}
setStatus('Speichere...');
setError('');
setLoading(true);
try {
const response = await authorizedFetch(CONFIG_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(configRef.current)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Unbekannter Fehler beim Speichern');
}
setStatus('Konfiguration erfolgreich gespeichert!');
setTimeout(() => setStatus(''), 3000);
setIsDirty(false);
return true;
} catch (error) {
setError(`Fehler beim Speichern: ${error.message}`);
setStatus('');
return false;
} finally {
setLoading(false);
}
}, [authorizedFetch, sessionToken, setError, setLoading, setStatus]);
return {
config,
setConfig,
isDirty,
setIsDirty,
persistConfigUpdate,
saveConfig
};
};
export default useConfigManager;

View File

@@ -0,0 +1,69 @@
import { useCallback, useState } from 'react';
const DEFAULT_MESSAGE = 'Änderungen wurden noch nicht gespeichert.';
const useDirtyNavigationGuard = ({ isDirty, saveConfig, onDiscard }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMessage, setDialogMessage] = useState(DEFAULT_MESSAGE);
const [dialogSaving, setDialogSaving] = useState(false);
const [pendingNavigation, setPendingNavigation] = useState(null);
const requestNavigation = useCallback(
(message, action = () => {}) => {
if (!isDirty) {
action();
return;
}
setDialogMessage(message || DEFAULT_MESSAGE);
setPendingNavigation(() => action);
setDialogOpen(true);
},
[isDirty]
);
const handleDirtySave = useCallback(async () => {
if (!pendingNavigation) {
setDialogOpen(false);
return;
}
setDialogSaving(true);
const success = await saveConfig();
setDialogSaving(false);
if (!success) {
return;
}
const action = pendingNavigation;
setPendingNavigation(null);
setDialogOpen(false);
action();
}, [pendingNavigation, saveConfig]);
const handleDirtyDiscard = useCallback(() => {
const action = pendingNavigation;
setPendingNavigation(null);
setDialogOpen(false);
onDiscard?.();
if (action) {
action();
}
}, [pendingNavigation, onDiscard]);
const handleDirtyCancel = useCallback(() => {
setDialogOpen(false);
setPendingNavigation(null);
}, []);
return {
requestNavigation,
dialogState: {
open: dialogOpen,
message: dialogMessage,
saving: dialogSaving
},
handleDirtySave,
handleDirtyDiscard,
handleDirtyCancel
};
};
export default useDirtyNavigationGuard;

204
src/hooks/useStoreSync.js Normal file
View File

@@ -0,0 +1,204 @@
import { useCallback } from 'react';
const useStoreSync = ({
sessionToken,
authorizedFetch,
setStatus,
setError,
setLoading,
setStores,
setConfig,
normalizeConfigEntries,
setIsDirty,
startSyncProgress,
updateSyncProgress,
finishSyncProgress
}) => {
const delay = useCallback((ms) => new Promise((resolve) => setTimeout(resolve, ms)), []);
const fetchConfig = useCallback(
async (tokenOverride, { silent = false } = {}) => {
const tokenToUse = tokenOverride || sessionToken;
if (!tokenToUse) {
return;
}
if (!silent) {
setStatus('');
}
setLoading(true);
setError('');
try {
const response = await authorizedFetch('/api/config', {}, tokenToUse);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setConfig(normalizeConfigEntries(Array.isArray(data) ? data : []));
if (!silent) {
setStatus('Konfiguration aktualisiert.');
setTimeout(() => setStatus(''), 3000);
}
} catch (err) {
setError(`Fehler beim Laden der Konfiguration: ${err.message}`);
} finally {
setLoading(false);
}
},
[sessionToken, authorizedFetch, setStatus, setLoading, setError, setConfig, normalizeConfigEntries]
);
const fetchStoresList = useCallback(
async (tokenOverride, { silent = false } = {}) => {
const tokenToUse = tokenOverride || sessionToken;
if (!tokenToUse) {
return;
}
if (!silent) {
setStatus('');
}
setError('');
try {
const response = await authorizedFetch('/api/stores', {}, tokenToUse);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setStores(Array.isArray(data) ? data : []);
if (!silent) {
setStatus('Betriebe aktualisiert.');
setTimeout(() => setStatus(''), 3000);
}
} catch (err) {
setError(`Fehler beim Laden der Betriebe: ${err.message}`);
}
},
[sessionToken, authorizedFetch, setStatus, setError, setStores]
);
const syncStoresWithProgress = useCallback(
async ({ block = false, reason = 'manual', startJob = true, reuseOverlay = false, tokenOverride } = {}) => {
const effectiveToken = tokenOverride || sessionToken;
if (!effectiveToken) {
return;
}
if (!reuseOverlay) {
startSyncProgress('Betriebe werden geprüft...', 5, block);
} else {
updateSyncProgress('Betriebe werden geprüft...', 35);
}
try {
let jobStarted = false;
const jobStartedAt = Date.now();
const triggerRefresh = async () => {
const response = await authorizedFetch(
'/api/stores/refresh',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: true, reason })
},
effectiveToken
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await response.json();
jobStarted = true;
};
if (startJob) {
await triggerRefresh();
}
let completed = false;
while (!completed) {
const statusResp = await authorizedFetch('/api/stores/refresh/status', {}, effectiveToken);
if (!statusResp.ok) {
throw new Error(`HTTP ${statusResp.status}`);
}
const statusData = await statusResp.json();
const job = statusData.job;
if (job?.status === 'running') {
const total = job.total || 0;
const processed = job.processed || 0;
let percent;
if (total > 0) {
const ratio = Math.max(0, Math.min(1, processed / total));
percent = Math.min(99, 5 + Math.round(ratio * 90));
}
let etaSeconds = null;
if (total > 0 && processed > 0) {
const elapsedSeconds = Math.max(1, (Date.now() - jobStartedAt) / 1000);
const rate = processed / elapsedSeconds;
if (rate > 0) {
const remaining = Math.max(0, total - processed);
etaSeconds = Math.round(remaining / rate);
}
}
const message = job.currentStore
? `Prüfe ${job.currentStore} (${processed}/${total || '?'})`
: 'Betriebe werden geprüft...';
updateSyncProgress(message, percent, { etaSeconds });
} else if (!job) {
if (statusData.storesFresh) {
updateSyncProgress('Betriebe aktuell.', 99, { etaSeconds: null });
completed = true;
} else if (!jobStarted) {
await triggerRefresh();
await delay(500);
} else {
updateSyncProgress('Warte auf Rückmeldung...', undefined, { etaSeconds: null });
}
} else if (job.status === 'done') {
updateSyncProgress('Synchronisierung abgeschlossen', 100, { etaSeconds: null });
completed = true;
} else if (job.status === 'error') {
throw new Error(job.error || 'Unbekannter Fehler beim Prüfen der Betriebe.');
}
if (!completed) {
await delay(1000);
}
}
await fetchStoresList(effectiveToken, { silent: reason !== 'manual' });
await fetchConfig(effectiveToken, { silent: true });
setIsDirty(false);
setStatus('Betriebe aktualisiert.');
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setError(`Aktualisieren der Betriebe fehlgeschlagen: ${err.message}`);
} finally {
if (!reuseOverlay) {
finishSyncProgress();
}
}
},
[
sessionToken,
authorizedFetch,
startSyncProgress,
updateSyncProgress,
finishSyncProgress,
delay,
fetchStoresList,
fetchConfig,
setIsDirty,
setStatus,
setError
]
);
const refreshStoresAndConfig = useCallback(
({ block = false } = {}) => syncStoresWithProgress({ block, reason: 'manual', startJob: true }),
[syncStoresWithProgress]
);
return {
fetchConfig,
fetchStoresList,
syncStoresWithProgress,
refreshStoresAndConfig
};
};
export default useStoreSync;