[31b88f42] Finalize staff locations and travel time logic

This commit is contained in:
2026-03-09 14:44:10 +00:00
parent 9afe4148ba
commit 2c90da3ba5
4 changed files with 185 additions and 28 deletions

View File

@@ -57,7 +57,13 @@ function App() {
const [staffData, setStaffData] = useState<StaffData>({ sales: [], technicians: [] });
const [showSales, setShowSales] = useState(false);
const [showTechnicians, setShowTechnicians] = useState(false);
const [travelRadius, setTravelRadius] = useState(50); // In km
const [travelTime, setTravelTime] = useState(60); // In minutes
const [averageSpeed, setAverageSpeed] = useState(80); // In km/h
const [isochrones, setIsochrones] = useState<Record<string, any>>({});
const [orsError, setOrsError] = useState<string | null>(null);
// Derived travel radius in km (fallback)
const travelRadius = (travelTime / 60) * averageSpeed;
useEffect(() => {
const fetchStaffLocations = async () => {
@@ -71,6 +77,56 @@ function App() {
fetchStaffLocations();
}, []);
// Fetch isochrones when settings change
useEffect(() => {
if (!showSales && !showTechnicians) return;
const fetchAllIsochrones = async () => {
const activeStaff = [
...(showSales ? staffData.sales : []),
...(showTechnicians ? staffData.technicians : [])
];
if (activeStaff.length === 0) return;
const newIsochrones: Record<string, any> = { ...isochrones };
let anyOrsError = false;
for (const person of activeStaff) {
// Skip if already fetched for this person and time
if (newIsochrones[person.name] && newIsochrones[person.name].minutes === travelTime) {
continue;
}
try {
const response = await axios.get('/heatmap/api/isochrones', {
params: { lat: person.lat, lon: person.lon, minutes: travelTime }
});
if (response.data.simulated || response.data.error) {
setOrsError("OpenRouteService API Key missing or invalid. Showing circular fallback.");
anyOrsError = true;
break;
}
newIsochrones[person.name] = {
data: response.data,
minutes: travelTime
};
} catch (err) {
console.error(`Failed to fetch isochrone for ${person.name}:`, err);
// Don't set anyOrsError here, just let this one person use the fallback
}
}
setIsochrones(newIsochrones);
if (!anyOrsError) setOrsError(null);
};
const timer = setTimeout(fetchAllIsochrones, 800); // Debounce
return () => clearTimeout(timer);
}, [travelTime, showSales, showTechnicians, staffData]);
const handleUploadSuccess = (response: any) => {
setError(null);
if (response.plz_column_needed) {
@@ -194,18 +250,44 @@ function App() {
</div>
{(showSales || showTechnicians) && (
<div className="radius-control" style={{ marginBottom: '20px' }}>
<label htmlFor="travel-radius">Travel Radius: {travelRadius} km</label>
<input
type="range"
id="travel-radius"
min="10"
max="200"
step="5"
value={travelRadius}
onChange={(e) => setTravelRadius(parseInt(e.target.value))}
style={{ width: '100%' }}
/>
<div className="radius-control" style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#444', borderRadius: '4px' }}>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="travel-time">Travel Time: {travelTime} min (max 60)</label>
<input
type="range"
id="travel-time"
min="15"
max="60"
step="5"
value={travelTime}
</div>
{orsError ? (
<>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="avg-speed">Avg. Speed: {averageSpeed} km/h</label>
<input
type="range"
id="avg-speed"
min="30"
max="120"
step="5"
value={averageSpeed}
onChange={(e) => setAverageSpeed(parseInt(e.target.value))}
style={{ width: '100%' }}
/>
</div>
<div style={{ fontSize: '0.8em', color: '#ffcc00', marginBottom: '5px' }}>
{orsError}
</div>
<div style={{ fontSize: '0.9em', color: '#ccc', textAlign: 'center', borderTop: '1px solid #666', paddingTop: '5px' }}>
Resulting Radius: <strong>{travelRadius.toFixed(1)} km</strong>
</div>
</>
) : (
<div style={{ fontSize: '0.8em', color: '#4CAF50' }}>
Using real road data (OpenRouteService)
</div>
)}
</div>
)}
@@ -240,6 +322,7 @@ function App() {
showSales={showSales}
showTechnicians={showTechnicians}
travelRadius={travelRadius}
isochrones={isochrones}
/>
)}
</div>

View File

@@ -1,6 +1,6 @@
// src/components/MapDisplay.tsx
import React from 'react';
import { MapContainer, TileLayer, CircleMarker, Tooltip, Marker, Circle } from 'react-leaflet';
import { MapContainer, TileLayer, CircleMarker, Tooltip, Marker, Circle, GeoJSON } from 'react-leaflet';
import { HeatmapLayer } from 'react-leaflet-heatmap-layer-v3';
import 'leaflet/dist/leaflet.css';
import 'leaflet.heat';
@@ -25,6 +25,7 @@ interface MapDisplayProps {
showSales: boolean;
showTechnicians: boolean;
travelRadius: number;
isochrones?: Record<string, any>;
}
const MapDisplay: React.FC<MapDisplayProps> = ({
@@ -35,7 +36,8 @@ const MapDisplay: React.FC<MapDisplayProps> = ({
staffData,
showSales,
showTechnicians,
travelRadius
travelRadius,
isochrones = {}
}) => {
const germanyCenter: [number, number] = [51.1657, 10.4515];
const maxCount = heatmapData.length > 0 ? Math.max(...heatmapData.map(p => p.count), 1) : 1;
@@ -120,16 +122,25 @@ const MapDisplay: React.FC<MapDisplayProps> = ({
if (showSales) {
staffData.sales.forEach((person, idx) => {
const iso = isochrones[person.name];
elements.push(
<React.Fragment key={`sales-${idx}`}>
<Marker position={[person.lat, person.lon]} icon={salesIcon}>
<Tooltip permanent={false}>Sales: {person.name} ({person.plz})</Tooltip>
</Marker>
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.1 }}
/>
{iso ? (
<GeoJSON
key={`iso-sales-${person.name}-${iso.minutes}`}
data={iso.data}
pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.2, weight: 1 }}
/>
) : (
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.1 }}
/>
)}
</React.Fragment>
);
});
@@ -137,16 +148,25 @@ const MapDisplay: React.FC<MapDisplayProps> = ({
if (showTechnicians) {
staffData.technicians.forEach((person, idx) => {
const iso = isochrones[person.name];
elements.push(
<React.Fragment key={`tech-${idx}`}>
<Marker position={[person.lat, person.lon]} icon={technicianIcon}>
<Tooltip permanent={false}>Tech: {person.name} ({person.plz})</Tooltip>
</Marker>
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'orange', fillColor: 'orange', fillOpacity: 0.1 }}
/>
{iso ? (
<GeoJSON
key={`iso-tech-${person.name}-${iso.minutes}`}
data={iso.data}
pathOptions={{ color: 'orange', fillColor: 'orange', fillOpacity: 0.2, weight: 1 }}
/>
) : (
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'orange', fillColor: 'orange', fillOpacity: 0.1 }}
/>
)}
</React.Fragment>
);
});