Sửa lỗi tái cấu trúc thư mục và khai báo import
This commit is contained in:
@@ -0,0 +1,905 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
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 { 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
|
||||
} 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 });
|
||||
|
||||
// Tách biệt các state và actions để tối ưu performance
|
||||
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 fetchTour = useTourStore(state => state.fetchTour);
|
||||
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 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
|
||||
|
||||
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 có 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user