refactoring
This commit is contained in:
406
src/App.js
406
src/App.js
@@ -7,6 +7,9 @@ import 'react-date-range/dist/theme/default.css';
|
|||||||
import { formatDateValue, formatRangeLabel } from './utils/dateUtils';
|
import { formatDateValue, formatRangeLabel } from './utils/dateUtils';
|
||||||
import useSyncProgress from './hooks/useSyncProgress';
|
import useSyncProgress from './hooks/useSyncProgress';
|
||||||
import useNotificationSettings from './hooks/useNotificationSettings';
|
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 NavigationTabs from './components/NavigationTabs';
|
||||||
import LoginView from './components/LoginView';
|
import LoginView from './components/LoginView';
|
||||||
import DashboardView from './components/DashboardView';
|
import DashboardView from './components/DashboardView';
|
||||||
@@ -22,7 +25,6 @@ const TOKEN_STORAGE_KEY = 'pickupConfigToken';
|
|||||||
function App() {
|
function App() {
|
||||||
const [session, setSession] = useState(null);
|
const [session, setSession] = useState(null);
|
||||||
const [credentials, setCredentials] = useState({ email: '', password: '' });
|
const [credentials, setCredentials] = useState({ email: '', password: '' });
|
||||||
const [config, setConfig] = useState([]);
|
|
||||||
const [stores, setStores] = useState([]);
|
const [stores, setStores] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
@@ -31,19 +33,10 @@ function App() {
|
|||||||
const [adminSettings, setAdminSettings] = useState(null);
|
const [adminSettings, setAdminSettings] = useState(null);
|
||||||
const [adminSettingsLoading, setAdminSettingsLoading] = useState(false);
|
const [adminSettingsLoading, setAdminSettingsLoading] = useState(false);
|
||||||
const [initializing, setInitializing] = 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 [activeRangePicker, setActiveRangePicker] = useState(null);
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState({ open: false, resolve: null });
|
||||||
const [notificationPanelOpen, setNotificationPanelOpen] = useState(false);
|
const [notificationPanelOpen, setNotificationPanelOpen] = useState(false);
|
||||||
const [focusedStoreId, setFocusedStoreId] = useState(null);
|
const [focusedStoreId, setFocusedStoreId] = useState(null);
|
||||||
const configRef = useRef(config);
|
|
||||||
useEffect(() => {
|
|
||||||
configRef.current = config;
|
|
||||||
}, [config]);
|
|
||||||
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
|
const minSelectableDate = useMemo(() => startOfDay(new Date()), []);
|
||||||
|
|
||||||
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||||
@@ -83,8 +76,6 @@ function App() {
|
|||||||
return normalized;
|
return normalized;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
const delay = useCallback((ms) => new Promise((resolve) => setTimeout(resolve, ms)), []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
syncProgress,
|
syncProgress,
|
||||||
startSyncProgress,
|
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(() => {
|
const resetSessionState = useCallback(() => {
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setConfig([]);
|
setConfig([]);
|
||||||
@@ -136,7 +195,7 @@ function App() {
|
|||||||
setAdminSettingsLoading(false);
|
setAdminSettingsLoading(false);
|
||||||
setAvailableCollapsed(true);
|
setAvailableCollapsed(true);
|
||||||
setInitializing(false);
|
setInitializing(false);
|
||||||
}, []);
|
}, [setConfig]);
|
||||||
|
|
||||||
const handleUnauthorized = useCallback(() => {
|
const handleUnauthorized = useCallback(() => {
|
||||||
resetSessionState();
|
resetSessionState();
|
||||||
@@ -147,6 +206,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [resetSessionState]);
|
}, [resetSessionState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleUnauthorizedRef.current = handleUnauthorized;
|
||||||
|
}, [handleUnauthorized]);
|
||||||
|
|
||||||
const bootstrapSession = useCallback(
|
const bootstrapSession = useCallback(
|
||||||
async (token, { progress } = {}) => {
|
async (token, { progress } = {}) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -197,27 +260,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
[handleUnauthorized, normalizeAdminSettings, normalizeConfigEntries]
|
[handleUnauthorized, normalizeAdminSettings, normalizeConfigEntries, setConfig]
|
||||||
);
|
|
||||||
|
|
||||||
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]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -345,249 +388,6 @@ function App() {
|
|||||||
requestNavigation('dich abzumelden', performLogout);
|
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(
|
const askConfirmation = useCallback(
|
||||||
(options = {}) =>
|
(options = {}) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
@@ -685,50 +485,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [isDirty]);
|
}, [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 deleteEntry = async (entryId) => {
|
||||||
const confirmed = await askConfirmation({
|
const confirmed = await askConfirmation({
|
||||||
title: 'Eintrag löschen',
|
title: 'Eintrag löschen',
|
||||||
@@ -1204,12 +960,12 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<StoreSyncOverlay state={syncProgress} />
|
<StoreSyncOverlay state={syncProgress} />
|
||||||
<DirtyNavigationDialog
|
<DirtyNavigationDialog
|
||||||
open={dirtyDialogOpen}
|
open={dirtyDialogState.open}
|
||||||
message={dirtyDialogMessage}
|
message={dirtyDialogState.message}
|
||||||
onSave={handleDirtySave}
|
onSave={handleDirtySave}
|
||||||
onDiscard={handleDirtyDiscard}
|
onDiscard={handleDirtyDiscard}
|
||||||
onCancel={handleDirtyCancel}
|
onCancel={handleDirtyCancel}
|
||||||
saving={dirtyDialogSaving}
|
saving={dirtyDialogState.saving}
|
||||||
/>
|
/>
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
open={!!confirmDialog.open}
|
open={!!confirmDialog.open}
|
||||||
|
|||||||
100
src/hooks/useConfigManager.js
Normal file
100
src/hooks/useConfigManager.js
Normal 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;
|
||||||
69
src/hooks/useDirtyNavigationGuard.js
Normal file
69
src/hooks/useDirtyNavigationGuard.js
Normal 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
204
src/hooks/useStoreSync.js
Normal 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;
|
||||||
Reference in New Issue
Block a user