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

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import NotificationPanel from './NotificationPanel';
const DashboardView = ({
@@ -31,7 +31,12 @@ const DashboardView = ({
onDeleteEntry,
canDelete,
focusedStoreId,
onClearFocus
onClearFocus,
userLocation,
locationLoading,
locationSaving,
locationError,
onUpdateLocation
}) => {
useEffect(() => {
if (!focusedStoreId) {
@@ -53,6 +58,44 @@ const DashboardView = ({
row.classList.remove('dashboard-row-highlight', 'ring-4', 'ring-blue-400');
};
}, [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 {
error: notificationError,
message: notificationMessage,
@@ -229,10 +272,56 @@ const DashboardView = ({
{status && (
<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>
</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">
<thead>
<tr className="bg-gray-100">

View File

@@ -7,6 +7,7 @@ import {
getSortedRowModel,
useReactTable
} from '@tanstack/react-table';
import { haversineDistanceKm } from '../utils/distance';
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 [selectedRegionId, setSelectedRegionId] = useState('');
const [storesByRegion, setStoresByRegion] = useState({});
@@ -65,18 +66,34 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
[watchList]
);
const selectedRegion = useMemo(
() => regions.find((region) => String(region.id) === String(selectedRegionId)) || null,
[regions, selectedRegionId]
);
const selectedRegion = useMemo(() => {
if (selectedRegionId === 'all') {
return null;
}
return regions.find((region) => String(region.id) === String(selectedRegionId)) || null;
}, [regions, selectedRegionId]);
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)];
if (!regionEntry || !Array.isArray(regionEntry.stores)) {
return [];
}
return regionEntry.stores;
}, [storesByRegion, selectedRegionId]);
}, [regions, storesByRegion, selectedRegionId]);
const eligibleStores = useMemo(
() => currentStores.filter((store) => Number(store.cooperationStatus) === 5),
@@ -95,11 +112,21 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
const tableData = useMemo(
() =>
eligibleStores.map((store) => ({
...store,
membership: membershipMap.has(String(store.id))
})),
[eligibleStores, membershipMap]
eligibleStores.map((store) => {
const membership = membershipMap.has(String(store.id));
const lat = Number(store.location?.lat);
const lon = Number(store.location?.lon);
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(
@@ -238,6 +265,46 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
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({
id: 'watch',
header: () => <span>Überwachen</span>,
@@ -257,7 +324,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
}
})
],
[handleToggleStore, watchedIds]
[handleToggleStore, watchedIds, userLocation]
);
const table = useReactTable({
@@ -321,14 +388,16 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
}, [authorizedFetch]);
const fetchStoresForRegion = useCallback(
async (regionId, { force } = {}) => {
async (regionId, { force, silent } = {}) => {
if (!authorizedFetch || !regionId) {
return;
}
if (!force && storesByRegion[String(regionId)]) {
return;
}
setStoresLoading(true);
if (!silent) {
setStoresLoading(true);
}
setError('');
try {
const endpoint = force
@@ -350,22 +419,55 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
} catch (err) {
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
} finally {
setStoresLoading(false);
if (!silent) {
setStoresLoading(false);
}
}
},
[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(() => {
loadRegions();
loadSubscriptions();
}, [loadRegions, loadSubscriptions]);
useEffect(() => {
if (selectedRegionId) {
if (!selectedRegionId) {
return;
}
if (selectedRegionId === 'all') {
fetchAllRegions({ force: false });
} else {
fetchStoresForRegion(selectedRegionId);
}
}, [selectedRegionId, fetchStoresForRegion]);
}, [selectedRegionId, fetchStoresForRegion, fetchAllRegions]);
const handleToggleStore = useCallback(
(store, checked) => {
@@ -486,6 +588,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
disabled={regionLoading}
>
<option value="">Region auswählen</option>
<option value="all">Alle Regionen</option>
{regions.map((region) => (
<option key={region.id} value={region.id}>
{region.name}
@@ -504,7 +607,11 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
</button>
<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"
disabled={!selectedRegionId || storesLoading}
>