Feat: Geolocation

This commit is contained in:
2025-11-10 17:44:43 +01:00
parent 9c3f000a29
commit b060e89cef
3 changed files with 151 additions and 70 deletions

View File

@@ -196,6 +196,15 @@ const DashboardView = ({
onCopyLink={onNotificationCopy}
copyFeedback={copyFeedback}
ntfyPreviewUrl={ntfyPreviewUrl}
location={userLocation}
locationLoading={locationLoading}
locationSaving={locationSaving || geoBusy}
locationError={locationError || geoError}
onDetectLocation={() => {
setGeoError('');
handleDetectLocation();
}}
onClearLocation={handleClearLocation}
/>
)}
@@ -275,52 +284,6 @@ const DashboardView = ({
</div>
)}
<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>

View File

@@ -12,7 +12,13 @@ const NotificationPanel = ({
onSendTest,
onCopyLink,
copyFeedback,
ntfyPreviewUrl
ntfyPreviewUrl,
location,
locationLoading,
locationSaving,
locationError,
onDetectLocation,
onClearLocation
}) => {
return (
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
@@ -171,6 +177,50 @@ const NotificationPanel = ({
</div>
</div>
<div className="mt-6 border border-blue-100 bg-blue-50 rounded-lg p-4">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-blue-900">Standort für Entfernungssortierung</h3>
<p className="text-sm text-blue-800">
Der Standort wird für die Entfernungskalkulation im Monitoring genutzt. Nur für dich sichtbar.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onDetectLocation?.()}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-60"
disabled={!onDetectLocation || locationLoading || locationSaving}
>
{locationSaving ? 'Speichere...' : 'Standort ermitteln'}
</button>
{location && (
<button
type="button"
onClick={() => onClearLocation?.()}
className="px-4 py-2 rounded-md border border-blue-300 text-blue-700 text-sm bg-white disabled:opacity-60"
disabled={!onClearLocation || locationSaving}
>
Entfernen
</button>
)}
</div>
</div>
<div className="mt-3 text-sm text-blue-900">
{locationLoading ? (
<p>Standort wird geladen...</p>
) : location ? (
<p>
Aktueller Standort: {location.lat.toFixed(4)}, {location.lon.toFixed(4)} (
{location.updatedAt ? new Date(location.updatedAt).toLocaleString('de-DE') : 'Zeit unbekannt'})
</p>
) : (
<p>Kein Standort hinterlegt.</p>
)}
</div>
{locationError && <p className="mt-2 text-sm text-red-600">{locationError}</p>}
</div>
<div className="flex items-center justify-end mt-4 gap-3">
<button
type="button"

View File

@@ -59,7 +59,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
const [columnFilters, setColumnFilters] = useState([{ id: 'isOpen', value: 'true' }]);
const watchedIds = useMemo(
() => new Set(watchList.map((entry) => String(entry.storeId))),
@@ -73,6 +73,21 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
return regions.find((region) => String(region.id) === String(selectedRegionId)) || null;
}, [regions, selectedRegionId]);
const activeRegionLabel = selectedRegionId === 'all' ? 'allen Regionen' : selectedRegion?.name || 'dieser Region';
const lastUpdatedAt = useMemo(() => {
if (selectedRegionId === 'all') {
const timestamps = regions
.map((region) => storesByRegion[String(region.id)]?.fetchedAt)
.filter(Boolean);
if (timestamps.length === 0) {
return null;
}
return Math.max(...timestamps);
}
return storesByRegion[String(selectedRegionId)]?.fetchedAt || null;
}, [regions, selectedRegionId, storesByRegion]);
const currentStores = useMemo(() => {
if (selectedRegionId === 'all') {
const combined = new Map();
@@ -95,10 +110,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
return regionEntry.stores;
}, [regions, storesByRegion, selectedRegionId]);
const eligibleStores = useMemo(
() => currentStores.filter((store) => Number(store.cooperationStatus) === 5),
[currentStores]
);
const regionStores = useMemo(() => currentStores, [currentStores]);
const membershipMap = useMemo(() => {
const map = new Map();
@@ -116,7 +128,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
const storeId = String(store.id || store.storeId);
const existing = prev.find((entry) => entry.storeId === storeId);
if (checked) {
if (existing) {
if (!store.isOpen || existing) {
return prev;
}
setDirty(true);
@@ -154,7 +166,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
const tableData = useMemo(
() =>
eligibleStores.map((store) => {
regionStores.map((store) => {
const membership = membershipMap.has(String(store.id));
const lat = Number(store.location?.lat);
const lon = Number(store.location?.lon);
@@ -162,13 +174,15 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
userLocation && Number.isFinite(lat) && Number.isFinite(lon)
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
: null;
const isOpen = Number(store.cooperationStatus) === 5;
return {
...store,
membership,
distanceKm: distance
distanceKm: distance,
isOpen
};
}),
[eligibleStores, membershipMap, userLocation]
[regionStores, membershipMap, userLocation]
);
const columns = useMemo(
@@ -258,6 +272,55 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
return new Date(a || 0).getTime() - new Date(b || 0).getTime();
}
}),
columnHelper.accessor('isOpen', {
header: ({ column }) => (
<div>
<button
type="button"
className="flex w-full items-center justify-between text-left font-semibold"
onClick={column.getToggleSortingHandler()}
>
<span>Offen</span>
{column.getIsSorted() ? (
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</button>
<ColumnSelectFilter
column={column}
options={[
{ value: 'true', label: 'Ja' },
{ value: 'false', label: 'Nein' }
]}
/>
</div>
),
cell: ({ getValue }) => {
const value = getValue();
return (
<span
className={`inline-flex items-center justify-center rounded px-2 py-1 text-xs font-semibold ${
value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}
>
{value ? 'Ja' : 'Nein'}
</span>
);
},
filterFn: (row, columnId, value) => {
if (value === undefined) {
return true;
}
const boolValue = value === 'true';
return row.getValue(columnId) === boolValue;
},
sortingFn: (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId);
const b = rowB.getValue(columnId);
return Number(b) - Number(a);
}
}),
columnHelper.accessor('membership', {
header: ({ column }) => (
<div>
@@ -397,9 +460,19 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
const data = await response.json();
const normalized = Array.isArray(data.regions) ? data.regions : [];
setRegions(normalized);
if (!selectedRegionId && normalized.length > 0) {
setSelectedRegionId(String(normalized[0].id));
}
setSelectedRegionId((prev) => {
if (!prev) {
return 'all';
}
if (prev === 'all') {
return prev;
}
const exists = normalized.some((region) => String(region.id) === String(prev));
if (!exists) {
return normalized.length > 0 ? String(normalized[0].id) : 'all';
}
return prev;
});
} catch (err) {
setError(`Regionen konnten nicht geladen werden: ${err.message}`);
} finally {
@@ -582,11 +655,10 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
<select
id="region-select"
value={selectedRegionId}
onChange={(event) => setSelectedRegionId(event.target.value)}
onChange={(event) => setSelectedRegionId(event.target.value || 'all')}
className="border rounded-md p-2 w-full"
disabled={regionLoading}
>
<option value="">Region auswählen</option>
<option value="all">Alle Regionen</option>
{regions.map((region) => (
<option key={region.id} value={region.id}>
@@ -612,7 +684,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
: fetchStoresForRegion(selectedRegionId, { force: true })
}
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
disabled={!selectedRegionId || storesLoading}
disabled={regionLoading || storesLoading || (selectedRegionId === 'all' && regions.length === 0)}
>
Betriebe aktualisieren
</button>
@@ -625,19 +697,15 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-gray-800">Betriebe in der Region</h2>
{storesByRegion[String(selectedRegionId)]?.fetchedAt && (
<h2 className="text-lg font-semibold text-gray-800">Betriebe in {activeRegionLabel}</h2>
{lastUpdatedAt && (
<span className="text-xs text-gray-500">
Aktualisiert:{' '}
{new Date(storesByRegion[String(selectedRegionId)].fetchedAt).toLocaleTimeString('de-DE')}
Aktualisiert: {new Date(lastUpdatedAt).toLocaleTimeString('de-DE')}
</span>
)}
</div>
{storesLoading && <p className="text-sm text-gray-600">Lade Betriebe...</p>}
{!storesLoading && !selectedRegionId && (
<p className="text-sm text-gray-500">Bitte zuerst eine Region auswählen.</p>
)}
{!storesLoading && selectedRegionId && table.getRowModel().rows.length === 0 && (
{!storesLoading && table.getRowModel().rows.length === 0 && (
<p className="text-sm text-gray-500">
Keine Betriebe gefunden. Prüfe Filter oder sortiere anders.
</p>