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 StoreSyncOverlay from './components/StoreSyncOverlay';
import RangePickerModal from './components/RangePickerModal';
import PickupConfigEditor from './PickupConfigEditor';
function App() {
const [credentials, setCredentials] = useState({ email: '', password: '' });
@@ -694,6 +695,25 @@ function App() {
<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 (
<Router>
<>
@@ -702,6 +722,7 @@ function App() {
<NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} />
<Routes>
<Route path="/" element={dashboardContent} />
<Route path="/legacy" element={legacyEditorContent} />
<Route path="/admin" element={adminPageContent} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -1,91 +1,105 @@
import { useEffect, useState } from 'react';
import { startOfDay } from 'date-fns';
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 {
config,
loading,
status,
error,
fetchConfig,
saveConfig,
toggleActive,
toggleProfileCheck,
toggleOnlyNotify,
changeWeekday,
updateDateRange
} = usePickupConfig();
const [activeRangePicker, setActiveRangePicker] = useState(null);
const minSelectableDate = startOfDay(new Date());
const PickupConfigEditor = ({
config,
loading,
status,
error,
weekdays,
onToggleActive,
onToggleProfileCheck,
onToggleOnlyNotify,
onWeekdayChange,
onRangePickerRequest,
onResetConfig,
onSaveConfig
}) => {
const resolvedWeekdays =
weekdays || ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
const handleWeekdayChange = (index, value, entryId) => {
changeWeekday(index, value);
if (value && entryId) {
setActiveRangePicker((prev) => (prev === entryId ? null : prev));
const resolveEntryId = (entryId, index) => {
if (entryId) {
return entryId;
}
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(() => {
if (!activeRangePicker) {
return;
const handleToggleProfileCheck = (entryId, index) => {
const resolvedId = resolveEntryId(entryId, index);
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
? config.find((item) => item.id === activeRangePicker) || null
: null;
const handleWeekdayChange = (entryId, value, index) => {
const resolvedId = resolveEntryId(entryId, index);
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>;
}
const weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
return (
<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 && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">{error}</div>
)}
{status && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{status}
</div>
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">{status}</div>
)}
<PickupConfigTable
config={config}
weekdays={weekdays}
onToggleActive={toggleActive}
onToggleProfileCheck={toggleProfileCheck}
onToggleOnlyNotify={toggleOnlyNotify}
weekdays={resolvedWeekdays}
onToggleActive={handleToggleActive}
onToggleProfileCheck={handleToggleProfileCheck}
onToggleOnlyNotify={handleToggleOnlyNotify}
onWeekdayChange={handleWeekdayChange}
onRangePickerRequest={setActiveRangePicker}
onRangePickerRequest={handleRangePickerRequest}
/>
<div className="mt-6 flex justify-between">
<div className="mt-6 flex justify-between gap-3">
<button
onClick={fetchConfig}
className="bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded"
type="button"
onClick={onResetConfig}
className="flex-1 bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded"
>
Zurücksetzen
</button>
<button
onClick={saveConfig}
className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded"
type="button"
onClick={onSaveConfig}
className="flex-1 bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded"
>
Konfiguration speichern
</button>
@@ -93,25 +107,8 @@ const PickupConfigEditor = () => {
<div className="mt-8 p-4 border rounded bg-gray-50">
<h2 className="text-lg font-bold mb-2">Aktuelle JSON-Konfiguration:</h2>
<pre className="bg-gray-100 p-4 rounded overflow-x-auto">
{JSON.stringify(config, null, 2)}
</pre>
<pre className="bg-gray-100 p-4 rounded overflow-x-auto">{JSON.stringify(config, null, 2)}</pre>
</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>
);
};

View File

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

View File

@@ -24,6 +24,7 @@ const PickupConfigTable = ({
</thead>
<tbody>
{config.map((item, index) => {
const itemId = item?.id;
const normalizedRange = item.desiredDateRange
? { ...item.desiredDateRange }
: item.desiredDate
@@ -39,7 +40,7 @@ const PickupConfigTable = ({
<input
type="checkbox"
checked={item.active}
onChange={() => onToggleActive(index)}
onChange={() => onToggleActive(itemId, index)}
className="h-5 w-5"
/>
</td>
@@ -50,7 +51,7 @@ const PickupConfigTable = ({
<input
type="checkbox"
checked={item.checkProfileId}
onChange={() => onToggleProfileCheck(index)}
onChange={() => onToggleProfileCheck(itemId, index)}
className="h-5 w-5"
/>
</td>
@@ -58,14 +59,14 @@ const PickupConfigTable = ({
<input
type="checkbox"
checked={item.onlyNotify}
onChange={() => onToggleOnlyNotify(index)}
onChange={() => onToggleOnlyNotify(itemId, index)}
className="h-5 w-5"
/>
</td>
<td className="px-4 py-2">
<select
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"
disabled={hasDateRange}
>
@@ -84,7 +85,7 @@ const PickupConfigTable = ({
if (item.desiredWeekday) {
return;
}
onRangePickerRequest(item.id);
onRangePickerRequest(itemId, index);
}}
disabled={Boolean(item.desiredWeekday)}
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();
});
});