fix: sửa tính năng hiển thị quãng đường tại điểm đến kế tiếp

This commit is contained in:
2026-06-17 15:37:18 +07:00
parent 7ad785fed9
commit c5474f1ff6
7 changed files with 450 additions and 107 deletions
+147 -45
View File
@@ -51,27 +51,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
// Định nghĩa các static icons để ngăn chặn việc khởi tạo lại liên tục gây crash khi unmount
const START_ICON = L.divIcon({
className: 'custom-marker-s',
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const END_ICON = L.divIcon({
className: 'custom-marker-e',
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const VISIT_ICON = L.divIcon({
className: 'custom-marker-v',
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
/**
* Helper tính khoảng cách giữa 2 tọa độ (Haversine formula)
*/
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
const p = 0.017453292519943295; // Math.PI / 180
const c = Math.cos;
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
c(lat1 * p) * c(lat2 * p) *
(1 - c((lon2 - lon1) * p)) / 2;
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
}
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
const MapTourBounds = ({ locations }: { locations: any[] }) => {
@@ -190,6 +180,28 @@ export const TourDetailPage = ({
const userRole = useTourStore(state => state.userRole);
const mapCenter = useTourStore(state => state.mapCenter);
// Định nghĩa Icons bên trong Component bằng useMemo để đảm bảo tính ổn định và tránh lỗi render
const mapIcons = useMemo(() => ({
start: L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-7 h-7 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">S</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
}),
end: L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-7 h-7 bg-green-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">E</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
}),
visit: L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-5 h-5 bg-indigo-500 rounded-full border-2 border-white shadow-lg hover:scale-125 transition-transform flex items-center justify-center"><div class="w-1.5 h-1.5 bg-white rounded-full opacity-50"></div></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
})
}), []);
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
@@ -594,10 +606,63 @@ export const TourDetailPage = ({
notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' });
}
};
// Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour
const handleQuickNote = (locationName: string) => {
if (isPublicView) return;
const content = window.prompt(`Ghi chú nhanh cho địa điểm: ${locationName}`);
if (!content || !content.trim()) return;
const storedUser = localStorage.getItem('user');
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
const userName = user.name || 'Thành viên';
const now = new Date().toLocaleString('vi-VN');
const noteTitle = `Ghi chú của hành trình: ${currentTour?.title}`;
const savedNotes = localStorage.getItem('my_journey_notes');
let notes = [];
try {
notes = savedNotes ? JSON.parse(savedNotes) : [];
} catch (e) { notes = []; }
let targetNote = notes.find((n: any) => n.title === noteTitle);
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp
const newContentLine = `
<div class="quick-note-box" style="border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; background: #fffbeb; border-radius: 8px; border: 1px solid #fef3c7; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} • 👤 ${userName}</span>
</div>
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${locationName}:</strong> ${content}</p>
</div>
<p></p>
`;
if (targetNote) {
targetNote.content += newContentLine;
} else {
const newNote = {
id: Date.now().toString(),
title: noteTitle,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour?.title}</strong> của bạn tại đây...</p>` + newContentLine,
createdAt: new Date().toISOString()
};
notes.unshift(newNote);
}
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
};
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
const startPoint = legs[0]?.locations[0];
const lastLeg = legs[legs.length - 1];
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
const startPoint = useMemo(() =>
legs.flatMap(l => l.locations).find(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0),
[legs]
);
const endPoint = useMemo(() =>
legs.flatMap(l => l.locations).find(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0),
[legs]
);
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
const tabs = [
@@ -938,7 +1003,11 @@ export const TourDetailPage = ({
setTargetLegId(loc.legId);
setMapCenter([loc.latitude, loc.longitude]);
setIsAddLocationOpen(true);
}} isPublicView={isPublicView} />
}}
onQuickNote={(locName: string) => handleQuickNote(locName)}
onSuccess={() => fetchTour(tourId)}
isPublicView={isPublicView}
/>
) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
@@ -1011,30 +1080,59 @@ export const TourDetailPage = ({
/>
)}
<MarkerClusterGroup chunkedLoading>
{legs.flatMap(l => l.locations).map((loc: any) => {
const isStart = startPoint?.id === loc.id;
const isEnd = endPoint?.id === loc.id;
// Sử dụng các icon tĩnh đã định nghĩa ở trên
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
<MarkerClusterGroup key={`cluster-group-${allLocations.length}`} chunkedLoading>
{allLocations.map((loc: any, index: number) => {
const isStart = startPoint && startPoint.id === loc.id;
const isEnd = endPoint && endPoint.id === loc.id;
// Lấy icon tương ứng từ mapIcons memoized
const icon = isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit;
// Tính quãng đường từ điểm trước đó (A -> B) để hiển thị tại điểm B
const prevLoc = index > 0 ? allLocations[index - 1] : null;
const distanceToPrev = prevLoc
? calculateDistance(prevLoc.latitude, prevLoc.longitude, loc.latitude, loc.longitude).toFixed(1)
: null;
return (
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
<Marker key={`marker-${loc.id}-${isStart ? 'start' : isEnd ? 'end' : 'visit'}`} position={[loc.latitude, loc.longitude]} icon={icon}>
<Popup>
<div className="p-1">
<div className="font-bold text-gray-900">{loc.name}</div>
<div className="text-[10px] text-gray-500 mb-2 uppercase tracking-tight">{loc.type}</div>
<button
onClick={() => {
setCommentLocationId(loc.id);
setCommentLocationName(loc.name);
setIsCommentModalOpen(true);
}}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100"
>
<MessageSquare className="w-3 h-3" />
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`}
</button>
<div className="font-bold text-gray-900 leading-tight mb-0.5">{loc.name}</div>
<div className="text-[10px] text-gray-400 mb-2 uppercase tracking-widest">{loc.type}</div>
<div className="flex flex-col gap-1">
<button
onClick={() => handleQuickNote(loc.name)}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-black transition-all border border-amber-100"
>
<FileText className="w-3 h-3" />
GHI CHÚ NHANH
</button>
<button
onClick={() => {
setCommentLocationId(loc.id);
setCommentLocationName(loc.name);
setIsCommentModalOpen(true);
}}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100"
>
<MessageSquare className="w-3 h-3" />
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`}
</button>
</div>
{distanceToPrev && distanceToPrev !== "0.0" && (
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-2 animate-in fade-in slide-in-from-bottom-1">
<div className="p-1.5 bg-blue-50 rounded-lg">
<Navigation className="w-3 h-3 text-blue-600 rotate-45" />
</div>
<div className="flex flex-col">
<span className="text-[9px] font-black text-blue-400 uppercase leading-none tracking-tighter mb-0.5">Khoảng cách từ chặng trước</span>
<span className="text-xs font-black text-blue-700">{distanceToPrev} km</span>
</div>
</div>
)}
</div>
</Popup>
</Marker>
@@ -1410,6 +1508,10 @@ export const TourDetailPage = ({
editingLocation={editingLocation}
tourId={currentTour.id}
isPublicView={isPublicView} // Pass isPublicView
onSuccess={() => {
console.log("TourDetailPage: AddLocationModal onSuccess -> Re-fetching tour data.");
fetchTour(tourId); // Re-fetch tour data after adding/editing location
}}
/>
)}