refactoring

This commit is contained in:
2025-11-10 13:49:49 +01:00
parent 7b3625ae3b
commit 33626c7e45
6 changed files with 106 additions and 309 deletions

View File

@@ -21,6 +21,7 @@ import DirtyNavigationDialog from './components/DirtyNavigationDialog';
import ConfirmationDialog from './components/ConfirmationDialog'; import ConfirmationDialog from './components/ConfirmationDialog';
import StoreSyncOverlay from './components/StoreSyncOverlay'; import StoreSyncOverlay from './components/StoreSyncOverlay';
import RangePickerModal from './components/RangePickerModal'; import RangePickerModal from './components/RangePickerModal';
import PickupConfigEditor from './PickupConfigEditor';
function App() { function App() {
const [credentials, setCredentials] = useState({ email: '', password: '' }); const [credentials, setCredentials] = useState({ email: '', password: '' });
@@ -694,6 +695,25 @@ function App() {
<AdminAccessMessage /> <AdminAccessMessage />
); );
const legacyEditorContent = session?.isAdmin ? (
<PickupConfigEditor
config={config}
loading={loading}
status={status}
error={error}
weekdays={weekdays}
onToggleActive={handleToggleActive}
onToggleProfileCheck={handleToggleProfileCheck}
onToggleOnlyNotify={handleToggleOnlyNotify}
onWeekdayChange={handleWeekdayChange}
onRangePickerRequest={setActiveRangePicker}
onResetConfig={() => fetchConfig()}
onSaveConfig={saveConfig}
/>
) : (
<AdminAccessMessage />
);
return ( return (
<Router> <Router>
<> <>
@@ -702,6 +722,7 @@ function App() {
<NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} /> <NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} />
<Routes> <Routes>
<Route path="/" element={dashboardContent} /> <Route path="/" element={dashboardContent} />
<Route path="/legacy" element={legacyEditorContent} />
<Route path="/admin" element={adminPageContent} /> <Route path="/admin" element={adminPageContent} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@@ -1,91 +1,105 @@
import { useEffect, useState } from 'react';
import { startOfDay } from 'date-fns';
import PickupConfigTable from './components/PickupConfigTable'; import PickupConfigTable from './components/PickupConfigTable';
import RangePickerModal from './components/RangePickerModal';
import usePickupConfig from './hooks/usePickupConfig';
import 'react-date-range/dist/styles.css';
import 'react-date-range/dist/theme/default.css';
const PickupConfigEditor = () => { const PickupConfigEditor = ({
const { config,
config, loading,
loading, status,
status, error,
error, weekdays,
fetchConfig, onToggleActive,
saveConfig, onToggleProfileCheck,
toggleActive, onToggleOnlyNotify,
toggleProfileCheck, onWeekdayChange,
toggleOnlyNotify, onRangePickerRequest,
changeWeekday, onResetConfig,
updateDateRange onSaveConfig
} = usePickupConfig(); }) => {
const [activeRangePicker, setActiveRangePicker] = useState(null); const resolvedWeekdays =
const minSelectableDate = startOfDay(new Date()); weekdays || ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
const handleWeekdayChange = (index, value, entryId) => { const resolveEntryId = (entryId, index) => {
changeWeekday(index, value); if (entryId) {
if (value && entryId) { return entryId;
setActiveRangePicker((prev) => (prev === entryId ? null : prev)); }
if (typeof index === 'number' && config[index]?.id) {
return config[index].id;
}
return null;
};
const handleToggleActive = (entryId, index) => {
const resolvedId = resolveEntryId(entryId, index);
if (resolvedId && onToggleActive) {
onToggleActive(resolvedId);
} }
}; };
useEffect(() => { const handleToggleProfileCheck = (entryId, index) => {
if (!activeRangePicker) { const resolvedId = resolveEntryId(entryId, index);
return; if (resolvedId && onToggleProfileCheck) {
onToggleProfileCheck(resolvedId);
} }
const entry = config.find((item) => item.id === activeRangePicker); };
if (!entry || entry.desiredWeekday) {
setActiveRangePicker(null); const handleToggleOnlyNotify = (entryId, index) => {
const resolvedId = resolveEntryId(entryId, index);
if (resolvedId && onToggleOnlyNotify) {
onToggleOnlyNotify(resolvedId);
} }
}, [activeRangePicker, config]); };
const activeRangeEntry = activeRangePicker const handleWeekdayChange = (entryId, value, index) => {
? config.find((item) => item.id === activeRangePicker) || null const resolvedId = resolveEntryId(entryId, index);
: null; if (resolvedId && onWeekdayChange) {
onWeekdayChange(resolvedId, value);
}
};
if (loading) { const handleRangePickerRequest = (entryId, index) => {
const resolvedId = resolveEntryId(entryId, index);
if (resolvedId && onRangePickerRequest) {
onRangePickerRequest(resolvedId);
}
};
if (loading && !config.length) {
return <div className="text-center p-8">Lade Konfiguration...</div>; return <div className="text-center p-8">Lade Konfiguration...</div>;
} }
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
return ( return (
<div className="p-4 max-w-4xl mx-auto"> <div className="p-4 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">ioBroker Abholung-Konfiguration</h1> <h1 className="text-2xl font-bold mb-6">Tabellarischer Editor</h1>
{error && ( {error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">{error}</div>
{error}
</div>
)} )}
{status && ( {status && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4"> <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">{status}</div>
{status}
</div>
)} )}
<PickupConfigTable <PickupConfigTable
config={config} config={config}
weekdays={weekdays} weekdays={resolvedWeekdays}
onToggleActive={toggleActive} onToggleActive={handleToggleActive}
onToggleProfileCheck={toggleProfileCheck} onToggleProfileCheck={handleToggleProfileCheck}
onToggleOnlyNotify={toggleOnlyNotify} onToggleOnlyNotify={handleToggleOnlyNotify}
onWeekdayChange={handleWeekdayChange} onWeekdayChange={handleWeekdayChange}
onRangePickerRequest={setActiveRangePicker} onRangePickerRequest={handleRangePickerRequest}
/> />
<div className="mt-6 flex justify-between"> <div className="mt-6 flex justify-between gap-3">
<button <button
onClick={fetchConfig} type="button"
className="bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded" onClick={onResetConfig}
className="flex-1 bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded"
> >
Zurücksetzen Zurücksetzen
</button> </button>
<button <button
onClick={saveConfig} type="button"
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded" onClick={onSaveConfig}
className="flex-1 bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded"
> >
Konfiguration speichern Konfiguration speichern
</button> </button>
@@ -93,25 +107,8 @@ const PickupConfigEditor = () => {
<div className="mt-8 p-4 border rounded bg-gray-50"> <div className="mt-8 p-4 border rounded bg-gray-50">
<h2 className="text-lg font-bold mb-2">Aktuelle JSON-Konfiguration:</h2> <h2 className="text-lg font-bold mb-2">Aktuelle JSON-Konfiguration:</h2>
<pre className="bg-gray-100 p-4 rounded overflow-x-auto"> <pre className="bg-gray-100 p-4 rounded overflow-x-auto">{JSON.stringify(config, null, 2)}</pre>
{JSON.stringify(config, null, 2)}
</pre>
</div> </div>
{activeRangeEntry && (
<RangePickerModal
entry={activeRangeEntry}
minDate={minSelectableDate}
onSelectRange={(startDate, endDate) =>
updateDateRange(activeRangeEntry.id, startDate, endDate)
}
onResetRange={() => {
updateDateRange(activeRangeEntry.id, null, null);
setActiveRangePicker(null);
}}
onClose={() => setActiveRangePicker(null)}
/>
)}
</div> </div>
); );
}; };

View File

@@ -10,6 +10,7 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
const tabs = [ const tabs = [
{ to: '/', label: 'Konfiguration' }, { to: '/', label: 'Konfiguration' },
{ to: '/legacy', label: 'Tabelleneditor' },
{ to: '/admin', label: 'Admin' } { to: '/admin', label: 'Admin' }
]; ];

View File

@@ -24,6 +24,7 @@ const PickupConfigTable = ({
</thead> </thead>
<tbody> <tbody>
{config.map((item, index) => { {config.map((item, index) => {
const itemId = item?.id;
const normalizedRange = item.desiredDateRange const normalizedRange = item.desiredDateRange
? { ...item.desiredDateRange } ? { ...item.desiredDateRange }
: item.desiredDate : item.desiredDate
@@ -39,7 +40,7 @@ const PickupConfigTable = ({
<input <input
type="checkbox" type="checkbox"
checked={item.active} checked={item.active}
onChange={() => onToggleActive(index)} onChange={() => onToggleActive(itemId, index)}
className="h-5 w-5" className="h-5 w-5"
/> />
</td> </td>
@@ -50,7 +51,7 @@ const PickupConfigTable = ({
<input <input
type="checkbox" type="checkbox"
checked={item.checkProfileId} checked={item.checkProfileId}
onChange={() => onToggleProfileCheck(index)} onChange={() => onToggleProfileCheck(itemId, index)}
className="h-5 w-5" className="h-5 w-5"
/> />
</td> </td>
@@ -58,14 +59,14 @@ const PickupConfigTable = ({
<input <input
type="checkbox" type="checkbox"
checked={item.onlyNotify} checked={item.onlyNotify}
onChange={() => onToggleOnlyNotify(index)} onChange={() => onToggleOnlyNotify(itemId, index)}
className="h-5 w-5" className="h-5 w-5"
/> />
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<select <select
value={item.desiredWeekday || ''} value={item.desiredWeekday || ''}
onChange={(e) => onWeekdayChange(index, e.target.value, item.id)} onChange={(e) => onWeekdayChange(itemId, e.target.value, index)}
className="border rounded p-1 w-full" className="border rounded p-1 w-full"
disabled={hasDateRange} disabled={hasDateRange}
> >
@@ -84,7 +85,7 @@ const PickupConfigTable = ({
if (item.desiredWeekday) { if (item.desiredWeekday) {
return; return;
} }
onRangePickerRequest(item.id); onRangePickerRequest(itemId, index);
}} }}
disabled={Boolean(item.desiredWeekday)} disabled={Boolean(item.desiredWeekday)}
className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${ className={`w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${

View File

@@ -1,139 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { sortEntriesByLabel } from '../utils/configUtils';
import { formatDateValue } from '../utils/dateUtils';
const API_URL = '/api/iobroker/pickup-config';
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const usePickupConfig = () => {
const [config, setConfig] = useState([]);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState('');
const [error, setError] = useState('');
const fetchConfig = useCallback(async () => {
setLoading(true);
setError('');
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setConfig(sortEntriesByLabel(Array.isArray(data) ? data : []));
} catch (err) {
setError('Fehler beim Laden der Konfiguration: ' + err.message);
} finally {
setLoading(false);
}
}, []);
const saveConfig = useCallback(async () => {
setStatus('Speichere...');
setError('');
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
setStatus('Konfiguration erfolgreich gespeichert!');
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setError('Fehler beim Speichern: ' + err.message);
}
}, []);
const updateEntryByIndex = useCallback((index, updater) => {
setConfig((prev) => {
if (!prev[index]) {
return prev;
}
const next = [...prev];
next[index] = updater(next[index]);
return next;
});
}, []);
const updateEntryById = useCallback((entryId, updater) => {
setConfig((prev) =>
prev.map((item) => {
if (item.id !== entryId) {
return item;
}
return updater(item);
})
);
}, []);
const toggleActive = useCallback((index) => {
updateEntryByIndex(index, (entry) => ({ ...entry, active: !entry.active }));
}, [updateEntryByIndex]);
const toggleProfileCheck = useCallback((index) => {
updateEntryByIndex(index, (entry) => ({ ...entry, checkProfileId: !entry.checkProfileId }));
}, [updateEntryByIndex]);
const toggleOnlyNotify = useCallback((index) => {
updateEntryByIndex(index, (entry) => ({ ...entry, onlyNotify: !entry.onlyNotify }));
}, [updateEntryByIndex]);
const changeWeekday = useCallback((index, value) => {
updateEntryByIndex(index, (entry) => {
const next = { ...entry, desiredWeekday: value };
if (next.desiredDateRange) {
delete next.desiredDateRange;
}
return next;
});
}, [updateEntryByIndex]);
const updateDateRange = useCallback((entryId, startDate, endDate) => {
const startValue = formatDateValue(startDate);
const endValue = formatDateValue(endDate);
updateEntryById(entryId, (entry) => {
const next = { ...entry };
if (startValue || endValue) {
next.desiredDateRange = {
start: startValue || endValue,
end: endValue || startValue
};
if (next.desiredWeekday) {
delete next.desiredWeekday;
}
} else if (next.desiredDateRange) {
delete next.desiredDateRange;
}
if (next.desiredDate) {
delete next.desiredDate;
}
return next;
});
}, [updateEntryById]);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
return {
API_URL,
config,
loading,
status,
error,
fetchConfig,
saveConfig,
toggleActive,
toggleProfileCheck,
toggleOnlyNotify,
changeWeekday,
updateDateRange
};
};
export default usePickupConfig;

View File

@@ -1,84 +0,0 @@
import React from 'react';
import { act, render } from '@testing-library/react';
import usePickupConfig from './usePickupConfig';
describe('usePickupConfig', () => {
const mockConfig = [
{ id: '2', label: 'Beta Store', active: false, checkProfileId: true, onlyNotify: false },
{ id: '1', label: 'alpha Shop', active: false, checkProfileId: true, onlyNotify: false }
];
const advanceTimers = async (ms = 0) => {
await act(async () => {
jest.advanceTimersByTime(ms);
await Promise.resolve();
});
};
const renderHook = () => {
let latest;
const TestHarness = () => {
latest = usePickupConfig();
return null;
};
render(<TestHarness />);
return () => latest;
};
beforeEach(() => {
jest.useFakeTimers();
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockConfig)
})
);
});
afterEach(() => {
global.fetch.mockRestore?.();
jest.useRealTimers();
});
test('fetchConfig loads sorted configuration', async () => {
const getHook = renderHook();
await advanceTimers(600);
const hook = getHook();
expect(hook.loading).toBe(false);
expect(hook.config[0].label).toBe('alpha Shop');
expect(hook.config[hook.config.length - 1].label).toBe('Beta Store');
});
test('mutators update entries as expected', async () => {
const getHook = renderHook();
await advanceTimers(600);
await act(async () => {
getHook().toggleActive(0);
getHook().toggleProfileCheck(0);
getHook().toggleOnlyNotify(0);
});
let updated = getHook().config[0];
expect(updated.active).toBe(true);
expect(updated.checkProfileId).toBe(false);
expect(updated.onlyNotify).toBe(true);
await act(async () => {
getHook().changeWeekday(0, 'Montag');
});
updated = getHook().config[0];
expect(updated.desiredWeekday).toBe('Montag');
expect(updated.desiredDateRange).toBeUndefined();
await act(async () => {
getHook().updateDateRange(updated.id, new Date('2025-01-01'), new Date('2025-01-03'));
});
updated = getHook().config[0];
expect(updated.desiredDateRange).toEqual({
start: '2025-01-01',
end: '2025-01-03'
});
expect(updated.desiredWeekday).toBeUndefined();
});
});