894 lines
43 KiB
TypeScript
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 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>
|
|
);
|
|
}; |