feat: streamline dashboard actions by removing inline add-entry form and manual refresh
This commit is contained in:
209
src/App.js
209
src/App.js
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate, Link, useLocation } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const TOKEN_STORAGE_KEY = 'pickupConfigToken';
|
const TOKEN_STORAGE_KEY = 'pickupConfigToken';
|
||||||
@@ -23,6 +23,11 @@ function App() {
|
|||||||
etaSeconds: null
|
etaSeconds: null
|
||||||
});
|
});
|
||||||
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 weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||||
const delay = useCallback((ms) => new Promise((resolve) => setTimeout(resolve, ms)), []);
|
const delay = useCallback((ms) => new Promise((resolve) => setTimeout(resolve, ms)), []);
|
||||||
@@ -281,7 +286,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const performLogout = useCallback(async () => {
|
||||||
if (!session?.token) {
|
if (!session?.token) {
|
||||||
handleUnauthorized();
|
handleUnauthorized();
|
||||||
return;
|
return;
|
||||||
@@ -293,6 +298,10 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
handleUnauthorized();
|
handleUnauthorized();
|
||||||
}
|
}
|
||||||
|
}, [session?.token, authorizedFetch, handleUnauthorized]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
requestNavigation('dich abzumelden', performLogout);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchConfig = useCallback(async (tokenOverride, { silent = false } = {}) => {
|
const fetchConfig = useCallback(async (tokenOverride, { silent = false } = {}) => {
|
||||||
@@ -427,6 +436,7 @@ function App() {
|
|||||||
|
|
||||||
await fetchStoresList(effectiveToken);
|
await fetchStoresList(effectiveToken);
|
||||||
await fetchConfig(effectiveToken, { silent: true });
|
await fetchConfig(effectiveToken, { silent: true });
|
||||||
|
setIsDirty(false);
|
||||||
setStatus('Betriebe aktualisiert.');
|
setStatus('Betriebe aktualisiert.');
|
||||||
setTimeout(() => setStatus(''), 3000);
|
setTimeout(() => setStatus(''), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -451,11 +461,88 @@ function App() {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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(
|
const refreshStoresAndConfig = useCallback(
|
||||||
({ block = false } = {}) => syncStoresWithProgress({ block, reason: 'manual', startJob: true }),
|
({ block = false } = {}) => syncStoresWithProgress({ block, reason: 'manual', startJob: true }),
|
||||||
[syncStoresWithProgress]
|
[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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ticker;
|
let ticker;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -513,33 +600,20 @@ function App() {
|
|||||||
syncStoresWithProgress
|
syncStoresWithProgress
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const saveConfig = async () => {
|
useEffect(() => {
|
||||||
if (!session?.token) {
|
const handleBeforeUnload = (event) => {
|
||||||
return;
|
if (!isDirty) {
|
||||||
}
|
return;
|
||||||
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();
|
event.preventDefault();
|
||||||
if (result.success) {
|
event.returnValue = '';
|
||||||
setStatus('Konfiguration erfolgreich gespeichert!');
|
return '';
|
||||||
setTimeout(() => setStatus(''), 3000);
|
};
|
||||||
} else {
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
throw new Error(result.error || 'Unbekannter Fehler beim Speichern');
|
return () => {
|
||||||
}
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
} catch (err) {
|
};
|
||||||
setError(`Fehler beim Speichern: ${err.message}`);
|
}, [isDirty]);
|
||||||
setStatus('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistConfigUpdate = async (updater, successMessage) => {
|
const persistConfigUpdate = async (updater, successMessage) => {
|
||||||
if (!session?.token) {
|
if (!session?.token) {
|
||||||
@@ -571,12 +645,14 @@ function App() {
|
|||||||
const message = successMessage || 'Konfiguration gespeichert.';
|
const message = successMessage || 'Konfiguration gespeichert.';
|
||||||
setStatus(message);
|
setStatus(message);
|
||||||
setTimeout(() => setStatus(''), 3000);
|
setTimeout(() => setStatus(''), 3000);
|
||||||
|
setIsDirty(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(`Fehler beim Speichern: ${err.message}`);
|
setError(`Fehler beim Speichern: ${err.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteEntry = (entryId) => {
|
const deleteEntry = (entryId) => {
|
||||||
|
setIsDirty(true);
|
||||||
if (window.confirm('Soll dieser Eintrag dauerhaft gelöscht werden?')) {
|
if (window.confirm('Soll dieser Eintrag dauerhaft gelöscht werden?')) {
|
||||||
const updatedConfig = config.filter((item) => item.id !== entryId);
|
const updatedConfig = config.filter((item) => item.id !== entryId);
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
@@ -589,6 +665,7 @@ function App() {
|
|||||||
if (!window.confirm('Soll dieser Betrieb ausgeblendet werden?')) {
|
if (!window.confirm('Soll dieser Betrieb ausgeblendet werden?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setIsDirty(true);
|
||||||
await persistConfigUpdate(
|
await persistConfigUpdate(
|
||||||
(prev) => prev.map((item) => (item.id === entryId ? { ...item, hidden: true } : item)),
|
(prev) => prev.map((item) => (item.id === entryId ? { ...item, hidden: true } : item)),
|
||||||
'Betrieb ausgeblendet.'
|
'Betrieb ausgeblendet.'
|
||||||
@@ -596,6 +673,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleActive = (entryId) => {
|
const handleToggleActive = (entryId) => {
|
||||||
|
setIsDirty(true);
|
||||||
setConfig((prev) =>
|
setConfig((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === entryId ? { ...item, active: !item.active } : item
|
item.id === entryId ? { ...item, active: !item.active } : item
|
||||||
@@ -604,6 +682,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleProfileCheck = (entryId) => {
|
const handleToggleProfileCheck = (entryId) => {
|
||||||
|
setIsDirty(true);
|
||||||
setConfig((prev) =>
|
setConfig((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === entryId ? { ...item, checkProfileId: !item.checkProfileId } : item
|
item.id === entryId ? { ...item, checkProfileId: !item.checkProfileId } : item
|
||||||
@@ -612,6 +691,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleOnlyNotify = (entryId) => {
|
const handleToggleOnlyNotify = (entryId) => {
|
||||||
|
setIsDirty(true);
|
||||||
setConfig((prev) =>
|
setConfig((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === entryId ? { ...item, onlyNotify: !item.onlyNotify } : item
|
item.id === entryId ? { ...item, onlyNotify: !item.onlyNotify } : item
|
||||||
@@ -620,6 +700,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleWeekdayChange = (entryId, value) => {
|
const handleWeekdayChange = (entryId, value) => {
|
||||||
|
setIsDirty(true);
|
||||||
setConfig((prev) =>
|
setConfig((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((item) => {
|
||||||
if (item.id !== entryId) {
|
if (item.id !== entryId) {
|
||||||
@@ -635,6 +716,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDateChange = (entryId, value) => {
|
const handleDateChange = (entryId, value) => {
|
||||||
|
setIsDirty(true);
|
||||||
setConfig((prev) =>
|
setConfig((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((item) => {
|
||||||
if (item.id !== entryId) {
|
if (item.id !== entryId) {
|
||||||
@@ -671,6 +753,7 @@ function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = existing ? 'Betrieb wieder eingeblendet.' : 'Betrieb zur Liste hinzugefügt.';
|
const message = existing ? 'Betrieb wieder eingeblendet.' : 'Betrieb zur Liste hinzugefügt.';
|
||||||
|
setIsDirty(true);
|
||||||
await persistConfigUpdate(
|
await persistConfigUpdate(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
const already = prev.find((item) => item.id === storeId);
|
const already = prev.find((item) => item.id === storeId);
|
||||||
@@ -996,6 +1079,15 @@ function App() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:text-blue-800 flex items-center"
|
className="text-blue-600 hover:text-blue-800 flex items-center"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!isDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
requestNavigation('das Foodsharing-Dashboard zu öffnen', () =>
|
||||||
|
window.open('https://foodsharing.de/?page=dashboard', '_blank', 'noopener,noreferrer')
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
@@ -1300,7 +1392,7 @@ function App() {
|
|||||||
<>
|
<>
|
||||||
<div className="min-h-screen bg-gray-100 py-6">
|
<div className="min-h-screen bg-gray-100 py-6">
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
<NavigationTabs isAdmin={session?.isAdmin} />
|
<NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={dashboardContent} />
|
<Route path="/" element={dashboardContent} />
|
||||||
<Route path="/admin" element={adminPageContent} />
|
<Route path="/admin" element={adminPageContent} />
|
||||||
@@ -1309,13 +1401,22 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StoreSyncOverlay state={syncProgress} />
|
<StoreSyncOverlay state={syncProgress} />
|
||||||
|
<DirtyNavigationDialog
|
||||||
|
open={dirtyDialogOpen}
|
||||||
|
message={dirtyDialogMessage}
|
||||||
|
onSave={handleDirtySave}
|
||||||
|
onDiscard={handleDirtyDiscard}
|
||||||
|
onCancel={handleDirtyCancel}
|
||||||
|
saving={dirtyDialogSaving}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavigationTabs({ isAdmin }) {
|
function NavigationTabs({ isAdmin, onProtectedNavigate }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1324,6 +1425,18 @@ function NavigationTabs({ isAdmin }) {
|
|||||||
{ to: '/admin', label: 'Admin' }
|
{ to: '/admin', label: 'Admin' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleClick = (event, tab) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (location.pathname === tab.to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onProtectedNavigate) {
|
||||||
|
onProtectedNavigate(`zur Seite "${tab.label}" wechseln`, () => navigate(tab.to));
|
||||||
|
} else {
|
||||||
|
navigate(tab.to);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
@@ -1332,6 +1445,7 @@ function NavigationTabs({ isAdmin }) {
|
|||||||
<Link
|
<Link
|
||||||
key={tab.to}
|
key={tab.to}
|
||||||
to={tab.to}
|
to={tab.to}
|
||||||
|
onClick={(event) => handleClick(event, tab)}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
isActive ? 'bg-blue-600 text-white shadow' : 'bg-white text-blue-600 border border-blue-200 hover:bg-blue-50'
|
isActive ? 'bg-blue-600 text-white shadow' : 'bg-white text-blue-600 border border-blue-200 hover:bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
@@ -1359,6 +1473,41 @@ function AdminAccessMessage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DirtyNavigationDialog({ open, message, onSave, onDiscard, onCancel, saving }) {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-2xl max-w-md w-full p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-2">Änderungen nicht gespeichert</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">{message || 'Es gibt ungespeicherte Änderungen. Wie möchtest du fortfahren?'}</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichere...' : 'Speichern & fortfahren'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDiscard}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-gray-800 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||||
|
>
|
||||||
|
Ohne Speichern fortfahren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded focus:outline-none focus:ring-2 focus:ring-gray-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatEta(seconds) {
|
function formatEta(seconds) {
|
||||||
if (seconds == null || seconds === Infinity) {
|
if (seconds == null || seconds === Infinity) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user