import React, { useState, useEffect, useMemo } from 'react'; import { ItineraryTimeline } from './ItineraryTimeline.js'; import { ExpenseManager } from './ExpenseManager.js'; import { useTourStore } from './useTourStore.js'; import { AddLocationModal } from './AddLocationModal.js'; import { AddMemberModal } from './AddMemberModal.js'; import { ConfirmModal } from './ConfirmModal.js'; import { NotificationModal, useNotificationModal } from './components/NotificationModal.js'; import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet'; import _MarkerClusterGroup from 'react-leaflet-cluster'; const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup; import { useMap } from 'react-leaflet'; import { Map as MapIcon, Wallet, Image as ImageIcon, Calendar, Users, ChevronLeft, Settings, Quote, Plus, List, Map as MapIconLucide, MapPin, Flag, Clock, Check, X } from 'lucide-react'; import L from 'leaflet'; // Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT'; // Fix lỗi icon mặc định của Leaflet cho môi trường Vite delete (L.Icon.Default.prototype as any)._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', 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: `
S
`, iconSize: [24, 24], iconAnchor: [12, 12] }); const END_ICON = L.divIcon({ className: 'custom-marker-e', html: `
E
`, iconSize: [24, 24], iconAnchor: [12, 12] }); const VISIT_ICON = L.divIcon({ className: 'custom-marker-v', html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] }); // 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[] }) => { const map = useMap(); // Tạo một key dựa trên giá trị tọa độ để tránh chạy lại khi chỉ thay đổi tham chiếu mảng const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]); useEffect(() => { if (locations.length > 0) { const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude])); if (locations.length === 1) { // Chỉ thực hiện nếu bản đồ chưa ở đúng vị trí (tránh trigger moveend liên tục) map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true }); } else { map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 }); } } }, [locKey, map]); return null; }; // Menu ngữ cảnh cho bản đồ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => { const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null); const menuRef = React.useRef(null); // Sử dụng selector để tránh re-render khi mapCenter thay đổi const legs = useTourStore(state => state.legs); const setMapCenter = useTourStore(state => state.setMapCenter); useMapEvents({ contextmenu: (e) => { // Ngăn menu mặc định của trình duyệt hiện lên. if (e.originalEvent) { L.DomEvent.preventDefault(e.originalEvent); L.DomEvent.stopPropagation(e.originalEvent); } console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`); setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng }); }, moveend: (e) => { const map = e.target; const center = map.getCenter(); const zoom = map.getZoom(); const coords: [number, number] = [center.lat, center.lng]; // Chỉ cập nhật store nếu tọa độ thay đổi đáng kể (> 0.0001) để tránh loop const currentStored = useTourStore.getState().mapCenter; const diff = Math.abs(currentStored[0] - coords[0]) + Math.abs(currentStored[1] - coords[1]); if (diff > 0.0001) { setMapCenter(coords); } localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom })); }, click: () => setMenuPos(null), dragstart: () => setMenuPos(null), }); // Ngăn chặn các sự kiện của bản đồ khi tương tác với menu useEffect(() => { if (menuPos && menuRef.current) { L.DomEvent.disableClickPropagation(menuRef.current); L.DomEvent.disableScrollPropagation(menuRef.current); } }, [menuPos]); if (!menuPos) return null; return (
e.stopPropagation()} onContextMenu={(e) => e.preventDefault()} >
Thêm vào chặng
{legs.map(leg => ( ))}
); }; export const TourDetailPage = ({ onBack }: { onBack: () => void }) => { const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan'); const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline'); const [isAddLocationOpen, setIsAddLocationOpen] = useState(false); const [isAddMemberOpen, setIsAddMemberOpen] = useState(false); const [targetLegId, setTargetLegId] = useState(null); const [editingLocation, setEditingLocation] = useState(null); const [selectedMember, setSelectedMember] = useState(null); const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false); const [joinRequests, setJoinRequests] = useState([]); const [joinRequestActionId, setJoinRequestActionId] = useState(null); const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false }); const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember, fetchJoinRequests, acceptJoinRequest, rejectJoinRequest } = useTourStore(); const notificationModal = useNotificationModal(); // Khôi phục vị trí và mức zoom từ localStorage const [initialViewState] = useState(() => { const saved = localStorage.getItem('map_view_state'); if (saved) { try { return JSON.parse(saved); } catch (e) { return null; } } return null; }); // SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi const canEdit = ['OWNER', 'MANAGER'].includes(userRole || ''); useEffect(() => { if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) { fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([])); } }, [currentTour, userRole]); const [mapZoom] = useState(initialViewState?.zoom || 13); // Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]); useEffect(() => { if (initialViewState) { setMapCenter(initialViewState.center); } const loadData = async () => { // Nếu chưa có tour nào trong store, thử tải danh sách public trước if (publicTours.length === 0) { await fetchPublicTours(); } }; loadData(); }, []); useEffect(() => { // Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo if (publicTours.length > 0 && !currentTour) { fetchTour(publicTours[0].id); } }, [publicTours, currentTour, fetchTour]); // Hàm xử lý khai báo số chặng const handleDeclareLegs = async () => { const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3"); const count = parseInt(countStr || "0"); if (count > 0 && currentTour) { await initializeLegs(currentTour.id, count); } }; // Hàm xử lý các hành động từ Context Menu của bản đồ const handleMapAction = async (action: string, latlng: L.LatLng) => { // Bước 1: Lấy tọa độ (lat, lng) tại vị trí click (đã nhận qua tham số latlng) console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng }); // Đảm bảo có tour và ít nhất một chặng để ghim const currentLegs = useTourStore.getState().legs; if (!currentTour || currentLegs.length === 0) { alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện."); return; } let targetLegId = currentLegs[0].id; // Mặc định là chặng đầu let defaultName = "Địa điểm mới"; let locationType: LocationType = 'VISIT'; // Mặc định là tham quan if (action === 'START') { defaultName = "Điểm bắt đầu"; locationType = 'MOVE'; // Điểm bắt đầu thường liên quan đến di chuyển } if (action === 'END') { targetLegId = currentLegs[currentLegs.length - 1].id; defaultName = "Điểm kết thúc"; locationType = 'MOVE'; // Điểm kết thúc cũng liên quan đến di chuyển } if (action.startsWith('ADD_TO_LEG_')) { targetLegId = action.replace('ADD_TO_LEG_', ''); // Loại địa điểm mặc định vẫn là VISIT nếu thêm vào chặng cụ thể } // Bước 2: Gửi request đến dịch vụ bản đồ để phân tích tọa độ thành tên địa điểm cụ thể let detectedName = ""; try { const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`); const data = await res.json(); const addr = data.address; // Ưu tiên lấy tên Location/Tòa nhà/Tên đường, bỏ qua Tỉnh/Thành phố nếu có thông tin chi tiết hơn detectedName = addr.amenity || addr.building || addr.historic || addr.tourist || addr.shop || addr.office || addr.leisure || addr.attraction || addr.road || addr.neighbourhood || addr.suburb || data.display_name?.split(',')[0] || ""; console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`); } catch (e) { console.warn("[FRONTEND] Reverse Geocoding failed:", e); } try { if (action === 'START') { // Bước 3: Mutate State & UI - Đặt startLocationName = resolvedPlaceName const resolvedPlaceName = detectedName || "Điểm xuất phát"; console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng); await updateTourStartPoint(currentTour.id, { name: resolvedPlaceName, latitude: latlng.lat, longitude: latlng.lng, }); console.log("[FRONTEND] START point updated successfully."); // Giao diện Top Banner và Ghim màu xanh sẽ tự động cập nhật // khi store fetch lại dữ liệu tour và re-render. } else if (action === 'END') { // Bước 2: Thiết lập Điểm kết thúc const finalName = detectedName || "Điểm kết thúc"; await updateTourEndPoint(currentTour.id, { name: finalName, latitude: latlng.lat, longitude: latlng.lng, }); } else { // Đối với việc thêm địa điểm vào chặng, vẫn sử dụng Prompt để người dùng đặt tên theo ý muốn const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName); if (!name) return; await addLocation(currentTour.id, { name, address: '', latitude: latlng.lat, longitude: latlng.lng, legId: targetLegId, type: locationType as any, }); } } catch (error: any) { // Xử lý lỗi từ API (Ví dụ: Tour chưa có chặng nào) if (error.message.includes('Không tìm thấy chặng')) { alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc."); } else { alert("Đã xảy ra lỗi: " + error.message); } } }; // 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]; // Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store const tabs = [ { id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true }, { id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') }, { id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true }, { id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' }, ].filter(t => t.visible); const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || ''); const travelQuotes = [ "Đừng nghe họ nói, hãy tự mình đi xem.", "Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.", "Hành trình ngàn dặm bắt đầu từ một bước chân.", "Đi là để trở về, nhưng với một tâm hồn mới." ]; const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []); const tourInfo = { title: currentTour?.title || "Hành trình khám phá TP.HCM", date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày", membersCount: currentTour?.participants?.length || 0, budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND", coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop" }; return (
{/* Top Navigation Bar */}

{tourInfo.title}

{/* Spacer */}
{/* Tour Header Info */}
Tour Cover

{tourInfo.title}

{/* Dòng tóm tắt Lộ trình */}
Lộ trình: Điểm xuất phát: {startPoint?.name || '...'} - Điểm kết thúc: {endPoint?.name || '...'}
{tourInfo.date}
{tourInfo.membersCount} thành viên
{/* Member Avatars Stack */}
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => ( ))} {joinRequests.slice(0, 3).map((req: any) => (
{req.user?.name?.charAt(0) || '?'}
))} {tourInfo.membersCount > 5 && (
+{tourInfo.membersCount - 5}
)}
{/* Financial Quick-View Widget or Quote */}
{hasFinanceAccess ? ( <>

Tổng chi tiêu hiện tại

{tourInfo.budget}

) : (

"{randomQuote}"

)}
{/* Khối hiển thị Điểm đầu & Điểm cuối (Dưới Financial Quick-View) */}
{startPoint && (

Điểm xuất phát

{startPoint.name}

)} {endPoint && (

Điểm kết thúc

{endPoint.name}

)}
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
{/* Tab Switcher */}
{tabs.map((tab) => ( ))}
{/* Tab Panels */}
{activeTab === 'plan' && (
{/* View Mode Toggle */}
{viewMode === 'timeline' ? ( { setTargetLegId(legId); setEditingLocation(null); setIsAddLocationOpen(true); }} onEditLocation={(loc) => { setEditingLocation(loc); setTargetLegId(loc.legId); setMapCenter([loc.latitude, loc.longitude]); setIsAddLocationOpen(true); }} /> ) : (
{canEdit && } {/* Tự động đóng khung Start và End khi dữ liệu thay đổi */} {/* Vẽ đường Polyline nối các điểm - Liên tục toàn bộ lộ trình xuyên suốt các chặng */} {allLocations.length > 1 && ( [l.latitude, l.longitude]) as any} color="#3b82f6" weight={3} dashArray="5, 10" smoothFactor={1.5} /> )} {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; return (
{loc.name}
{loc.type}
); })}
Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm
)}
)} {activeTab === 'expense' && (
)} {activeTab === 'photo' && (
{[1, 2, 3, 4, 5, 6].map((i) => (
Tour photo
))}
)} {activeTab === 'settings' && (

Yêu cầu tham gia

{joinRequests.length} đang chờ
{joinRequests.map((req: any) => (
{req.user?.name?.charAt(0) || '?'}
{req.user?.name || req.userId}
Được mời bởi {req.requestedBy?.name} • {new Date(req.createdAt).toLocaleString('vi-VN')}
))} {joinRequests.length === 0 && (
Không có yêu cầu tham gia nào đang chờ phê duyệt.
)}

Tính năng cài đặt khác đang được cập nhật...

)}
{/* Floating Action Button (Mobile) */} {canEdit && (
)} {/* Add Member Modal */} {currentTour && ( setIsAddMemberOpen(false)} tourId={currentTour.id} participants={currentTour.participants || []} joinRequests={joinRequests} onRemoveMember={(userId) => removeMember(currentTour.id, userId)} onMemberAdded={() => fetchTour(currentTour.id)} userRole={userRole || undefined} /> )} {/* Add Location Modal */} {currentTour && ( setIsAddLocationOpen(false)} initialLegId={targetLegId || undefined} editingLocation={editingLocation} tourId={currentTour.id} /> )} {/* Member Detail Popover */} {isMemberDetailOpen && selectedMember && (
setIsMemberDetailOpen(false)} />
{selectedMember.user?.name?.charAt(0) || '?'}
{selectedMember.user?.name || 'Chưa đặt tên'}
{selectedMember.user?.email}
{selectedMember.role}
{(selectedMember.user?.phone || selectedMember.user?.address) && (
{selectedMember.user?.phone &&
📞 {selectedMember.user.phone}
} {selectedMember.user?.address &&
📍 {selectedMember.user.address}
}
)}
{canEdit && selectedMember.role !== 'OWNER' && ( )} {canEdit && selectedMember.role === 'OWNER' && ( )}
)} confirmState.onConfirm?.()} onCancel={() => setConfirmState({ open: false })} /> notificationModal.closeModal()} />
); };