296 lines
9.3 KiB
JavaScript
296 lines
9.3 KiB
JavaScript
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||
import { useConfigContext } from '../../contexts/ConfigContext';
|
||
import MapView from '../map/MapView';
|
||
import FilterPanel from '../users/FilterPanel';
|
||
import { calculateDistance } from '../../utils/helpers';
|
||
import { getGeocodeByPostalCode } from '../../services/users';
|
||
import './PublicUserList.css';
|
||
|
||
const PublicUserList = ({ users, loading, onRefetch }) => {
|
||
const { userTypeLabels } = useConfigContext();
|
||
|
||
// Load saved location from localStorage on mount
|
||
const savedLocation = localStorage.getItem('userLocation');
|
||
const initialCoords = savedLocation ? JSON.parse(savedLocation) : null;
|
||
|
||
const [showMap, setShowMap] = useState(true);
|
||
const [showFilters, setShowFilters] = useState(false);
|
||
const [locationStatus, setLocationStatus] = useState(initialCoords ? 'granted' : 'idle');
|
||
const [locationError, setLocationError] = useState('');
|
||
const [coords, setCoords] = useState(initialCoords);
|
||
const [radiusKm, setRadiusKm] = useState(100);
|
||
const [postalCode, setPostalCode] = useState('');
|
||
const [postalSearching, setPostalSearching] = useState(false);
|
||
const [filters, setFilters] = useState({
|
||
type: null,
|
||
sortBy: 'name'
|
||
});
|
||
|
||
// Save location to localStorage whenever it changes
|
||
useEffect(() => {
|
||
if (coords) {
|
||
localStorage.setItem('userLocation', JSON.stringify(coords));
|
||
}
|
||
}, [coords]);
|
||
|
||
const handleFilterChange = useCallback((key, value) => {
|
||
setFilters(prev => ({ ...prev, [key]: value }));
|
||
}, []);
|
||
|
||
// Re-fetch from backend when type filter changes (server-side filtering)
|
||
useEffect(() => {
|
||
if (onRefetch) {
|
||
onRefetch(filters.type ? { type: filters.type } : {});
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [filters.type]);
|
||
|
||
const requestLocation = () => {
|
||
if (!navigator.geolocation) {
|
||
return;
|
||
}
|
||
|
||
setLocationStatus('requesting');
|
||
setLocationError('');
|
||
|
||
navigator.geolocation.getCurrentPosition(
|
||
(position) => {
|
||
setCoords({
|
||
lat: position.coords.latitude,
|
||
lng: position.coords.longitude
|
||
});
|
||
setLocationStatus('granted');
|
||
},
|
||
() => {
|
||
setLocationStatus('idle');
|
||
},
|
||
{
|
||
enableHighAccuracy: true,
|
||
timeout: 15000,
|
||
maximumAge: 0
|
||
}
|
||
);
|
||
};
|
||
|
||
const searchByPostalCode = async () => {
|
||
if (!postalCode || postalCode.trim().length < 4) {
|
||
setLocationError('Bitte geben Sie eine gültige Postleitzahl ein');
|
||
return;
|
||
}
|
||
|
||
setPostalSearching(true);
|
||
setLocationError('');
|
||
|
||
const result = await getGeocodeByPostalCode(postalCode.trim());
|
||
|
||
if (result.success) {
|
||
setCoords({ lat: result.data.lat, lng: result.data.lng });
|
||
setLocationStatus('granted');
|
||
setLocationError('');
|
||
} else {
|
||
setLocationError(result.message || 'Fehler bei der PLZ-Suche');
|
||
}
|
||
setPostalSearching(false);
|
||
};
|
||
|
||
const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => {
|
||
let filtered = [...users];
|
||
|
||
|
||
const missingGps = filtered.filter(user => !(user.gps && user.gps.lat && user.gps.lng)).length;
|
||
|
||
// Alle Benutzer mit GPS für die Karte (vor Radius-Filterung)
|
||
const allWithGPS = filtered.filter(user => user.gps && user.gps.lat && user.gps.lng);
|
||
|
||
if (coords) {
|
||
const withDistance = filtered
|
||
.filter(user => user.gps && user.gps.lat && user.gps.lng)
|
||
.map(user => ({
|
||
...user,
|
||
distanceKm: calculateDistance(
|
||
coords.lat,
|
||
coords.lng,
|
||
user.gps.lat,
|
||
user.gps.lng
|
||
)
|
||
}))
|
||
.filter(user => user.distanceKm <= radiusKm)
|
||
.sort((a, b) => a.distanceKm - b.distanceKm);
|
||
|
||
return { list: withDistance, missingGpsCount: missingGps, allUsersWithGPS: allWithGPS };
|
||
}
|
||
|
||
// Sort by name/type when no location
|
||
filtered.sort((a, b) => {
|
||
switch (filters.sortBy) {
|
||
case 'nameDesc':
|
||
return b.name.localeCompare(a.name);
|
||
case 'type':
|
||
return a.type.localeCompare(b.type);
|
||
case 'name':
|
||
default:
|
||
return a.name.localeCompare(b.name);
|
||
}
|
||
});
|
||
|
||
return { list: filtered, missingGpsCount: missingGps, allUsersWithGPS: allWithGPS };
|
||
}, [users, filters, coords, radiusKm]);
|
||
|
||
if (loading) {
|
||
return <div className="loading">Lädt verfügbare Führer...</div>;
|
||
}
|
||
|
||
if (!users || users.length === 0) {
|
||
return (
|
||
<div className="no-users">
|
||
<p>Derzeit sind keine Nachsuchenführer verfügbar.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="public-user-list">
|
||
<div className="list-header">
|
||
<h2>Verfügbare Nachsuchenführer</h2>
|
||
<button
|
||
className="toggle-map-button"
|
||
onClick={() => setShowMap(!showMap)}
|
||
>
|
||
{showMap ? 'Karte ausblenden' : 'Karte anzeigen'}
|
||
</button>
|
||
</div>
|
||
<div className="location-panel">
|
||
<div className="location-controls">
|
||
<button
|
||
className="location-button"
|
||
onClick={requestLocation}
|
||
disabled={locationStatus === 'requesting'}
|
||
>
|
||
{locationStatus === 'requesting' ? 'Wird geladen...' : coords ? 'Standort aktualisieren' : 'Standort freigeben'}
|
||
</button>
|
||
<div className="postal-search">
|
||
<input
|
||
type="text"
|
||
placeholder="oder PLZ eingeben"
|
||
value={postalCode}
|
||
onChange={(e) => setPostalCode(e.target.value)}
|
||
onKeyPress={(e) => e.key === 'Enter' && searchByPostalCode()}
|
||
disabled={postalSearching}
|
||
/>
|
||
<button
|
||
onClick={searchByPostalCode}
|
||
disabled={postalSearching || !postalCode}
|
||
>
|
||
{postalSearching ? 'Suche...' : 'Suchen'}
|
||
</button>
|
||
</div>
|
||
{coords && (
|
||
<span className="location-status success">
|
||
Standort aktiv
|
||
</span>
|
||
)}
|
||
{locationError && (
|
||
<span className="location-status error">
|
||
{locationError}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="radius-filter">
|
||
<label htmlFor="radiusKm">Radius (km)</label>
|
||
<div className="radius-inputs">
|
||
<input
|
||
type="range"
|
||
id="radiusKm"
|
||
min="1"
|
||
max="200"
|
||
value={radiusKm}
|
||
onChange={(e) => setRadiusKm(parseInt(e.target.value, 10))}
|
||
disabled={!coords}
|
||
/>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="200"
|
||
value={radiusKm}
|
||
onChange={(e) => setRadiusKm(parseInt(e.target.value || '1', 10))}
|
||
disabled={!coords}
|
||
/>
|
||
</div>
|
||
<p className="radius-hint">
|
||
Standort wird nur im Browser genutzt und nicht gespeichert.
|
||
</p>
|
||
</div>
|
||
{coords && missingGpsCount > 0 && (
|
||
<div className="gps-hint">
|
||
{missingGpsCount} verfügbare Führer ohne GPS-Daten werden nicht nach Entfernung angezeigt.
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="filter-toggle">
|
||
<button
|
||
className="filter-toggle-button"
|
||
onClick={() => setShowFilters(!showFilters)}
|
||
>
|
||
{showFilters ? 'Filter ausblenden' : 'Filter anzeigen'}
|
||
</button>
|
||
</div>
|
||
{showFilters && (
|
||
<FilterPanel
|
||
filters={filters}
|
||
onFilterChange={handleFilterChange}
|
||
showAvailableFilter={false}
|
||
/>
|
||
)}
|
||
{showMap && (
|
||
<MapView
|
||
users={allUsersWithGPS}
|
||
center={[52.5, 9.5]}
|
||
zoom={8}
|
||
userLocation={coords}
|
||
radiusKm={radiusKm}
|
||
/>
|
||
)}
|
||
{filteredAndSortedUsers.length === 0 ? (
|
||
<div className="no-users">
|
||
<p>Keine Nachsuchenführer im gewählten Radius gefunden.</p>
|
||
</div>
|
||
) : (
|
||
<div className="user-grid">
|
||
{filteredAndSortedUsers.map(user => (
|
||
<div key={user._id} className="user-card">
|
||
{user.photo && (
|
||
<div className="user-card-photo">
|
||
<img src={user.photo} alt={user.name} className="user-card-photo-img" />
|
||
</div>
|
||
)}
|
||
<h3 className="user-name">{user.name}</h3>
|
||
<p className="user-role">Nachsuchenführer</p>
|
||
<div className="user-info">
|
||
<p className="user-address">📍 {user.address}</p>
|
||
<a href={`tel:${user.phone}`} className="user-phone">
|
||
<EFBFBD> {user.phone}
|
||
</a>
|
||
{user.landline && (
|
||
<a href={`tel:${user.landline}`} className="user-phone">
|
||
📞 {user.landline}
|
||
</a>
|
||
)}
|
||
<p className="user-type">
|
||
<span className="type-badge" title={userTypeLabels[user.type]}>{user.type}</span>
|
||
</p>
|
||
</div>
|
||
{user.distanceKm !== undefined && (
|
||
<div className="user-distance">
|
||
Entfernung: {user.distanceKm.toFixed(1)} km
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default PublicUserList;
|