Neue Seite um Betriebe zu überwachen

This commit is contained in:
2025-11-10 17:22:26 +01:00
parent 49dec43c1e
commit 69a588e6f1
7 changed files with 458 additions and 24 deletions

View File

@@ -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);

View 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
};

View File

@@ -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>

View File

@@ -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">

View File

@@ -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}
> >

View 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
View 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));
}