Files
travelplanning/TourDetailPage.tsx

894 lines
43 KiB
TypeScript

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: `<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]
});
// 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<HTMLDivElement>(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 (
<div
ref={menuRef}
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
style={{ top: menuPos.y, left: menuPos.x }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-600" /> Bắt đầu từ đây
</button>
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50">
<div className="w-2 h-2 rounded-full bg-green-600" /> Kết thúc đây
</button>
<div className="px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest">Thêm vào chặng</div>
{legs.map(leg => (
<button
key={leg.id}
onClick={() => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate"
>
Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'}
</button>
))}
</div>
);
};
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<string | null>(null);
const [editingLocation, setEditingLocation] = useState<any>(null);
const [selectedMember, setSelectedMember] = useState<any>(null);
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
const [joinRequests, setJoinRequests] = useState<any[]>([]);
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-50 pb-20">
{/* Top Navigation Bar */}
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" />
</button>
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
{tourInfo.title}
</h1>
<div className="w-10" /> {/* Spacer */}
</div>
{/* Tour Header Info */}
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
<img
src={tourInfo.coverImage}
className="absolute inset-0 w-full h-full object-cover opacity-60"
alt="Tour Cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="relative z-10 p-6 text-white pt-28 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
{/* Dòng tóm tắt Lộ trình */}
<div className="mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full">
<span className="text-white/60 mr-1">Lộ trình:</span>
<span className="text-blue-300">Điểm xuất phát:</span>
<span className="ml-1 text-white banner-location-text" title={startPoint?.name}>{startPoint?.name || '...'}</span>
<span className="mx-2 text-white/30">-</span>
<span className="text-green-300">Điểm kết thúc:</span>
<span className="ml-1 text-white banner-location-text" title={endPoint?.name}>{endPoint?.name || '...'}</span>
</div>
<div className="flex flex-wrap gap-4 text-sm font-medium opacity-90">
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
<Calendar className="w-4 h-4 mr-1.5" />
{tourInfo.date}
</div>
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
<Users className="w-4 h-4 mr-1.5" />
{tourInfo.membersCount} thành viên
</div>
</div>
{/* Member Avatars Stack */}
<div className="flex items-center gap-2 mt-4">
<div className="flex flex-wrap gap-2">
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
<button
key={p.userId || i}
onClick={() => {
setSelectedMember(p);
setIsMemberDetailOpen(true);
}}
className="w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform"
title={p.user?.name || p.userId}
>
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
</button>
))}
{joinRequests.slice(0, 3).map((req: any) => (
<div key={req.id} className="relative group">
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
{req.user?.name?.charAt(0) || '?'}
</div>
<div className="absolute -top-1 -right-1 flex">
<button
type="button"
disabled={joinRequestActionId === req.id}
onClick={async (e) => {
e.stopPropagation();
if (!currentTour) return;
setConfirmState({
open: true,
title: 'Chấp nhận yêu cầu',
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
});
}}
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
aria-label="Accept"
>
+
</button>
<button
type="button"
disabled={joinRequestActionId === req.id}
onClick={async (e) => {
e.stopPropagation();
if (!currentTour) return;
setConfirmState({
open: true,
title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
});
}}
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
aria-label="Reject"
>
x
</button>
</div>
</div>
))}
{tourInfo.membersCount > 5 && (
<div className="w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg">
+{tourInfo.membersCount - 5}
</div>
)}
</div>
<button
onClick={() => {
if (!currentTour) return;
if (canEdit) setIsAddMemberOpen(true);
}}
disabled={!canEdit}
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
canEdit ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
}`}
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
{/* Financial Quick-View Widget or Quote */}
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
<div className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`}>
<div className="flex justify-between items-center">
{hasFinanceAccess ? (
<>
<div>
<p className="text-indigo-100 text-xs font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại</p>
<h3 className="text-3xl font-black">{tourInfo.budget}</h3>
</div>
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
</>
) : (
<div className="flex items-start gap-4 py-2">
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
</div>
)}
</div>
</div>
</div>
{/* Khối hiển thị Điểm đầu & Điểm cuối (Dưới Financial Quick-View) */}
<div className="max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
{startPoint && (
<div className="bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner">
<MapPin className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5">Điểm xuất phát</p>
<p className="text-sm font-bold text-gray-800 truncate">{startPoint.name}</p>
</div>
</div>
)}
{endPoint && (
<div className="bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner">
<Flag className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5">Điểm kết thúc</p>
<p className="text-sm font-bold text-gray-800 truncate">{endPoint.name}</p>
</div>
</div>
)}
</div>
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`}>
{/* Tab Switcher */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
}`}
>
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}`} />
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
<div className="transition-opacity duration-300">
{activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-2">
{/* View Mode Toggle */}
<div className="flex justify-center mb-6">
<div className="bg-gray-100 p-1 rounded-2xl flex gap-1">
<button
onClick={() => setViewMode('timeline')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
>
<List className="w-3.5 h-3.5" /> Danh sách
</button>
<button
onClick={() => setViewMode('map')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
>
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
</button>
</div>
</div>
{viewMode === 'timeline' ? (
<ItineraryTimeline onAddLocation={(legId) => {
setTargetLegId(legId);
setEditingLocation(null);
setIsAddLocationOpen(true);
}} onEditLocation={(loc) => {
setEditingLocation(loc);
setTargetLegId(loc.legId);
setMapCenter([loc.latitude, loc.longitude]);
setIsAddLocationOpen(true);
}} />
) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
<MapContainer
center={initialViewState?.center || mapCenter}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
{canEdit && <MapContextMenu onAction={handleMapAction} />}
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
<MapTourBounds locations={allLocations} />
{/* 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 && (
<Polyline
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
color="#3b82f6"
weight={3}
dashArray="5, 10"
smoothFactor={1.5}
/>
)}
<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;
return (
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
<Popup>
<div className="font-bold">{loc.name}</div>
<div className="text-xs text-gray-500">{loc.type}</div>
</Popup>
</Marker>
);
})}
</MarkerClusterGroup>
</MapContainer>
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm
</div>
</div>
)}
</div>
)}
{activeTab === 'expense' && (
<div className="animate-in fade-in slide-in-from-bottom-4">
<ExpenseManager />
</div>
)}
{activeTab === 'photo' && (
<div className="grid grid-cols-3 gap-1.5 animate-in fade-in">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white">
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<img
src={`https://picsum.photos/seed/${i + 10}/400/400`}
alt="Tour photo"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
</div>
))}
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-4">
<div className="p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
<div className="flex items-center gap-3 mb-4">
<Clock className="w-6 h-6 text-blue-500" />
<h3 className="text-lg font-bold text-gray-900">Yêu cầu tham gia</h3>
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full">{joinRequests.length} đang chờ</span>
</div>
<div className="space-y-2">
{joinRequests.map((req: any) => (
<div key={req.id} className="flex items-center justify-between gap-3 p-3 rounded-2xl border border-gray-100 bg-white">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-700 font-bold text-sm">
{req.user?.name?.charAt(0) || '?'}
</div>
<div>
<div className="text-sm font-bold text-gray-800">{req.user?.name || req.userId}</div>
<div className="text-[11px] text-gray-500">
Được mời bởi {req.requestedBy?.name} {new Date(req.createdAt).toLocaleString('vi-VN')}
</div>
</div>
</div>
<div className="flex gap-2">
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
if (!currentTour) return;
setConfirmState({
open: true,
title: 'Chấp nhận yêu cầu',
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
});
}}
className="p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50"
aria-label="Accept"
>
<Check className="w-4 h-4" />
</button>
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
if (!currentTour) return;
setConfirmState({
open: true,
title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
});
}}
className="p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50"
aria-label="Reject"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
))}
{joinRequests.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không yêu cầu tham gia nào đang chờ phê duyệt.</div>
)}
</div>
</div>
<div className="p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200">
<Settings className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">Tính năng cài đặt khác đang được cập nhật...</p>
</div>
</div>
)}
</div>
</div>
{/* Floating Action Button (Mobile) */}
{canEdit && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<button
onClick={() => {
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
setEditingLocation(null);
if (activeTab === 'plan') setIsAddLocationOpen(true);
}}
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
</button>
</div>
)}
{/* Add Member Modal */}
{currentTour && (
<AddMemberModal
isOpen={isAddMemberOpen}
onClose={() => 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 && (
<AddLocationModal
isOpen={isAddLocationOpen}
onClose={() => setIsAddLocationOpen(false)}
initialLegId={targetLegId || undefined}
editingLocation={editingLocation}
tourId={currentTour.id}
/>
)}
{/* Member Detail Popover */}
{isMemberDetailOpen && selectedMember && (
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => setIsMemberDetailOpen(false)} />
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold">
{selectedMember.user?.name?.charAt(0) || '?'}
</div>
<div>
<div className="text-base font-bold text-gray-900">{selectedMember.user?.name || 'Chưa đặt tên'}</div>
<div className="text-xs text-gray-500">{selectedMember.user?.email}</div>
<div className="text-[10px] font-semibold text-gray-500">{selectedMember.role}</div>
</div>
</div>
{(selectedMember.user?.phone || selectedMember.user?.address) && (
<div className="mt-3 text-xs text-gray-600 space-y-1">
{selectedMember.user?.phone && <div>📞 {selectedMember.user.phone}</div>}
{selectedMember.user?.address && <div>📍 {selectedMember.user.address}</div>}
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button onClick={() => setIsMemberDetailOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100">Đóng</button>
{canEdit && selectedMember.role !== 'OWNER' && (
<button
onClick={async () => {
if (!currentTour || !selectedMember) return;
try {
await removeMember(currentTour.id, selectedMember.userId);
setIsMemberDetailOpen(false);
} catch (e) {
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
}
}}
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold"
>
Xóa
</button>
)}
{canEdit && selectedMember.role === 'OWNER' && (
<button
onClick={() => {
setIsMemberDetailOpen(false);
setIsAddMemberOpen(true);
}}
className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold"
>
Mời thêm người
</button>
)}
</div>
</div>
</div>
)}
<ConfirmModal
isOpen={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={() => confirmState.onConfirm?.()}
onCancel={() => setConfirmState({ open: false })}
/>
<NotificationModal
isOpen={notificationModal.modalState?.isOpen ?? false}
title={notificationModal.modalState?.title}
message={notificationModal.modalState?.message}
type={notificationModal.modalState?.type}
onConfirm={() => notificationModal.closeModal()}
/>
</div>
);
};