Feat: Geolocation
This commit is contained in:
@@ -196,6 +196,15 @@ const DashboardView = ({
|
|||||||
onCopyLink={onNotificationCopy}
|
onCopyLink={onNotificationCopy}
|
||||||
copyFeedback={copyFeedback}
|
copyFeedback={copyFeedback}
|
||||||
ntfyPreviewUrl={ntfyPreviewUrl}
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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">
|
<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>
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ const NotificationPanel = ({
|
|||||||
onSendTest,
|
onSendTest,
|
||||||
onCopyLink,
|
onCopyLink,
|
||||||
copyFeedback,
|
copyFeedback,
|
||||||
ntfyPreviewUrl
|
ntfyPreviewUrl,
|
||||||
|
location,
|
||||||
|
locationLoading,
|
||||||
|
locationSaving,
|
||||||
|
locationError,
|
||||||
|
onDetectLocation,
|
||||||
|
onClearLocation
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
|
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||||
@@ -171,6 +177,50 @@ const NotificationPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center justify-end mt-4 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [sorting, setSorting] = useState([]);
|
const [sorting, setSorting] = useState([]);
|
||||||
const [columnFilters, setColumnFilters] = useState([]);
|
const [columnFilters, setColumnFilters] = useState([{ id: 'isOpen', value: 'true' }]);
|
||||||
|
|
||||||
const watchedIds = useMemo(
|
const watchedIds = useMemo(
|
||||||
() => new Set(watchList.map((entry) => String(entry.storeId))),
|
() => 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;
|
return regions.find((region) => String(region.id) === String(selectedRegionId)) || null;
|
||||||
}, [regions, selectedRegionId]);
|
}, [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(() => {
|
const currentStores = useMemo(() => {
|
||||||
if (selectedRegionId === 'all') {
|
if (selectedRegionId === 'all') {
|
||||||
const combined = new Map();
|
const combined = new Map();
|
||||||
@@ -95,10 +110,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
return regionEntry.stores;
|
return regionEntry.stores;
|
||||||
}, [regions, storesByRegion, selectedRegionId]);
|
}, [regions, storesByRegion, selectedRegionId]);
|
||||||
|
|
||||||
const eligibleStores = useMemo(
|
const regionStores = useMemo(() => currentStores, [currentStores]);
|
||||||
() => currentStores.filter((store) => Number(store.cooperationStatus) === 5),
|
|
||||||
[currentStores]
|
|
||||||
);
|
|
||||||
|
|
||||||
const membershipMap = useMemo(() => {
|
const membershipMap = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -116,7 +128,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
const storeId = String(store.id || store.storeId);
|
const storeId = String(store.id || store.storeId);
|
||||||
const existing = prev.find((entry) => entry.storeId === storeId);
|
const existing = prev.find((entry) => entry.storeId === storeId);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (existing) {
|
if (!store.isOpen || existing) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
@@ -154,7 +166,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
|
|
||||||
const tableData = useMemo(
|
const tableData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
eligibleStores.map((store) => {
|
regionStores.map((store) => {
|
||||||
const membership = membershipMap.has(String(store.id));
|
const membership = membershipMap.has(String(store.id));
|
||||||
const lat = Number(store.location?.lat);
|
const lat = Number(store.location?.lat);
|
||||||
const lon = Number(store.location?.lon);
|
const lon = Number(store.location?.lon);
|
||||||
@@ -162,13 +174,15 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
userLocation && Number.isFinite(lat) && Number.isFinite(lon)
|
userLocation && Number.isFinite(lat) && Number.isFinite(lon)
|
||||||
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
|
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
|
||||||
: null;
|
: null;
|
||||||
|
const isOpen = Number(store.cooperationStatus) === 5;
|
||||||
return {
|
return {
|
||||||
...store,
|
...store,
|
||||||
membership,
|
membership,
|
||||||
distanceKm: distance
|
distanceKm: distance,
|
||||||
|
isOpen
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[eligibleStores, membershipMap, userLocation]
|
[regionStores, membershipMap, userLocation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
@@ -258,6 +272,55 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
return new Date(a || 0).getTime() - new Date(b || 0).getTime();
|
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', {
|
columnHelper.accessor('membership', {
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<div>
|
<div>
|
||||||
@@ -397,9 +460,19 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const normalized = Array.isArray(data.regions) ? data.regions : [];
|
const normalized = Array.isArray(data.regions) ? data.regions : [];
|
||||||
setRegions(normalized);
|
setRegions(normalized);
|
||||||
if (!selectedRegionId && normalized.length > 0) {
|
setSelectedRegionId((prev) => {
|
||||||
setSelectedRegionId(String(normalized[0].id));
|
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) {
|
} catch (err) {
|
||||||
setError(`Regionen konnten nicht geladen werden: ${err.message}`);
|
setError(`Regionen konnten nicht geladen werden: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -582,11 +655,10 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
<select
|
<select
|
||||||
id="region-select"
|
id="region-select"
|
||||||
value={selectedRegionId}
|
value={selectedRegionId}
|
||||||
onChange={(event) => setSelectedRegionId(event.target.value)}
|
onChange={(event) => setSelectedRegionId(event.target.value || 'all')}
|
||||||
className="border rounded-md p-2 w-full"
|
className="border rounded-md p-2 w-full"
|
||||||
disabled={regionLoading}
|
disabled={regionLoading}
|
||||||
>
|
>
|
||||||
<option value="">Region auswählen</option>
|
|
||||||
<option value="all">Alle Regionen</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}>
|
||||||
@@ -612,7 +684,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
: fetchStoresForRegion(selectedRegionId, { 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={regionLoading || storesLoading || (selectedRegionId === 'all' && regions.length === 0)}
|
||||||
>
|
>
|
||||||
Betriebe aktualisieren
|
Betriebe aktualisieren
|
||||||
</button>
|
</button>
|
||||||
@@ -625,19 +697,15 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) =>
|
|||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h2 className="text-lg font-semibold text-gray-800">Betriebe in der Region</h2>
|
<h2 className="text-lg font-semibold text-gray-800">Betriebe in {activeRegionLabel}</h2>
|
||||||
{storesByRegion[String(selectedRegionId)]?.fetchedAt && (
|
{lastUpdatedAt && (
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
Aktualisiert:{' '}
|
Aktualisiert: {new Date(lastUpdatedAt).toLocaleTimeString('de-DE')}
|
||||||
{new Date(storesByRegion[String(selectedRegionId)].fetchedAt).toLocaleTimeString('de-DE')}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{storesLoading && <p className="text-sm text-gray-600">Lade Betriebe...</p>}
|
{storesLoading && <p className="text-sm text-gray-600">Lade Betriebe...</p>}
|
||||||
{!storesLoading && !selectedRegionId && (
|
{!storesLoading && table.getRowModel().rows.length === 0 && (
|
||||||
<p className="text-sm text-gray-500">Bitte zuerst eine Region auswählen.</p>
|
|
||||||
)}
|
|
||||||
{!storesLoading && selectedRegionId && table.getRowModel().rows.length === 0 && (
|
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Keine Betriebe gefunden. Prüfe Filter oder sortiere anders.
|
Keine Betriebe gefunden. Prüfe Filter oder sortiere anders.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user