import React, { useState, useEffect, useMemo } from 'react'; import { io } from 'socket.io-client'; import { ItineraryTimeline } from '../components/ItineraryTimeline'; import { ExpenseManager } from '../components/ExpenseManager'; import { useTourStore } from '@/store/useTourStore'; import { AddLocationModal } from '@/components/AddLocationModal'; import { AddMemberModal } from '../components/AddMemberModal'; import { ConfirmModal } from '../components/ConfirmModal'; import { NotificationModal, useNotificationModal } from '@/components/NotificationModal'; import { CommentModal } from '@/components/CommentModal'; import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet'; import _MarkerClusterGroup from 'react-leaflet-cluster'; const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup; import { Map as MapIcon, Wallet, Image as ImageIcon, Calendar, Users, ChevronLeft, Settings, Quote, Plus, List, Map as MapIconLucide, MapPin, Flag, Clock, Check, X, MessageSquare, Share2 } 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, tourId, isPublicView = false }: { onBack: () => void, tourId: string, isPublicView?: boolean }) => { // Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization const currentTour = useTourStore(state => state.currentTour); const legs = useTourStore(state => state.legs); const publicTours = useTourStore(state => state.publicTours); const userRole = useTourStore(state => state.userRole); const mapCenter = useTourStore(state => state.mapCenter); 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 [titleInput, setTitleInput] = useState(currentTour?.title ?? ''); const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? ''); // State cho input số lượng người tham gia const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0); const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0); const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0); const [isCommentModalOpen, setIsCommentModalOpen] = useState(false); const [commentLocationId, setCommentLocationId] = useState(''); const [commentLocationName, setCommentLocationName] = useState(''); const [joinRequestActionId, setJoinRequestActionId] = useState(null); const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false }); const fetchTour = useTourStore(state => state.fetchTour); const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action // Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour const handleCommentIncrement = (locationId: string) => { const currentLegs = useTourStore.getState().legs; const updatedLegs = currentLegs.map(leg => ({ ...leg, locations: leg.locations.map(loc => loc.id === locationId ? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } } : loc ) })); // Cập nhật trực tiếp vào Store useTourStore.setState({ legs: updatedLegs }); }; const fetchPublicTours = useTourStore(state => state.fetchPublicTours); const setMapCenter = useTourStore(state => state.setMapCenter); const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint); const updateTourEndPoint = useTourStore(state => state.updateTourEndPoint); const updateTourDetails = useTourStore(state => state.updateTourDetails); // Thêm action này const initializeLegs = useTourStore(state => state.initializeLegs); const addLocation = useTourStore(state => state.addLocation); const removeMember = useTourStore(state => state.removeMember); const fetchJoinRequests = useTourStore(state => state.fetchJoinRequests); const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest); const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest); 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 // Nếu là public view, không có quyền chỉnh sửa const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || ''); const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || ''); const isOwner = isPublicView ? false : userRole === 'OWNER'; const canShare = isPublicView || ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || ''); useEffect(() => { if (isPublicView) { fetchPublicTourDetails(tourId); } else if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) { fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([])); } // Fetch tour details when tourId changes or public view status changes if (tourId) { isPublicView ? fetchPublicTourDetails(tourId) : fetchTour(tourId); } }, [tourId, isPublicView, userRole]); // Add tourId to dependencies 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); } // This useEffect is for initial load, but we now have tourId prop // The fetching logic is moved to the useEffect above that depends on tourId and isPublicView // So this useEffect can be simplified or removed if its only purpose was initial data load. // if (publicTours.length === 0 && !isPublicView) { // Only fetch public tours if not in public view and not already loaded // fetchPublicTours(); // } }, [initialViewState]); // Removed publicTours, currentTour, fetchPublicTours, fetchTour from dependencies const handleShare = () => { if (!currentTour) return; // Tạo link với query param ?viewTour=... const shareUrl = `${window.location.origin}?viewTour=${currentTour.id}`; if (navigator.share) { navigator.share({ title: currentTour.title, url: shareUrl, }).catch(() => {}); } else if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(shareUrl).then(() => { notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success'); }); } else { // Giải pháp dự phòng cho môi trường không có HTTPS (truy cập qua IP) const textArea = document.createElement("textarea"); textArea.value = shareUrl; document.body.appendChild(textArea); textArea.select(); try { document.execCommand('copy'); notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success'); } catch (err) {} document.body.removeChild(textArea); } }; // Thiết lập kết nối WebSocket Real-time useEffect(() => { if (!currentTour) return; const socket = io(); // Kết nối qua Proxy của Vite (cùng origin) socket.on('connect', () => { socket.emit('joinTour', currentTour.id); }); socket.on('commentAdded', (data: any) => { // Cập nhật UI ngay lập tức khi bất kỳ ai bình luận handleCommentIncrement(data.locationId); }); return () => { socket.disconnect(); }; }, [currentTour?.id]); // This useEffect is for initial demo loading, might not be needed if tourId is always passed // useEffect(() => { // if (publicTours.length > 0 && !currentTour && !isPublicView) { // fetchTour(publicTours[0].id); // } // }, [publicTours, currentTour, fetchTour, isPublicView]); // 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); } } }; // Hàm xử lý cập nhật số lượng người tham gia const handleUpdateTourInfo = async () => { if (!currentTour) return; try { await updateTourDetails(currentTour.id, { title: titleInput, description: descriptionInput, adultCount: adultCountInput, childCount: childCountInput, childDiscount: childDiscountInput, }); notificationModal.openModal('Thành công', 'Đã cập nhật thông tin chuyến đi.', 'success'); fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan } catch (error: any) { notificationModal.openModal('Lỗi', error.message || 'Không thể cập nhật thông tin.', 'error'); } }; // 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 || ''); // Logic tính toán ngày hiển thị: Ưu tiên ngày của Tour, sau đó đến ngày của các Chặng const tourDateDisplay = useMemo(() => { if (currentTour?.startDate && currentTour?.endDate) { return `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}`; } const firstLeg = legs[0]; const lastLeg = legs[legs.length - 1]; const start = firstLeg?.startDate; const end = lastLeg?.endDate || lastLeg?.startDate; if (start && end) { return `${new Date(start).toLocaleDateString('vi-VN')} - ${new Date(end).toLocaleDateString('vi-VN')}`; } else if (start) { return `Từ ${new Date(start).toLocaleDateString('vi-VN')}`; } return "Chưa xác định ngày"; }, [currentTour, legs]); 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: tourDateDisplay, 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}

{/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */} {canShare && ( )}
{/* Tour Header Info */}
Tour Cover

{tourInfo.title}

{currentTour?.description && (

{currentTour.description}

)} {/* 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 */}
{/* Always show participants */} {currentTour?.participants?.slice(0, 5).map((p: any, i: number) => ( ))} {isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view
{req.user?.name?.charAt(0) || '?'}
))} {tourInfo.membersCount > 5 && (
+{tourInfo.membersCount - 5}
)}
{!isPublicView && ( // Hide add member button in public view )}
{/* Financial Quick-View Widget or Quote */}
hasFinanceAccess && setActiveTab('expense')} className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`} >
{hasFinanceAccess ? ( <>

Tổng chi tiêu hiện tại (Nhấn để xem chi tiết)

{tourInfo.budget}

) : ( // If no finance access or is public view, show quote

"{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); }} isPublicView={isPublicView} /> ) : (
{canEdit && !isPublicView && } {/* Hide map context menu in public view */} {/* 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}
); })}
{isPublicView ? 'Xem chi tiết lộ trình' : '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' && !isPublicView && ( // Hide settings tab in public view

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')}
{isOwner &&
}
))} {joinRequests.length === 0 && (
Không có yêu cầu tham gia nào đang chờ phê duyệt.
)}
{canEdit && (

Thông tin cơ bản

{isOwner && ( <>
setTitleInput(e.target.value)} className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold" placeholder="Nhập tên chuyến đi..." />