Neue Seite um Betriebe zu überwachen
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user