388 lines
42 KiB
JavaScript
388 lines
42 KiB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
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.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';
|
|
delete L.Icon.Default.prototype._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',
|
|
});
|
|
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]
|
|
});
|
|
const MapTourBounds = ({ locations }) => {
|
|
const map = useMap();
|
|
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) {
|
|
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
|
|
}
|
|
else {
|
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
|
}
|
|
}
|
|
}, [locKey, map]);
|
|
return null;
|
|
};
|
|
const MapContextMenu = ({ onAction }) => {
|
|
const [menuPos, setMenuPos] = useState(null);
|
|
const menuRef = React.useRef(null);
|
|
const legs = useTourStore(state => state.legs);
|
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
|
useMapEvents({
|
|
contextmenu: (e) => {
|
|
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 = [center.lat, center.lng];
|
|
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),
|
|
});
|
|
useEffect(() => {
|
|
if (menuPos && menuRef.current) {
|
|
L.DomEvent.disableClickPropagation(menuRef.current);
|
|
L.DomEvent.disableScrollPropagation(menuRef.current);
|
|
}
|
|
}, [menuPos]);
|
|
if (!menuPos)
|
|
return null;
|
|
return (_jsxs("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(), children: [_jsxs("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", children: [_jsx("div", { className: "w-2 h-2 rounded-full bg-blue-600" }), " B\u1EAFt \u0111\u1EA7u t\u1EEB \u0111\u00E2y"] }), _jsxs("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", children: [_jsx("div", { className: "w-2 h-2 rounded-full bg-green-600" }), " K\u1EBFt th\u00FAc \u1EDF \u0111\u00E2y"] }), _jsx("div", { className: "px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest", children: "Th\u00EAm v\u00E0o ch\u1EB7ng" }), legs.map(leg => (_jsxs("button", { 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", children: ["Ch\u1EB7ng ", leg.sequence, ": ", leg.note || 'Không có ghi chú'] }, leg.id)))] }));
|
|
};
|
|
export const TourDetailPage = ({ onBack }) => {
|
|
const [activeTab, setActiveTab] = useState('plan');
|
|
const [viewMode, setViewMode] = useState('timeline');
|
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
|
const [targetLegId, setTargetLegId] = useState(null);
|
|
const [editingLocation, setEditingLocation] = useState(null);
|
|
const [selectedMember, setSelectedMember] = useState(null);
|
|
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
|
const [joinRequests, setJoinRequests] = useState([]);
|
|
const [joinRequestActionId, setJoinRequestActionId] = useState(null);
|
|
const [confirmState, setConfirmState] = useState({ open: false });
|
|
const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember, fetchJoinRequests, acceptJoinRequest, rejectJoinRequest } = useTourStore();
|
|
const notificationModal = useNotificationModal();
|
|
const [initialViewState] = useState(() => {
|
|
const saved = localStorage.getItem('map_view_state');
|
|
if (saved) {
|
|
try {
|
|
return JSON.parse(saved);
|
|
}
|
|
catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
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);
|
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
|
useEffect(() => {
|
|
if (initialViewState) {
|
|
setMapCenter(initialViewState.center);
|
|
}
|
|
const loadData = async () => {
|
|
if (publicTours.length === 0) {
|
|
await fetchPublicTours();
|
|
}
|
|
};
|
|
loadData();
|
|
}, []);
|
|
useEffect(() => {
|
|
if (publicTours.length > 0 && !currentTour) {
|
|
fetchTour(publicTours[0].id);
|
|
}
|
|
}, [publicTours, currentTour, fetchTour]);
|
|
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);
|
|
}
|
|
};
|
|
const handleMapAction = async (action, latlng) => {
|
|
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
|
|
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;
|
|
let defaultName = "Địa điểm mới";
|
|
let locationType = 'VISIT';
|
|
if (action === 'START') {
|
|
defaultName = "Điểm bắt đầu";
|
|
locationType = 'MOVE';
|
|
}
|
|
if (action === 'END') {
|
|
targetLegId = currentLegs[currentLegs.length - 1].id;
|
|
defaultName = "Điểm kết thúc";
|
|
locationType = 'MOVE';
|
|
}
|
|
if (action.startsWith('ADD_TO_LEG_')) {
|
|
targetLegId = action.replace('ADD_TO_LEG_', '');
|
|
}
|
|
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;
|
|
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') {
|
|
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.");
|
|
}
|
|
else if (action === 'END') {
|
|
const finalName = detectedName || "Điểm kết thúc";
|
|
await updateTourEndPoint(currentTour.id, {
|
|
name: finalName,
|
|
latitude: latlng.lat,
|
|
longitude: latlng.lng,
|
|
});
|
|
}
|
|
else {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
catch (error) {
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
const startPoint = legs[0]?.locations[0];
|
|
const lastLeg = legs[legs.length - 1];
|
|
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
|
|
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 (_jsxs("div", { className: "min-h-screen bg-gray-50 pb-20", children: [_jsxs("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", children: [_jsx("button", { onClick: onBack, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: _jsx(ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), _jsx("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), _jsx("div", { className: "w-10" }), " "] }), _jsxs("div", { className: "relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end", children: [_jsx("img", { src: tourInfo.coverImage, className: "absolute inset-0 w-full h-full object-cover opacity-60", alt: "Tour Cover" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" }), _jsx("div", { className: "relative z-10 p-6 text-white pt-28 pb-20", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-4", children: [_jsx("h2", { className: "text-3xl font-black tracking-tight drop-shadow-md", children: tourInfo.title }), _jsxs("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", children: [_jsx("span", { className: "text-white/60 mr-1", children: "L\u1ED9 tr\u00ECnh:" }), _jsx("span", { className: "text-blue-300", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: startPoint?.name, children: startPoint?.name || '...' }), _jsx("span", { className: "mx-2 text-white/30", children: "-" }), _jsx("span", { className: "text-green-300", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: endPoint?.name, children: endPoint?.name || '...' })] }), _jsxs("div", { className: "flex flex-wrap gap-4 text-sm font-medium opacity-90", children: [_jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), _jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.membersCount, " th\u00E0nh vi\u00EAn"] })] }), _jsxs("div", { className: "flex items-center gap-2 mt-4", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [currentTour?.participants?.slice(0, 5).map((p, i) => (_jsx("button", { 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, children: _jsx("img", { src: `https://i.pravatar.cc/100?u=${p.userId}`, alt: "Avatar" }) }, p.userId || i))), joinRequests.slice(0, 3).map((req) => (_jsxs("div", { className: "relative group", children: [_jsx("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", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("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) {
|
|
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", children: "+" }), _jsx("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) {
|
|
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", children: "x" })] })] }, req.id))), tourInfo.membersCount > 5 && (_jsxs("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", children: ["+", tourInfo.membersCount - 5] }))] }), _jsx("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'}`, children: _jsx(Plus, { className: "w-4 h-4" }) })] })] }) })] }), _jsx("div", { className: "max-w-2xl mx-auto -mt-10 px-4 relative z-10", children: _jsx("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'}`, children: _jsx("div", { className: "flex justify-between items-center", children: hasFinanceAccess ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("p", { className: "text-indigo-100 text-xs font-black uppercase tracking-widest mb-1", children: "T\u1ED5ng chi ti\u00EAu hi\u1EC7n t\u1EA1i" }), _jsx("h3", { className: "text-3xl font-black", children: tourInfo.budget })] }), _jsx("div", { className: "p-4 bg-white/10 rounded-2xl backdrop-blur-md", children: _jsx(Wallet, { className: "w-8 h-8" }) })] })) : (_jsxs("div", { className: "flex items-start gap-4 py-2", children: [_jsx(Quote, { className: "w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" }), _jsxs("p", { className: "italic text-lg font-medium leading-relaxed", children: ["\"", randomQuote, "\""] })] })) }) }) }), _jsxs("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", children: [startPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner", children: _jsx(MapPin, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: startPoint.name })] })] })), endPoint && (_jsxs("div", { className: "bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow", children: [_jsx("div", { className: "w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner", children: _jsx(Flag, { className: "w-5 h-5" }) }), _jsxs("div", { className: "overflow-hidden", children: [_jsx("p", { className: "text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" }), _jsx("p", { className: "text-sm font-bold text-gray-800 truncate", children: endPoint.name })] })] }))] }), _jsxs("div", { className: `${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`, children: [_jsx("div", { className: "bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20", children: tabs.map((tab) => (_jsxs("button", { onClick: () => setActiveTab(tab.id), 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'}`, children: [_jsx(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}` }), tab.label] }, tab.id))) }), _jsxs("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && (_jsxs("div", { className: "animate-in fade-in slide-in-from-bottom-2", children: [_jsx("div", { className: "flex justify-center mb-6", children: _jsxs("div", { className: "bg-gray-100 p-1 rounded-2xl flex gap-1", children: [_jsxs("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'}`, children: [_jsx(List, { className: "w-3.5 h-3.5" }), " Danh s\u00E1ch"] }), _jsxs("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'}`, children: [_jsx(MapIconLucide, { className: "w-3.5 h-3.5" }), " B\u1EA3n \u0111\u1ED3"] })] }) }), viewMode === 'timeline' ? (_jsx(ItineraryTimeline, { onAddLocation: (legId) => {
|
|
setTargetLegId(legId);
|
|
setEditingLocation(null);
|
|
setIsAddLocationOpen(true);
|
|
}, onEditLocation: (loc) => {
|
|
setEditingLocation(loc);
|
|
setTargetLegId(loc.legId);
|
|
setMapCenter([loc.latitude, loc.longitude]);
|
|
setIsAddLocationOpen(true);
|
|
} })) : (_jsxs("div", { className: "h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative", children: [_jsxs(MapContainer, { center: initialViewState?.center || mapCenter, zoom: mapZoom, className: "h-full w-full", preferCanvas: true, children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" }), canEdit && _jsx(MapContextMenu, { onAction: handleMapAction }), _jsx(MapTourBounds, { locations: allLocations }), allLocations.length > 1 && (_jsx(Polyline, { positions: allLocations.map(l => [l.latitude, l.longitude]), color: "#3b82f6", weight: 3, dashArray: "5, 10", smoothFactor: 1.5 })), _jsx(MarkerClusterGroup, { chunkedLoading: true, children: legs.flatMap(l => l.locations).map((loc) => {
|
|
const isStart = startPoint?.id === loc.id;
|
|
const isEnd = endPoint?.id === loc.id;
|
|
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
|
|
return (_jsx(Marker, { position: [loc.latitude, loc.longitude], icon: icon, children: _jsxs(Popup, { children: [_jsx("div", { className: "font-bold", children: loc.name }), _jsx("div", { className: "text-xs text-gray-500", children: loc.type })] }) }, loc.id));
|
|
}) })] }), _jsx("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", children: "M\u1EB9o: Nh\u1EA5n gi\u1EEF (Mobile) ho\u1EB7c Chu\u1ED9t ph\u1EA3i \u0111\u1EC3 ghim \u0111\u1ECBa \u0111i\u1EC3m" })] }))] })), activeTab === 'expense' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ExpenseManager, {}) })), activeTab === 'photo' && (_jsx("div", { className: "grid grid-cols-3 gap-1.5 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs("div", { className: "aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white", children: [_jsx("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), _jsx("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" })] }, i))) })), activeTab === 'settings' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsxs("div", { className: "flex items-center gap-3 mb-4", children: [_jsx(Clock, { className: "w-6 h-6 text-blue-500" }), _jsx("h3", { className: "text-lg font-bold text-gray-900", children: "Y\u00EAu c\u1EA7u tham gia" }), _jsxs("span", { className: "text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full", children: [joinRequests.length, " \u0111ang ch\u1EDD"] })] }), _jsxs("div", { className: "space-y-2", children: [joinRequests.map((req) => (_jsxs("div", { className: "flex items-center justify-between gap-3 p-3 rounded-2xl border border-gray-100 bg-white", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("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", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-bold text-gray-800", children: req.user?.name || req.userId }), _jsxs("div", { className: "text-[11px] text-gray-500", children: ["\u0110\u01B0\u1EE3c m\u1EDDi b\u1EDFi ", req.requestedBy?.name, " \u2022 ", new Date(req.createdAt).toLocaleString('vi-VN')] })] })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("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) {
|
|
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", children: _jsx(Check, { className: "w-4 h-4" }) }), _jsx("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) {
|
|
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", children: _jsx(X, { className: "w-4 h-4" }) })] })] }, req.id))), joinRequests.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng c\u00F3 y\u00EAu c\u1EA7u tham gia n\u00E0o \u0111ang ch\u1EDD ph\u00EA duy\u1EC7t." }))] })] }), _jsxs("div", { className: "p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200", children: [_jsx(Settings, { className: "w-10 h-10 text-gray-300 mx-auto mb-3" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng c\u00E0i \u0111\u1EB7t kh\u00E1c \u0111ang \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt..." })] })] }))] })] }), canEdit && (_jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { onClick: () => {
|
|
setTargetLegId(null);
|
|
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", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })), currentTour && (_jsx(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 })), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id })), isMemberDetailOpen && selectedMember && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: () => setIsMemberDetailOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("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", children: selectedMember.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-base font-bold text-gray-900", children: selectedMember.user?.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-xs text-gray-500", children: selectedMember.user?.email }), _jsx("div", { className: "text-[10px] font-semibold text-gray-500", children: selectedMember.role })] })] }), (selectedMember.user?.phone || selectedMember.user?.address) && (_jsxs("div", { className: "mt-3 text-xs text-gray-600 space-y-1", children: [selectedMember.user?.phone && _jsxs("div", { children: ["\uD83D\uDCDE ", selectedMember.user.phone] }), selectedMember.user?.address && _jsxs("div", { children: ["\uD83D\uDCCD ", selectedMember.user.address] })] })), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsMemberDetailOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100", children: "\u0110\u00F3ng" }), canEdit && selectedMember.role !== 'OWNER' && (_jsx("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", children: "X\u00F3a" })), canEdit && selectedMember.role === 'OWNER' && (_jsx("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", children: "M\u1EDDi th\u00EAm ng\u01B0\u1EDDi" }))] })] })] })), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) }), _jsx(NotificationModal, { isOpen: notificationModal.modalState?.isOpen ?? false, title: notificationModal.modalState?.title, message: notificationModal.modalState?.message, type: notificationModal.modalState?.type, onConfirm: () => notificationModal.closeModal() })] }));
|
|
};
|
|
//# sourceMappingURL=TourDetailPage.js.map
|