Neue Seite um Betriebe zu überwachen
This commit is contained in:
30
server.js
30
server.js
@@ -12,6 +12,7 @@ const adminConfig = require('./services/adminConfig');
|
|||||||
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
|
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
|
||||||
const notificationService = require('./services/notificationService');
|
const notificationService = require('./services/notificationService');
|
||||||
const { readStoreWatch, writeStoreWatch } = require('./services/storeWatchStore');
|
const { readStoreWatch, writeStoreWatch } = require('./services/storeWatchStore');
|
||||||
|
const { readPreferences, writePreferences } = require('./services/userPreferencesStore');
|
||||||
|
|
||||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||||
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
|
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
|
||||||
@@ -463,6 +464,35 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
|
|||||||
res.json({ success: true, stores: persisted });
|
res.json({ success: true, stores: persisted });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/user/preferences', requireAuth, (req, res) => {
|
||||||
|
const preferences = readPreferences(req.session.profile.id);
|
||||||
|
res.json(preferences);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/user/preferences/location', requireAuth, (req, res) => {
|
||||||
|
const { lat, lon } = req.body || {};
|
||||||
|
if (lat === null || lon === null || lat === undefined || lon === undefined) {
|
||||||
|
const updated = writePreferences(req.session.profile.id, { location: null });
|
||||||
|
return res.json({ location: updated.location });
|
||||||
|
}
|
||||||
|
const parsedLat = Number(lat);
|
||||||
|
const parsedLon = Number(lon);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(parsedLat) ||
|
||||||
|
!Number.isFinite(parsedLon) ||
|
||||||
|
parsedLat < -90 ||
|
||||||
|
parsedLat > 90 ||
|
||||||
|
parsedLon < -180 ||
|
||||||
|
parsedLon > 180
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Koordinaten' });
|
||||||
|
}
|
||||||
|
const updated = writePreferences(req.session.profile.id, {
|
||||||
|
location: { lat: parsedLat, lon: parsedLon }
|
||||||
|
});
|
||||||
|
res.json({ location: updated.location });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/config', requireAuth, (req, res) => {
|
app.get('/api/config', requireAuth, (req, res) => {
|
||||||
const config = readConfig(req.session.profile.id);
|
const config = readConfig(req.session.profile.id);
|
||||||
res.json(config);
|
res.json(config);
|
||||||
|
|||||||
75
services/userPreferencesStore.js
Normal file
75
services/userPreferencesStore.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PREF_DIR = path.join(__dirname, '..', 'config');
|
||||||
|
|
||||||
|
const DEFAULT_PREFERENCES = {
|
||||||
|
location: null
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureDir() {
|
||||||
|
if (!fs.existsSync(PREF_DIR)) {
|
||||||
|
fs.mkdirSync(PREF_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferencesPath(profileId = 'shared') {
|
||||||
|
return path.join(PREF_DIR, `${profileId}-preferences.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeLocation(location) {
|
||||||
|
if (!location) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lat = Number(location.lat);
|
||||||
|
const lon = Number(location.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreferences(profileId) {
|
||||||
|
ensureDir();
|
||||||
|
const filePath = getPreferencesPath(profileId);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(DEFAULT_PREFERENCES, null, 2));
|
||||||
|
return { ...DEFAULT_PREFERENCES };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
location: sanitizeLocation(parsed.location) || null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PREFERENCES] Konnte Datei ${filePath} nicht lesen:`, error.message);
|
||||||
|
return { ...DEFAULT_PREFERENCES };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePreferences(profileId, patch = {}) {
|
||||||
|
const current = readPreferences(profileId);
|
||||||
|
const next = {
|
||||||
|
location:
|
||||||
|
patch.location === undefined
|
||||||
|
? current.location
|
||||||
|
: sanitizeLocation(patch.location)
|
||||||
|
};
|
||||||
|
ensureDir();
|
||||||
|
const filePath = getPreferencesPath(profileId);
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
readPreferences,
|
||||||
|
writePreferences
|
||||||
|
};
|
||||||
28
src/App.js
28
src/App.js
@@ -12,6 +12,7 @@ import useStoreSync from './hooks/useStoreSync';
|
|||||||
import useDirtyNavigationGuard from './hooks/useDirtyNavigationGuard';
|
import useDirtyNavigationGuard from './hooks/useDirtyNavigationGuard';
|
||||||
import useSessionManager from './hooks/useSessionManager';
|
import useSessionManager from './hooks/useSessionManager';
|
||||||
import useAdminSettings from './hooks/useAdminSettings';
|
import useAdminSettings from './hooks/useAdminSettings';
|
||||||
|
import useUserPreferences from './hooks/useUserPreferences';
|
||||||
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';
|
||||||
@@ -116,6 +117,17 @@ function App() {
|
|||||||
setLoading
|
setLoading
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
preferences,
|
||||||
|
loading: preferencesLoading,
|
||||||
|
saving: locationSaving,
|
||||||
|
error: preferencesError,
|
||||||
|
updateLocation
|
||||||
|
} = useUserPreferences({
|
||||||
|
authorizedFetch,
|
||||||
|
sessionToken: session?.token
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchConfig,
|
fetchConfig,
|
||||||
syncStoresWithProgress,
|
syncStoresWithProgress,
|
||||||
@@ -667,6 +679,11 @@ function App() {
|
|||||||
canDelete={Boolean(session?.isAdmin)}
|
canDelete={Boolean(session?.isAdmin)}
|
||||||
focusedStoreId={focusedStoreId}
|
focusedStoreId={focusedStoreId}
|
||||||
onClearFocus={() => setFocusedStoreId(null)}
|
onClearFocus={() => setFocusedStoreId(null)}
|
||||||
|
userLocation={preferences?.location || null}
|
||||||
|
locationLoading={preferencesLoading}
|
||||||
|
locationSaving={locationSaving}
|
||||||
|
locationError={preferencesError}
|
||||||
|
onUpdateLocation={updateLocation}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -696,7 +713,16 @@ 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="/store-watch" element={<StoreWatchPage authorizedFetch={authorizedFetch} knownStores={stores} />} />
|
<Route
|
||||||
|
path="/store-watch"
|
||||||
|
element={
|
||||||
|
<StoreWatchPage
|
||||||
|
authorizedFetch={authorizedFetch}
|
||||||
|
knownStores={stores}
|
||||||
|
userLocation={preferences?.location || null}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/admin" element={adminPageContent} />
|
<Route path="/admin" element={adminPageContent} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import NotificationPanel from './NotificationPanel';
|
import NotificationPanel from './NotificationPanel';
|
||||||
|
|
||||||
const DashboardView = ({
|
const DashboardView = ({
|
||||||
@@ -31,7 +31,12 @@ const DashboardView = ({
|
|||||||
onDeleteEntry,
|
onDeleteEntry,
|
||||||
canDelete,
|
canDelete,
|
||||||
focusedStoreId,
|
focusedStoreId,
|
||||||
onClearFocus
|
onClearFocus,
|
||||||
|
userLocation,
|
||||||
|
locationLoading,
|
||||||
|
locationSaving,
|
||||||
|
locationError,
|
||||||
|
onUpdateLocation
|
||||||
}) => {
|
}) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusedStoreId) {
|
if (!focusedStoreId) {
|
||||||
@@ -53,6 +58,44 @@ const DashboardView = ({
|
|||||||
row.classList.remove('dashboard-row-highlight', 'ring-4', 'ring-blue-400');
|
row.classList.remove('dashboard-row-highlight', 'ring-4', 'ring-blue-400');
|
||||||
};
|
};
|
||||||
}, [focusedStoreId, onClearFocus]);
|
}, [focusedStoreId, onClearFocus]);
|
||||||
|
const [geoBusy, setGeoBusy] = useState(false);
|
||||||
|
const [geoError, setGeoError] = useState('');
|
||||||
|
|
||||||
|
const handleDetectLocation = useCallback(() => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setGeoError('Standortbestimmung wird von diesem Browser nicht unterstützt.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGeoBusy(true);
|
||||||
|
setGeoError('');
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async (position) => {
|
||||||
|
const lat = position.coords.latitude;
|
||||||
|
const lon = position.coords.longitude;
|
||||||
|
await onUpdateLocation?.({ lat, lon });
|
||||||
|
setGeoBusy(false);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
setGeoBusy(false);
|
||||||
|
setGeoError(
|
||||||
|
error.code === error.PERMISSION_DENIED
|
||||||
|
? 'Zugriff auf den Standort wurde verweigert.'
|
||||||
|
: 'Standort konnte nicht ermittelt werden.'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [onUpdateLocation]);
|
||||||
|
|
||||||
|
const handleClearLocation = useCallback(async () => {
|
||||||
|
setGeoError('');
|
||||||
|
await onUpdateLocation?.(null);
|
||||||
|
}, [onUpdateLocation]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
error: notificationError,
|
error: notificationError,
|
||||||
message: notificationMessage,
|
message: notificationMessage,
|
||||||
@@ -229,10 +272,56 @@ const DashboardView = ({
|
|||||||
{status && (
|
{status && (
|
||||||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4 relative">
|
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4 relative">
|
||||||
<span className="block sm:inline">{status}</span>
|
<span className="block sm:inline">{status}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="mb-6 border border-blue-100 bg-blue-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-blue-900">Standort für Entfernungssortierung</h2>
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
Damit die Monitoring-Liste nach Entfernung sortieren kann, hinterlege hier deinen Standort.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDetectLocation}
|
||||||
|
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-60"
|
||||||
|
disabled={geoBusy || locationSaving || locationLoading}
|
||||||
|
>
|
||||||
|
{geoBusy || locationSaving ? 'Speichere...' : 'Standort ermitteln'}
|
||||||
|
</button>
|
||||||
|
{userLocation && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearLocation}
|
||||||
|
className="px-4 py-2 rounded-md border border-blue-300 text-blue-700 text-sm bg-white disabled:opacity-60"
|
||||||
|
disabled={locationSaving || locationLoading}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm text-blue-900">
|
||||||
|
{locationLoading ? (
|
||||||
|
<p>Standort wird geladen...</p>
|
||||||
|
) : userLocation ? (
|
||||||
|
<p>
|
||||||
|
Aktueller Standort: {userLocation.lat.toFixed(4)}, {userLocation.lon.toFixed(4)} (
|
||||||
|
{userLocation.updatedAt ? new Date(userLocation.updatedAt).toLocaleString('de-DE') : 'Zeit unbekannt'})
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>Kein Standort hinterlegt.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(locationError || geoError) && (
|
||||||
|
<p className="mt-2 text-sm text-red-600">{locationError || geoError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full bg-white border border-gray-200">
|
<table className="min-w-full bg-white border border-gray-200">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-100">
|
<tr className="bg-gray-100">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
useReactTable
|
useReactTable
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
|
import { haversineDistanceKm } from '../utils/distance';
|
||||||
|
|
||||||
const columnHelper = createColumnHelper();
|
const columnHelper = createColumnHelper();
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ const ColumnSelectFilter = ({ column, options }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => {
|
||||||
const [regions, setRegions] = useState([]);
|
const [regions, setRegions] = useState([]);
|
||||||
const [selectedRegionId, setSelectedRegionId] = useState('');
|
const [selectedRegionId, setSelectedRegionId] = useState('');
|
||||||
const [storesByRegion, setStoresByRegion] = useState({});
|
const [storesByRegion, setStoresByRegion] = useState({});
|
||||||
@@ -65,18 +66,34 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
[watchList]
|
[watchList]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedRegion = useMemo(
|
const selectedRegion = useMemo(() => {
|
||||||
() => regions.find((region) => String(region.id) === String(selectedRegionId)) || null,
|
if (selectedRegionId === 'all') {
|
||||||
[regions, selectedRegionId]
|
return null;
|
||||||
);
|
}
|
||||||
|
return regions.find((region) => String(region.id) === String(selectedRegionId)) || null;
|
||||||
|
}, [regions, selectedRegionId]);
|
||||||
|
|
||||||
const currentStores = useMemo(() => {
|
const currentStores = useMemo(() => {
|
||||||
|
if (selectedRegionId === 'all') {
|
||||||
|
const combined = new Map();
|
||||||
|
regions.forEach((region) => {
|
||||||
|
const entry = storesByRegion[String(region.id)];
|
||||||
|
if (entry?.stores) {
|
||||||
|
entry.stores.forEach((store) => {
|
||||||
|
if (store?.id) {
|
||||||
|
combined.set(String(store.id), store);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(combined.values());
|
||||||
|
}
|
||||||
const regionEntry = storesByRegion[String(selectedRegionId)];
|
const regionEntry = storesByRegion[String(selectedRegionId)];
|
||||||
if (!regionEntry || !Array.isArray(regionEntry.stores)) {
|
if (!regionEntry || !Array.isArray(regionEntry.stores)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return regionEntry.stores;
|
return regionEntry.stores;
|
||||||
}, [storesByRegion, selectedRegionId]);
|
}, [regions, storesByRegion, selectedRegionId]);
|
||||||
|
|
||||||
const eligibleStores = useMemo(
|
const eligibleStores = useMemo(
|
||||||
() => currentStores.filter((store) => Number(store.cooperationStatus) === 5),
|
() => currentStores.filter((store) => Number(store.cooperationStatus) === 5),
|
||||||
@@ -95,11 +112,21 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
|
|
||||||
const tableData = useMemo(
|
const tableData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
eligibleStores.map((store) => ({
|
eligibleStores.map((store) => {
|
||||||
...store,
|
const membership = membershipMap.has(String(store.id));
|
||||||
membership: membershipMap.has(String(store.id))
|
const lat = Number(store.location?.lat);
|
||||||
})),
|
const lon = Number(store.location?.lon);
|
||||||
[eligibleStores, membershipMap]
|
const distance =
|
||||||
|
userLocation && Number.isFinite(lat) && Number.isFinite(lon)
|
||||||
|
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
...store,
|
||||||
|
membership,
|
||||||
|
distanceKm: distance
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[eligibleStores, membershipMap, userLocation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
@@ -238,6 +265,46 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
return Number(b) - Number(a);
|
return Number(b) - Number(a);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
columnHelper.accessor('distanceKm', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between text-left font-semibold disabled:cursor-not-allowed disabled:text-gray-400"
|
||||||
|
onClick={userLocation ? column.getToggleSortingHandler() : undefined}
|
||||||
|
disabled={!userLocation}
|
||||||
|
>
|
||||||
|
<span>Entfernung</span>
|
||||||
|
{column.getIsSorted() ? (
|
||||||
|
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">⇅</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!userLocation && <p className="mt-1 text-xs text-gray-500">Standort erforderlich</p>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
if (!value && value !== 0) {
|
||||||
|
return <span className="text-sm text-gray-500">–</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-sm text-gray-800">{value.toFixed(2)} km</span>;
|
||||||
|
},
|
||||||
|
sortingFn: (rowA, rowB, columnId) => {
|
||||||
|
const a = rowA.getValue(columnId);
|
||||||
|
const b = rowB.getValue(columnId);
|
||||||
|
if (a === null || a === undefined) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (b === null || b === undefined) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return a - b;
|
||||||
|
},
|
||||||
|
enableColumnFilter: false,
|
||||||
|
enableSorting: !!userLocation
|
||||||
|
}),
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: 'watch',
|
id: 'watch',
|
||||||
header: () => <span>Überwachen</span>,
|
header: () => <span>Überwachen</span>,
|
||||||
@@ -257,7 +324,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
[handleToggleStore, watchedIds]
|
[handleToggleStore, watchedIds, userLocation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -321,14 +388,16 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
}, [authorizedFetch]);
|
}, [authorizedFetch]);
|
||||||
|
|
||||||
const fetchStoresForRegion = useCallback(
|
const fetchStoresForRegion = useCallback(
|
||||||
async (regionId, { force } = {}) => {
|
async (regionId, { force, silent } = {}) => {
|
||||||
if (!authorizedFetch || !regionId) {
|
if (!authorizedFetch || !regionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!force && storesByRegion[String(regionId)]) {
|
if (!force && storesByRegion[String(regionId)]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStoresLoading(true);
|
if (!silent) {
|
||||||
|
setStoresLoading(true);
|
||||||
|
}
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const endpoint = force
|
const endpoint = force
|
||||||
@@ -350,22 +419,55 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
|
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setStoresLoading(false);
|
if (!silent) {
|
||||||
|
setStoresLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[authorizedFetch, storesByRegion]
|
[authorizedFetch, storesByRegion]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fetchAllRegions = useCallback(
|
||||||
|
async ({ force } = {}) => {
|
||||||
|
if (!authorizedFetch || regions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = regions.filter(
|
||||||
|
(region) => force || !storesByRegion[String(region.id)]
|
||||||
|
);
|
||||||
|
if (targets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStoresLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
for (const region of targets) {
|
||||||
|
await fetchStoresForRegion(region.id, { force, silent: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setStoresLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authorizedFetch, regions, storesByRegion, fetchStoresForRegion]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRegions();
|
loadRegions();
|
||||||
loadSubscriptions();
|
loadSubscriptions();
|
||||||
}, [loadRegions, loadSubscriptions]);
|
}, [loadRegions, loadSubscriptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRegionId) {
|
if (!selectedRegionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedRegionId === 'all') {
|
||||||
|
fetchAllRegions({ force: false });
|
||||||
|
} else {
|
||||||
fetchStoresForRegion(selectedRegionId);
|
fetchStoresForRegion(selectedRegionId);
|
||||||
}
|
}
|
||||||
}, [selectedRegionId, fetchStoresForRegion]);
|
}, [selectedRegionId, fetchStoresForRegion, fetchAllRegions]);
|
||||||
|
|
||||||
const handleToggleStore = useCallback(
|
const handleToggleStore = useCallback(
|
||||||
(store, checked) => {
|
(store, checked) => {
|
||||||
@@ -486,6 +588,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
disabled={regionLoading}
|
disabled={regionLoading}
|
||||||
>
|
>
|
||||||
<option value="">Region auswählen</option>
|
<option value="">Region auswählen</option>
|
||||||
|
<option value="all">Alle Regionen</option>
|
||||||
{regions.map((region) => (
|
{regions.map((region) => (
|
||||||
<option key={region.id} value={region.id}>
|
<option key={region.id} value={region.id}>
|
||||||
{region.name}
|
{region.name}
|
||||||
@@ -504,7 +607,11 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchStoresForRegion(selectedRegionId, { force: true })}
|
onClick={() =>
|
||||||
|
selectedRegionId === 'all'
|
||||||
|
? fetchAllRegions({ force: true })
|
||||||
|
: fetchStoresForRegion(selectedRegionId, { force: true })
|
||||||
|
}
|
||||||
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
|
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
|
||||||
disabled={!selectedRegionId || storesLoading}
|
disabled={!selectedRegionId || storesLoading}
|
||||||
>
|
>
|
||||||
|
|||||||
87
src/hooks/useUserPreferences.js
Normal file
87
src/hooks/useUserPreferences.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const PREFERENCES_ENDPOINT = '/api/user/preferences';
|
||||||
|
const LOCATION_ENDPOINT = '/api/user/preferences/location';
|
||||||
|
|
||||||
|
const useUserPreferences = ({ authorizedFetch, sessionToken }) => {
|
||||||
|
const [preferences, setPreferences] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const loadPreferences = useCallback(async () => {
|
||||||
|
if (!sessionToken || !authorizedFetch) {
|
||||||
|
setPreferences(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const response = await authorizedFetch(PREFERENCES_ENDPOINT);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setPreferences(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Einstellungen konnten nicht geladen werden: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [authorizedFetch, sessionToken]);
|
||||||
|
|
||||||
|
const updateLocation = useCallback(
|
||||||
|
async (coords) => {
|
||||||
|
if (!sessionToken || !authorizedFetch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload =
|
||||||
|
coords && typeof coords.lat === 'number' && typeof coords.lon === 'number'
|
||||||
|
? { lat: coords.lat, lon: coords.lon }
|
||||||
|
: { lat: null, lon: null };
|
||||||
|
const response = await authorizedFetch(LOCATION_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setPreferences((prev) => ({
|
||||||
|
...(prev || {}),
|
||||||
|
location: data.location || null
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Standort konnte nicht aktualisiert werden: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authorizedFetch, sessionToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionToken) {
|
||||||
|
loadPreferences();
|
||||||
|
} else {
|
||||||
|
setPreferences(null);
|
||||||
|
}
|
||||||
|
}, [sessionToken, loadPreferences]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferences,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
loadPreferences,
|
||||||
|
updateLocation
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUserPreferences;
|
||||||
20
src/utils/distance.js
Normal file
20
src/utils/distance.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const toRadians = (value) => (value * Math.PI) / 180;
|
||||||
|
|
||||||
|
export function haversineDistanceKm(lat1, lon1, lat2, lon2) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(lat1) ||
|
||||||
|
!Number.isFinite(lon1) ||
|
||||||
|
!Number.isFinite(lat2) ||
|
||||||
|
!Number.isFinite(lon2)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = toRadians(lat2 - lat1);
|
||||||
|
const dLon = toRadians(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return Number((R * c).toFixed(2));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user