feat: tìm kiếm địa điểm trên bản đồ
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { X, MapPin, Loader2, Clock, Map as MapIcon, Navigation } from 'lucide-react';
|
import { X, MapPin, Loader2, Clock, Map as MapIcon, Navigation, Search } from 'lucide-react';
|
||||||
import { useTourStore } from '@/store/useTourStore.js';
|
import { useTourStore } from '@/store/useTourStore.js';
|
||||||
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
@@ -78,6 +78,9 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
plannedEnd: ''
|
plannedEnd: ''
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const searchTimeout = useRef<any>(null);
|
||||||
|
|
||||||
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||||
const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore();
|
const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore();
|
||||||
@@ -118,11 +121,25 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
}
|
}
|
||||||
}, [initialLegId, editingLocation, isOpen]);
|
}, [initialLegId, editingLocation, isOpen]);
|
||||||
|
|
||||||
|
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng đó làm tham chiếu)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && !formData.name) {
|
if (isOpen && !editingLocation && formData.legId && !formData.name) {
|
||||||
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
const selectedLeg = legs.find(l => l.id === formData.legId);
|
||||||
|
|
||||||
|
if (selectedLeg && selectedLeg.locations && selectedLeg.locations.length > 0) {
|
||||||
|
// Di chuyển đến địa điểm cuối cùng của chặng để người dùng thấy điểm nối tiếp
|
||||||
|
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
latitude: lastLoc.latitude,
|
||||||
|
longitude: lastLoc.longitude
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Nếu chặng chưa có điểm nào, mặc định dùng vị trí trung tâm hiện tại của tour
|
||||||
|
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, mapCenter]);
|
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
|
||||||
|
|
||||||
// 2. Thực hiện các tính toán và hàm xử lý
|
// 2. Thực hiện các tính toán và hàm xử lý
|
||||||
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
||||||
@@ -130,6 +147,44 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
||||||
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
||||||
|
|
||||||
|
const handleSearchLocation = (query: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, name: query }));
|
||||||
|
|
||||||
|
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||||
|
|
||||||
|
if (query.trim().length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setIsSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
searchTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=8&addressdetails=1&accept-language=vi`);
|
||||||
|
const data = await res.json();
|
||||||
|
setSearchResults(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Lỗi tìm kiếm địa điểm:", e);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSearchResult = (result: any) => {
|
||||||
|
const lat = parseFloat(result.lat);
|
||||||
|
const lon = parseFloat(result.lon);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
name: result.display_name.split(',')[0],
|
||||||
|
address: result.display_name,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon
|
||||||
|
}));
|
||||||
|
setSearchResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePickLocation = async (latlng: L.LatLng) => {
|
const handlePickLocation = async (latlng: L.LatLng) => {
|
||||||
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
|
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
|
||||||
|
|
||||||
@@ -263,11 +318,45 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4" onClick={() => setSearchResults([])}>
|
||||||
<div>
|
<div className="relative">
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
||||||
<input required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
<div className="relative group">
|
||||||
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Gõ để tìm kiếm địa điểm..."
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => handleSearchLocation(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||||
|
) : formData.name ? (
|
||||||
|
<button type="button" onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); }} className="hover:text-red-500 transition-colors">
|
||||||
|
<X className="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Search className="w-4 h-4 text-gray-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="absolute z-[3100] left-0 right-0 mt-2 bg-white border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-64 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
||||||
|
{searchResults.map((result, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); selectSearchResult(result); }}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-all flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<div className="font-bold text-sm text-gray-900 line-clamp-1">{result.display_name.split(',')[0]}</div>
|
||||||
|
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight">{result.display_name}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user