2189 lines
108 KiB
TypeScript
2189 lines
108 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { io } from 'socket.io-client';
|
|
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
|
import { ExpenseManager } from '../components/ExpenseManager';
|
|
import { useTourStore } from '@/store/useTourStore';
|
|
import { AddLocationModal } from '@/components/AddLocationModal';
|
|
import { AddMemberModal } from '../components/AddMemberModal';
|
|
import { useConfirm } from '@/hooks/useConfirm';
|
|
import { useNotification } from '@/hooks/useNotification';
|
|
import { CommentModal } from '@/components/CommentModal';
|
|
import { AddPhotoModal } from '@/components/AddPhotoModal';
|
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet';
|
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
|
import {
|
|
Map as MapIcon,
|
|
Wallet,
|
|
Image as ImageIcon,
|
|
Upload,
|
|
Calendar,
|
|
Users,
|
|
ChevronLeft,
|
|
Settings,
|
|
Quote,
|
|
Plus,
|
|
List,
|
|
Map as MapIconLucide,
|
|
MapPin,
|
|
Search,
|
|
LocateFixed,
|
|
Loader2,
|
|
Compass,
|
|
Car,
|
|
Bike,
|
|
Navigation,
|
|
Footprints,
|
|
Flag,
|
|
Clock,
|
|
Check,
|
|
X,
|
|
MessageSquare,
|
|
Share2,
|
|
Tag as TagIcon,
|
|
Trash2,
|
|
FileText
|
|
} 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';
|
|
|
|
// Định nghĩa kiểu dữ liệu cho OSRM Route
|
|
interface OSRMRoute {
|
|
geometry: {
|
|
coordinates: [number, number][]; // [lng, lat]
|
|
};
|
|
distance: number; // meters
|
|
duration: number; // seconds
|
|
legs: { distance: number; duration: number; }[]; // OSRM's internal legs for a route
|
|
}
|
|
|
|
// Định nghĩa kiểu dữ liệu cho điểm (bao gồm cả userLocation khi được chuyển đổi)
|
|
interface LocationPoint {
|
|
latitude: number;
|
|
longitude: number;
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
legId: string;
|
|
}
|
|
|
|
// 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',
|
|
});
|
|
|
|
/**
|
|
* Helper tính khoảng cách giữa 2 tọa độ (Haversine formula)
|
|
*/
|
|
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
|
const p = 0.017453292519943295; // Math.PI / 180
|
|
const c = Math.cos;
|
|
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
|
c(lat1 * p) * c(lat2 * p) *
|
|
(1 - c((lon2 - lon1) * p)) / 2;
|
|
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
|
}
|
|
|
|
// Helper function to combine segment routes into a single overall route
|
|
const combineSegmentRoutes = (segmentRoutes: OSRMRoute[][], selectedIndices: number[]): OSRMRoute | null => {
|
|
if (segmentRoutes.length === 0) return null;
|
|
|
|
let combinedGeometry: [number, number][] = [];
|
|
let combinedDistance = 0;
|
|
let combinedDuration = 0;
|
|
const combinedLegs: { distance: number; duration: number; }[] = [];
|
|
|
|
for (let i = 0; i < segmentRoutes.length; i++) {
|
|
const segmentIndex = selectedIndices[i] !== undefined ? selectedIndices[i] : 0; // Default to first alternative
|
|
const chosenRoute = segmentRoutes[i][segmentIndex];
|
|
|
|
if (!chosenRoute) {
|
|
// If a segment has no chosen route (e.g., no alternatives or API failed),
|
|
// we cannot form a complete route.
|
|
return null;
|
|
}
|
|
|
|
// Concatenate geometry, avoiding duplicate points at segment junctions
|
|
if (i > 0 && combinedGeometry.length > 0 && chosenRoute.geometry.coordinates.length > 0) {
|
|
const lastPointOfPrev = combinedGeometry[combinedGeometry.length - 1];
|
|
const firstPointOfCurrent = chosenRoute.geometry.coordinates[0];
|
|
// OSRM coordinates are [lng, lat]
|
|
if (Math.abs(lastPointOfPrev[0] - firstPointOfCurrent[0]) < 1e-6 &&
|
|
Math.abs(lastPointOfPrev[1] - firstPointOfCurrent[1]) < 1e-6) {
|
|
combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates.slice(1));
|
|
} else {
|
|
combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates);
|
|
}
|
|
} else {
|
|
combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates);
|
|
}
|
|
|
|
combinedDistance += chosenRoute.distance;
|
|
combinedDuration += chosenRoute.duration;
|
|
combinedLegs.push(...chosenRoute.legs); // OSRM legs are sub-segments within a route
|
|
}
|
|
|
|
return {
|
|
geometry: { coordinates: combinedGeometry },
|
|
distance: combinedDistance,
|
|
duration: combinedDuration,
|
|
legs: combinedLegs,
|
|
};
|
|
};
|
|
|
|
// 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;
|
|
};
|
|
// Component Helper để di chuyển tâm bản đồ về vị trí người dùng khi nhấn nút
|
|
const RecenterUser = ({ position, trigger }: { position: [number, number] | null, trigger: number }) => {
|
|
const map = useMap();
|
|
useEffect(() => {
|
|
if (position && trigger > 0) {
|
|
map.setView(position, 16, { animate: true });
|
|
}
|
|
}, [trigger, position, map]);
|
|
return null;
|
|
};
|
|
// Component Helper để xử lý xoay bản đồ theo hướng di chuyển hoặc hướng Bắc
|
|
const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
|
const map = useMap();
|
|
useEffect(() => {
|
|
const container = map.getContainer();
|
|
// Xoay container bản đồ và scale nhẹ để tránh lộ khoảng trắng ở các góc khi xoay
|
|
container.style.transform = `rotate(${rotation}deg) scale(${rotation === 0 ? 1 : 1.2})`;
|
|
container.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
|
}, [rotation, map]);
|
|
return null;
|
|
};
|
|
|
|
// Component Helper để hiển thị mẹo khi người dùng dừng chuột trên bản đồ quá 3 giây
|
|
const MapHoverTip = ({ canEdit }: { canEdit: boolean }) => {
|
|
const [tipPos, setTipPos] = useState<L.LatLng | null>(null);
|
|
const [visible, setVisible] = useState(false);
|
|
const timerRef = React.useRef<any>(null);
|
|
|
|
useMapEvents({
|
|
mousemove: (e) => {
|
|
if (!canEdit) return;
|
|
setVisible(false);
|
|
setTipPos(e.latlng);
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(() => {
|
|
setVisible(true);
|
|
}, 3000);
|
|
},
|
|
mousedown: () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
setVisible(false);
|
|
},
|
|
dragstart: () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
setVisible(false);
|
|
},
|
|
contextmenu: () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
setVisible(false);
|
|
}
|
|
});
|
|
|
|
if (!visible || !tipPos) return null;
|
|
|
|
return (
|
|
<Marker position={tipPos} icon={L.divIcon({ className: 'opacity-0' })}>
|
|
<Tooltip direction="top" offset={[0, -10]} opacity={0.9} permanent>
|
|
<span className="text-[10px] font-bold text-blue-600 whitespace-nowrap">Mẹo: nhấn giữ chuột phải để ghim</span>
|
|
</Tooltip>
|
|
</Marker>
|
|
);
|
|
};
|
|
// 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,
|
|
tourId,
|
|
isPublicView = false,
|
|
onOpenNotes
|
|
}: {
|
|
onBack: () => void,
|
|
tourId: string,
|
|
isPublicView?: boolean,
|
|
onOpenNotes?: () => void
|
|
}) => {
|
|
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
|
const currentTour = useTourStore(state => state.currentTour);
|
|
const legs = useTourStore(state => state.legs);
|
|
const publicTours = useTourStore(state => state.publicTours);
|
|
const userRole = useTourStore(state => state.userRole);
|
|
const mapCenter = useTourStore(state => state.mapCenter);
|
|
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
|
|
|
// Định nghĩa Icons bên trong Component bằng useMemo để đảm bảo tính ổn định và tránh lỗi render
|
|
const mapIcons = useMemo(() => ({
|
|
start: L.divIcon({
|
|
className: '!bg-transparent !border-none',
|
|
html: `<div class="w-7 h-7 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">S</div>`,
|
|
iconSize: [28, 28],
|
|
iconAnchor: [14, 14]
|
|
}),
|
|
end: L.divIcon({
|
|
className: '!bg-transparent !border-none',
|
|
html: `<div class="w-7 h-7 bg-green-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">E</div>`,
|
|
iconSize: [28, 28],
|
|
iconAnchor: [14, 14]
|
|
}),
|
|
visit: L.divIcon({
|
|
className: '!bg-transparent !border-none',
|
|
html: `<div class="w-5 h-5 bg-indigo-500 rounded-full border-2 border-white shadow-lg hover:scale-125 transition-transform flex items-center justify-center"><div class="w-1.5 h-1.5 bg-white rounded-full opacity-50"></div></div>`,
|
|
iconSize: [20, 20],
|
|
iconAnchor: [10, 10]
|
|
}),
|
|
user: L.divIcon({
|
|
className: '!bg-transparent !border-none',
|
|
html: `
|
|
<div class="relative">
|
|
<div class="w-4 h-4 bg-blue-500 rounded-full border-2 border-white shadow-lg z-10"></div>
|
|
<div class="absolute -inset-2 bg-blue-400 rounded-full opacity-40 animate-ping"></div>
|
|
</div>
|
|
`,
|
|
iconSize: [16, 16],
|
|
iconAnchor: [8, 8]
|
|
})
|
|
}), []);
|
|
|
|
useEffect(() => {
|
|
if (!isPublicView && navigator.geolocation) {
|
|
const watchId = navigator.geolocation.watchPosition(
|
|
(pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]),
|
|
(err) => console.warn("Lỗi định vị người dùng:", err),
|
|
{ enableHighAccuracy: true }
|
|
);
|
|
return () => navigator.geolocation.clearWatch(watchId);
|
|
}
|
|
}, [isPublicView]);
|
|
|
|
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 [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
|
const [isStartPointAction, setIsStartPointAction] = useState(false);
|
|
const [isEndPointAction, setIsEndPointAction] = 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 [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
|
|
const currentUserId = useMemo(() => {
|
|
const token = localStorage.getItem('token');
|
|
if (!token) return null;
|
|
try {
|
|
return JSON.parse(atob(token.split('.')[1])).sub;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}, []);
|
|
const [joinRequests, setJoinRequests] = useState<any[]>([]);
|
|
const [titleInput, setTitleInput] = useState(currentTour?.title ?? '');
|
|
const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? '');
|
|
// State cho input số lượng người tham gia
|
|
const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0);
|
|
const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0);
|
|
const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0);
|
|
const [tagsInput, setTagsInput] = useState<string[]>(currentTour?.tags ?? []);
|
|
const [customTag, setCustomTag] = useState('');
|
|
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
|
const [selectedLegIdForPhoto, setSelectedLegIdForPhoto] = useState<string | 'all'>('all'); // Keep this state
|
|
|
|
// Memoized filtered photos based on selectedLegIdForPhoto
|
|
const filteredPhotos = useMemo(() => {
|
|
if (!currentTour?.photos) return [];
|
|
let photosToFilter = currentTour.photos;
|
|
|
|
if (selectedLegIdForPhoto !== 'all') {
|
|
const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto);
|
|
if (targetLeg) {
|
|
const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id);
|
|
// Filter photos that have a locationId and that locationId is in the current leg
|
|
photosToFilter = photosToFilter.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId));
|
|
} else {
|
|
photosToFilter = []; // If leg not found, no photos
|
|
}
|
|
}
|
|
return photosToFilter;
|
|
}, [currentTour?.photos, selectedLegIdForPhoto, legs]);
|
|
|
|
// Effect to set initial selected photo for display or reset if current one is no longer in filtered list
|
|
useEffect(() => {
|
|
if (filteredPhotos.length > 0 && !selectedPhotoForDisplay) {
|
|
setSelectedPhotoForDisplay(filteredPhotos[0]);
|
|
} else if (selectedPhotoForDisplay && !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id)) {
|
|
setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? filteredPhotos[0] : null);
|
|
}
|
|
}, [filteredPhotos, selectedPhotoForDisplay]);
|
|
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
|
const [commentLocationId, setCommentLocationId] = useState('');
|
|
const [commentLocationName, setCommentLocationName] = useState('');
|
|
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
|
|
|
// State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [locateTrigger, setLocateTrigger] = useState(0);
|
|
const [isMapControlsOpen, setIsMapControlsOpen] = useState(false);
|
|
const [isHeadingMode, setIsHeadingMode] = useState(false);
|
|
const [mapRotation, setMapRotation] = useState(0);
|
|
const [isRoutingLoading, setIsRoutingLoading] = useState(false);
|
|
const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving');
|
|
const [routes, setRoutes] = useState<any[]>([]);
|
|
const [selectedRouteIndex, setSelectedRouteIndex] = useState(0);
|
|
const [routeMenu, setRouteMenu] = useState<{ x: number, y: number, index: number } | null>(null);
|
|
const [drivingRoute, setDrivingRoute] = useState<[number, number][]>([]);
|
|
const [segmentDistances, setSegmentDistances] = useState<number[]>([]);
|
|
|
|
const handleSearchLocation = async (query: string) => {
|
|
setSearchQuery(query);
|
|
if (query.trim().length < 2) {
|
|
setSearchResults([]);
|
|
return;
|
|
}
|
|
setIsSearching(true);
|
|
try {
|
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&addressdetails=1&namedetails=1&accept-language=vi`);
|
|
const data = await res.json();
|
|
setSearchResults(data);
|
|
} catch (e) {
|
|
console.error("Lỗi tìm kiếm:", e);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
};
|
|
|
|
const handleDeletePhoto = async (photoId: string) => {
|
|
const isConfirmed = await confirm({
|
|
title: 'Xóa ảnh này?',
|
|
message: 'Bạn có chắc chắn muốn xóa ảnh này khỏi chuyến đi? Hành động này không thể hoàn tác.'
|
|
});
|
|
|
|
if (!isConfirmed) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/photos/${photoId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Không thể xóa ảnh');
|
|
|
|
notify({ title: 'Thành công', message: 'Đã xóa ảnh.', type: 'success' });
|
|
fetchTour(tourId); // Re-fetch tour to update photo list
|
|
setSelectedPhotoForDisplay(null); // Reset selected photo after deletion
|
|
} catch (error) {
|
|
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
|
|
}
|
|
};
|
|
|
|
const fetchTour = useTourStore(state => state.fetchTour);
|
|
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
|
|
|
useEffect(() => {
|
|
if (currentTour) {
|
|
setTagsInput(currentTour.tags || []);
|
|
}
|
|
}, [currentTour]);
|
|
|
|
// Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour
|
|
const handleCommentIncrement = (locationId: string) => {
|
|
const currentLegs = useTourStore.getState().legs;
|
|
const updatedLegs = currentLegs.map(leg => ({
|
|
...leg,
|
|
locations: leg.locations.map(loc =>
|
|
loc.id === locationId
|
|
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
|
|
: loc
|
|
)
|
|
}));
|
|
// Cập nhật trực tiếp vào Store
|
|
useTourStore.setState({ legs: updatedLegs });
|
|
};
|
|
|
|
const handleCommentDecrement = (locationId: string) => {
|
|
const currentLegs = useTourStore.getState().legs;
|
|
const updatedLegs = currentLegs.map(leg => ({
|
|
...leg,
|
|
locations: leg.locations.map(loc =>
|
|
loc.id === locationId
|
|
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
|
|
: loc
|
|
)
|
|
}));
|
|
useTourStore.setState({ legs: updatedLegs });
|
|
};
|
|
|
|
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
|
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
|
|
const updateTourEndPoint = useTourStore(state => state.updateTourEndPoint);
|
|
const updateTourDetails = useTourStore(state => state.updateTourDetails); // Thêm action này
|
|
const initializeLegs = useTourStore(state => state.initializeLegs);
|
|
const addLocation = useTourStore(state => state.addLocation);
|
|
const removeMember = useTourStore(state => state.removeMember);
|
|
const fetchJoinRequests = useTourStore(state => state.fetchJoinRequests);
|
|
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
|
|
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
|
const deleteTour = useTourStore(state => state.deleteTour);
|
|
|
|
const confirm = useConfirm();
|
|
const notify = useNotification();
|
|
|
|
// Khôi phục vị trí và mức zoom từ localStorage
|
|
const [initialViewState] = useState(() => {
|
|
const saved = localStorage.getItem('map_view_state');
|
|
if (saved) {
|
|
try { return JSON.parse(saved); } catch (e) { return null; }
|
|
}
|
|
return null;
|
|
});
|
|
|
|
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
|
|
// Nếu là public view, không có quyền chỉnh sửa
|
|
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
|
const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
|
const canUploadPhoto = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE'].includes(userRole || '');
|
|
const isOwner = isPublicView ? false : userRole === 'OWNER';
|
|
const canShare = isPublicView || ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
|
|
|
useEffect(() => {
|
|
if (isPublicView) {
|
|
fetchPublicTourDetails(tourId);
|
|
} else if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
|
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
|
|
}
|
|
// Fetch tour details when tourId changes or public view status changes
|
|
if (tourId) { isPublicView ? fetchPublicTourDetails(tourId) : fetchTour(tourId); }
|
|
}, [tourId, isPublicView, userRole]); // Add tourId to dependencies
|
|
|
|
// New useEffect to manage initial photo display when currentTour or legs change
|
|
useEffect(() => {
|
|
if (currentTour && currentTour.photos && currentTour.photos.length > 0) {
|
|
// If there are photos, and no leg is selected, default to 'all' and first photo
|
|
if (selectedLegIdForPhoto === 'all' && !selectedPhotoForDisplay) {
|
|
setSelectedPhotoForDisplay(currentTour.photos[0]);
|
|
} else if (selectedLegIdForPhoto !== 'all') {
|
|
// If a specific leg is selected, try to find a photo for that leg
|
|
const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto);
|
|
if (targetLeg) {
|
|
const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id);
|
|
const photosInLeg = currentTour.photos.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId));
|
|
if (photosInLeg.length > 0 && !selectedPhotoForDisplay) {
|
|
setSelectedPhotoForDisplay(photosInLeg[0]);
|
|
} else if (selectedPhotoForDisplay && !photosInLeg.some(p => p.id === selectedPhotoForDisplay.id)) {
|
|
setSelectedPhotoForDisplay(photosInLeg.length > 0 ? photosInLeg[0] : null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, [currentTour, legs, selectedLegIdForPhoto, selectedPhotoForDisplay]);
|
|
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]);
|
|
|
|
// Tạo key định danh cho lộ trình để buộc bản đồ vẽ lại khi dữ liệu thay đổi
|
|
const routeKey = useMemo(() => JSON.stringify({
|
|
locations: allLocations.map(l => ({ id: l.id, lat: l.latitude, lon: l.longitude })),
|
|
user: userLocation ? { lat: userLocation[0].toFixed(5), lon: userLocation[1].toFixed(5) } : null,
|
|
travelMode: travelMode,
|
|
selectedRouteIndex: selectedRouteIndex // Include selectedRouteIndex to force re-render when alternative is chosen
|
|
}), [allLocations, userLocation, travelMode, selectedRouteIndex]);
|
|
|
|
// Tự động tìm các quãng đường di chuyển thực tế theo phương tiện và vẽ lên bản đồ
|
|
useEffect(() => {
|
|
const fetchRoutes = async () => {
|
|
// Prepare points: userLocation + allLocations
|
|
const currentPoints: LocationPoint[] = [...allLocations];
|
|
if (userLocation) {
|
|
currentPoints.unshift({
|
|
latitude: userLocation[0],
|
|
longitude: userLocation[1],
|
|
id: 'user-location', // Dummy ID
|
|
name: 'Vị trí hiện tại', // Dummy name
|
|
type: 'MOVE', // Dummy type
|
|
legId: '', // Dummy legId
|
|
});
|
|
}
|
|
|
|
if (currentPoints.length < 2) {
|
|
setRoutes([]);
|
|
setSelectedRouteIndex(0);
|
|
setDrivingRoute([]);
|
|
setSegmentDistances([]);
|
|
return;
|
|
}
|
|
|
|
// Chèn vị trí người dùng vào đầu danh sách tọa độ nếu có
|
|
const coordsArray = allLocations.map(loc => `${loc.longitude},${loc.latitude}`);
|
|
|
|
if (userLocation) {
|
|
coordsArray.unshift(`${userLocation[1]},${userLocation[0]}`);
|
|
}
|
|
|
|
const coordsString = coordsArray.join(';');
|
|
|
|
setIsRoutingLoading(true);
|
|
try {
|
|
const segmentPromises: Promise<OSRMRoute[] | null>[] = [];
|
|
for (let i = 0; i < currentPoints.length - 1; i++) {
|
|
const p1 = currentPoints[i];
|
|
const p2 = currentPoints[i + 1];
|
|
const coordsString = `${p1.longitude},${p1.latitude};${p2.longitude},${p2.latitude}`;
|
|
|
|
segmentPromises.push(
|
|
fetch(`https://router.project-osrm.org/route/v1/${travelMode}/${coordsString}?overview=full&geometries=geojson&alternatives=3`) // Yêu cầu tối đa 5 phương án thay thế cho mỗi phân đoạn
|
|
.then(res => {
|
|
if (!res.ok) throw new Error(`OSRM API error: ${res.status}`);
|
|
return res.json();
|
|
})
|
|
.then(data => {
|
|
if (data.code === 'Ok' && data.routes.length > 0) {
|
|
return data.routes; // Array of OSRMRoute for this segment
|
|
}
|
|
return null; // No routes for this segment
|
|
})
|
|
.catch(error => {
|
|
console.error(`Lỗi lấy lộ trình cho phân đoạn ${i}-${i+1}:`, error);
|
|
return null;
|
|
})
|
|
);
|
|
}
|
|
|
|
const allSegmentAlternativesRaw = await Promise.all(segmentPromises);
|
|
const allSegmentAlternatives: OSRMRoute[][] = allSegmentAlternativesRaw.filter((seg): seg is OSRMRoute[] => seg !== null);
|
|
|
|
if (allSegmentAlternatives.length === 0) {
|
|
setRoutes([]);
|
|
setSelectedRouteIndex(0);
|
|
return;
|
|
}
|
|
|
|
// Now, construct overall alternative routes from segment alternatives
|
|
// Heuristic: Take the first alternative of each segment to form the primary route.
|
|
// Then, for subsequent overall alternatives, try different alternatives for the first segment,
|
|
// keeping other segments at their primary alternative.
|
|
|
|
const overallAlternativeRoutes: OSRMRoute[] = [];
|
|
// Limit overall alternatives based on the number of alternatives for the first segment, up to 3
|
|
const maxOverallAlternativesToGenerate = 3; // Số lượng lộ trình tổng thể muốn tạo
|
|
|
|
// Heuristic để tạo các lộ trình tổng thể đa dạng hơn:
|
|
// 1. Lộ trình chính (fastest/default cho tất cả các phân đoạn)
|
|
const primarySegmentIndices = allSegmentAlternatives.map(() => 0);
|
|
const primaryCombinedRoute = combineSegmentRoutes(allSegmentAlternatives, primarySegmentIndices);
|
|
if (primaryCombinedRoute) {
|
|
overallAlternativeRoutes.push(primaryCombinedRoute);
|
|
}
|
|
|
|
// 2. Các lộ trình thay thế: Thử kết hợp các phương án thay thế từ các phân đoạn
|
|
// Ví dụ: Lấy phương án thứ N của mỗi phân đoạn (nếu có), hoặc phương án 0 nếu không có
|
|
for (let altChoice = 1; altChoice < maxOverallAlternativesToGenerate; altChoice++) {
|
|
const selectedSegmentIndices: number[] = allSegmentAlternatives.map(segmentAlts =>
|
|
Math.min(altChoice, segmentAlts.length - 1) // Chọn phương án thứ 'altChoice', hoặc phương án cuối cùng nếu không đủ
|
|
);
|
|
const combinedRoute = combineSegmentRoutes(allSegmentAlternatives, selectedSegmentIndices);
|
|
if (combinedRoute && !overallAlternativeRoutes.some(r => JSON.stringify(r.geometry.coordinates) === JSON.stringify(combinedRoute.geometry.coordinates))) {
|
|
overallAlternativeRoutes.push(combinedRoute);
|
|
}
|
|
}
|
|
setRoutes(overallAlternativeRoutes);
|
|
setSelectedRouteIndex(0); // Always select the first overall alternative by default
|
|
|
|
} catch (error) {
|
|
console.error(`Lỗi lấy lộ trình ${travelMode}:`, error);
|
|
setRoutes([]);
|
|
} finally {
|
|
setIsRoutingLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchRoutes();
|
|
}, [allLocations, travelMode, userLocation]);
|
|
|
|
// Cập nhật dữ liệu lộ trình hiển thị khi người dùng chọn phương án khác
|
|
useEffect(() => {
|
|
if (routes.length > 0 && routes[selectedRouteIndex]) {
|
|
const route = routes[selectedRouteIndex];
|
|
// OSRM trả về [lng, lat], cần đổi sang [lat, lng] cho Leaflet
|
|
const mappedCoords: [number, number][] = route.geometry.coordinates.map((c: any) => [c[1], c[0]]);
|
|
setDrivingRoute(mappedCoords);
|
|
// Lưu quãng đường từng chặng của lộ trình được chọn
|
|
setSegmentDistances(route.legs.map((leg: any) => leg.distance / 1000));
|
|
}
|
|
}, [selectedRouteIndex, routes]);
|
|
|
|
// Tính toán thông tin hiển thị cho lộ trình đang chọn để đề xuất cho người dùng
|
|
const selectedRouteInfo = useMemo(() => {
|
|
if (!routes || routes.length === 0 || !routes[selectedRouteIndex]) return null;
|
|
const r = routes[selectedRouteIndex];
|
|
|
|
// Định dạng thời gian di chuyển
|
|
const duration = r.duration;
|
|
const hours = Math.floor(duration / 3600);
|
|
const minutes = Math.round((duration % 3600) / 60);
|
|
const durationStr = hours > 0 ? `${hours}h${minutes}p` : `${minutes}p`;
|
|
|
|
// Xác định nhãn đề xuất: Index 0 thường là lộ trình tối ưu nhất của OSRM (thông dụng nhất)
|
|
// Kiểm tra thêm nếu đây là lộ trình ngắn nhất trong các phương án
|
|
const isShortest = routes.length > 1 && r.distance === Math.min(...routes.map(rt => rt.distance));
|
|
|
|
let label = "Lộ trình";
|
|
if (selectedRouteIndex === 0) label = "Đề xuất";
|
|
else if (isShortest) label = "Ngắn nhất";
|
|
else label = `Lựa chọn ${selectedRouteIndex + 1}`;
|
|
|
|
return {
|
|
distance: (r.distance / 1000).toFixed(1),
|
|
duration: durationStr,
|
|
label
|
|
};
|
|
}, [routes, selectedRouteIndex]);
|
|
|
|
useEffect(() => {
|
|
if (initialViewState) {
|
|
setMapCenter(initialViewState.center);
|
|
}
|
|
// This useEffect is for initial load, but we now have tourId prop
|
|
// The fetching logic is moved to the useEffect above that depends on tourId and isPublicView
|
|
// So this useEffect can be simplified or removed if its only purpose was initial data load.
|
|
// if (publicTours.length === 0 && !isPublicView) { // Only fetch public tours if not in public view and not already loaded
|
|
// fetchPublicTours();
|
|
// }
|
|
}, [initialViewState]); // Removed publicTours, currentTour, fetchPublicTours, fetchTour from dependencies
|
|
|
|
|
|
const handleShare = () => {
|
|
if (!currentTour) return;
|
|
// Tạo link với query param ?viewTour=...
|
|
const shareUrl = `${window.location.origin}?viewTour=${currentTour.id}`;
|
|
if (navigator.share) {
|
|
navigator.share({
|
|
title: currentTour.title,
|
|
url: shareUrl,
|
|
}).catch(() => {});
|
|
} else if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã sao chép liên kết chia sẻ chuyến đi!',
|
|
type: 'success'
|
|
});
|
|
});
|
|
} else {
|
|
// Giải pháp dự phòng cho môi trường không có HTTPS (truy cập qua IP)
|
|
const textArea = document.createElement("textarea");
|
|
textArea.value = shareUrl;
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã sao chép liên kết chia sẻ chuyến đi!',
|
|
type: 'success'
|
|
});
|
|
} catch (err) {}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
};
|
|
|
|
// Thiết lập kết nối WebSocket Real-time
|
|
useEffect(() => {
|
|
if (!currentTour) return;
|
|
|
|
const socket = io(); // Kết nối qua Proxy của Vite (cùng origin)
|
|
|
|
socket.on('connect', () => {
|
|
socket.emit('joinTour', currentTour.id);
|
|
});
|
|
|
|
socket.on('commentAdded', (data: any) => {
|
|
// Cập nhật UI ngay lập tức khi bất kỳ ai bình luận
|
|
handleCommentIncrement(data.locationId);
|
|
});
|
|
|
|
return () => { socket.disconnect(); };
|
|
}, [currentTour?.id]);
|
|
|
|
// This useEffect is for initial demo loading, might not be needed if tourId is always passed
|
|
// useEffect(() => {
|
|
// if (publicTours.length > 0 && !currentTour && !isPublicView) {
|
|
// fetchTour(publicTours[0].id);
|
|
// }
|
|
// }, [publicTours, currentTour, fetchTour, isPublicView]);
|
|
|
|
|
|
|
|
// Hàm xử lý các hành động từ Context Menu của bản đồ
|
|
const handleMapAction = async (action: string, latlng: L.LatLng) => {
|
|
// Bước 1: Lấy tọa độ (lat, lng) tại vị trí click (đã nhận qua tham số latlng)
|
|
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
|
|
|
|
// Đảm bảo có tour và ít nhất một chặng để ghim
|
|
const currentLegs = useTourStore.getState().legs;
|
|
if (!currentTour || currentLegs.length === 0) {
|
|
alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện.");
|
|
return;
|
|
}
|
|
|
|
let targetLegId = currentLegs[0].id; // Mặc định là chặng đầu
|
|
let defaultName = "Địa điểm mới";
|
|
let locationType: LocationType = 'VISIT'; // Mặc định là tham quan
|
|
|
|
if (action === 'START') {
|
|
defaultName = "Điểm bắt đầu";
|
|
locationType = 'MOVE'; // Điểm bắt đầu thường liên quan đến di chuyển
|
|
}
|
|
if (action === 'END') {
|
|
targetLegId = currentLegs[currentLegs.length - 1].id;
|
|
defaultName = "Điểm kết thúc";
|
|
locationType = 'MOVE'; // Điểm kết thúc cũng liên quan đến di chuyển
|
|
}
|
|
if (action.startsWith('ADD_TO_LEG_')) {
|
|
targetLegId = action.replace('ADD_TO_LEG_', '');
|
|
// Loại địa điểm mặc định vẫn là VISIT nếu thêm vào chặng cụ thể
|
|
}
|
|
|
|
// Bước 2: Gửi request đến dịch vụ bản đồ để phân tích tọa độ thành tên địa điểm cụ thể
|
|
let detectedName = "";
|
|
try {
|
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
|
const data = await res.json();
|
|
const addr = data.address;
|
|
// Ưu tiên lấy tên Location/Tòa nhà/Tên đường, bỏ qua Tỉnh/Thành phố nếu có thông tin chi tiết hơn
|
|
detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
|
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
|
addr.road || addr.neighbourhood || addr.suburb ||
|
|
data.display_name?.split(',')[0] || "";
|
|
console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`);
|
|
} catch (e) {
|
|
console.warn("[FRONTEND] Reverse Geocoding failed:", e);
|
|
}
|
|
|
|
try {
|
|
if (action === 'START') {
|
|
// Bước 3: Mutate State & UI - Đặt startLocationName = resolvedPlaceName
|
|
const resolvedPlaceName = detectedName || "Điểm xuất phát";
|
|
console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng);
|
|
await updateTourStartPoint(currentTour.id, {
|
|
name: resolvedPlaceName,
|
|
latitude: latlng.lat,
|
|
longitude: latlng.lng,
|
|
});
|
|
console.log("[FRONTEND] START point updated successfully.");
|
|
// Giao diện Top Banner và Ghim màu xanh sẽ tự động cập nhật
|
|
// khi store fetch lại dữ liệu tour và re-render.
|
|
} else if (action === 'END') {
|
|
// Bước 2: Thiết lập Điểm kết thúc
|
|
const finalName = detectedName || "Điểm kết thúc";
|
|
await updateTourEndPoint(currentTour.id, {
|
|
name: finalName,
|
|
latitude: latlng.lat,
|
|
longitude: latlng.lng,
|
|
});
|
|
} else {
|
|
// Đối với việc thêm địa điểm vào chặng, vẫn sử dụng Prompt để người dùng đặt tên theo ý muốn
|
|
const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName);
|
|
if (!name) return;
|
|
|
|
await addLocation(currentTour.id, {
|
|
name,
|
|
address: '',
|
|
latitude: latlng.lat,
|
|
longitude: latlng.lng,
|
|
legId: targetLegId,
|
|
type: locationType as any,
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
// Xử lý lỗi từ API (Ví dụ: Tour chưa có chặng nào)
|
|
if (error.message.includes('Không tìm thấy chặng')) {
|
|
alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc.");
|
|
} else {
|
|
alert("Đã xảy ra lỗi: " + error.message);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Hàm xử lý cập nhật số lượng người tham gia
|
|
const handleUpdateTourInfo = async () => {
|
|
if (!currentTour) return;
|
|
try {
|
|
await updateTourDetails(currentTour.id, {
|
|
title: titleInput,
|
|
description: descriptionInput,
|
|
adultCount: adultCountInput,
|
|
childCount: childCountInput,
|
|
childDiscount: childDiscountInput,
|
|
tags: tagsInput
|
|
});
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã cập nhật thông tin chuyến đi.',
|
|
type: 'success'
|
|
});
|
|
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
|
} catch (error: any) {
|
|
notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' });
|
|
}
|
|
};
|
|
// Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour
|
|
const handleQuickNote = (locationName: string) => {
|
|
if (isPublicView) return;
|
|
|
|
const content = window.prompt(`Ghi chú nhanh cho địa điểm: ${locationName}`);
|
|
if (!content || !content.trim()) return;
|
|
|
|
const storedUser = localStorage.getItem('user');
|
|
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
|
|
const userName = user.name || 'Thành viên';
|
|
const now = new Date().toLocaleString('vi-VN');
|
|
|
|
const noteTitle = `Ghi chú của hành trình: ${currentTour?.title}`;
|
|
const savedNotes = localStorage.getItem('my_journey_notes');
|
|
let notes = [];
|
|
try {
|
|
notes = savedNotes ? JSON.parse(savedNotes) : [];
|
|
} catch (e) { notes = []; }
|
|
|
|
let targetNote = notes.find((n: any) => n.title === noteTitle);
|
|
|
|
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp
|
|
const newContentLine = `
|
|
<div class="quick-note-box" style="border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; background: #fffbeb; border-radius: 8px; border: 1px solid #fef3c7; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
|
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} • 👤 ${userName}</span>
|
|
</div>
|
|
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${locationName}:</strong> ${content}</p>
|
|
</div>
|
|
<p></p>
|
|
`;
|
|
|
|
if (targetNote) {
|
|
targetNote.content += newContentLine;
|
|
} else {
|
|
const newNote = {
|
|
id: Date.now().toString(),
|
|
title: noteTitle,
|
|
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour?.title}</strong> của bạn tại đây...</p>` + newContentLine,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
notes.unshift(newNote);
|
|
}
|
|
|
|
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
|
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
|
|
};
|
|
|
|
// Hàm xử lý xóa Tour vĩnh viễn
|
|
const handleDeleteTour = async () => {
|
|
if (!currentTour) return;
|
|
const isConfirmed = await confirm({
|
|
title: 'Xóa Tour vĩnh viễn?',
|
|
message: 'Toàn bộ dữ liệu về lộ trình, chi phí và hình ảnh của chuyến đi này sẽ bị xóa bỏ hoàn toàn. Bạn có chắc chắn muốn thực hiện?'
|
|
});
|
|
|
|
if (isConfirmed) {
|
|
try {
|
|
await deleteTour(currentTour.id);
|
|
notify({ title: 'Thành công', message: 'Hành trình đã được xóa.', type: 'success' });
|
|
onBack(); // Quay về trang khám phá sau khi xóa thành công
|
|
} catch (error: any) {
|
|
notify({ title: 'Lỗi', message: error.message || 'Không thể xóa hành trình.', type: 'error' });
|
|
}
|
|
}
|
|
};
|
|
|
|
// Ngăn chặn sự kiện click trên menu lộ trình làm ảnh hưởng bản đồ
|
|
const routeMenuRef = React.useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (routeMenu && routeMenuRef.current) {
|
|
L.DomEvent.disableClickPropagation(routeMenuRef.current);
|
|
}
|
|
}, [routeMenu]);
|
|
|
|
// Đóng menu lộ trình khi chuyển tab hoặc thay đổi chế độ xem
|
|
useEffect(() => {
|
|
setRouteMenu(null);
|
|
}, [activeTab, viewMode]);
|
|
|
|
// 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 = useMemo(() =>
|
|
legs.flatMap(l => l.locations).find(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0),
|
|
[legs]
|
|
);
|
|
const endPoint = useMemo(() =>
|
|
legs.flatMap(l => l.locations).find(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0),
|
|
[legs]
|
|
);
|
|
|
|
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
|
|
const tabs = [
|
|
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
|
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
|
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
|
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
|
|
].filter(t => t.visible);
|
|
|
|
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
|
|
|
// Logic tính toán ngày hiển thị: Ưu tiên ngày của Tour, sau đó đến ngày của các Chặng
|
|
const tourDateDisplay = useMemo(() => {
|
|
if (currentTour?.startDate && currentTour?.endDate) {
|
|
return `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}`;
|
|
}
|
|
const firstLeg = legs[0];
|
|
const lastLeg = legs[legs.length - 1];
|
|
const start = firstLeg?.startDate;
|
|
const end = lastLeg?.endDate || lastLeg?.startDate;
|
|
if (start && end) {
|
|
return `${new Date(start).toLocaleDateString('vi-VN')} - ${new Date(end).toLocaleDateString('vi-VN')}`;
|
|
} else if (start) {
|
|
return `Từ ${new Date(start).toLocaleDateString('vi-VN')}`;
|
|
}
|
|
return "Chưa xác định ngày";
|
|
}, [currentTour, legs]);
|
|
|
|
const travelQuotes = [
|
|
"Đừng nghe họ nói, hãy tự mình đi xem.",
|
|
"Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.",
|
|
"Hành trình ngàn dặm bắt đầu từ một bước chân.",
|
|
"Đi là để trở về, nhưng với một tâm hồn mới."
|
|
];
|
|
|
|
const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []);
|
|
|
|
const tourInfo = {
|
|
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
|
date: tourDateDisplay,
|
|
membersCount: currentTour?.participants?.length || 0,
|
|
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
|
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
|
};
|
|
|
|
return (
|
|
<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 flex-1 text-center">
|
|
{tourInfo.title}
|
|
</h1>
|
|
<div className="flex items-center gap-1">
|
|
{/* Nút Ghi chú: Chỉ hiển thị cho người dùng đã đăng nhập và không phải view công khai */}
|
|
{!isPublicView && onOpenNotes && (
|
|
<button
|
|
onClick={onOpenNotes}
|
|
className="p-2 hover:bg-amber-50 text-amber-600 rounded-full transition-colors"
|
|
title="Ghi chú của tôi"
|
|
>
|
|
<FileText className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
{/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */}
|
|
{canShare && (
|
|
<button
|
|
onClick={handleShare}
|
|
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
|
|
title="Chia sẻ tour"
|
|
>
|
|
<Share2 className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</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>
|
|
|
|
{/* Nhãn hiển thị ngay dưới Tiêu đề */}
|
|
{currentTour?.tags && currentTour.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{currentTour.tags.map((tag: string) => (
|
|
<span key={tag} className="px-2.5 py-1 bg-white/20 backdrop-blur-md border border-white/30 rounded-lg text-[10px] font-black uppercase tracking-wider">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mô tả hiển thị dưới Nhãn */}
|
|
{currentTour?.description && (
|
|
<p className="text-sm md:text-base text-white/90 max-w-xl line-clamp-3 md:line-clamp-none bg-black/20 backdrop-blur-sm p-4 rounded-2xl border border-white/10 italic leading-relaxed">
|
|
<Quote className="w-4 h-4 inline-block mr-2 opacity-50" />
|
|
{currentTour.description}
|
|
</p>
|
|
)}
|
|
|
|
{/* 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"> {/* Always show participants */}
|
|
{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>
|
|
))}
|
|
{isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view
|
|
<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;
|
|
const isConfirmed = await confirm({
|
|
title: 'Chấp nhận yêu cầu',
|
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
|
|
});
|
|
if (isConfirmed) {
|
|
setJoinRequestActionId(req.id);
|
|
try {
|
|
await acceptJoinRequest(currentTour.id, req.id);
|
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
} catch (e: any) {
|
|
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
|
|
} finally {
|
|
setJoinRequestActionId(null);
|
|
}
|
|
}
|
|
}}
|
|
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;
|
|
const isConfirmed = await confirm({
|
|
title: 'Từ chối yêu cầu',
|
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
|
|
});
|
|
if (isConfirmed) {
|
|
setJoinRequestActionId(req.id);
|
|
try {
|
|
await rejectJoinRequest(currentTour.id, req.id);
|
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
} catch (e: any) {
|
|
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
|
|
} finally {
|
|
setJoinRequestActionId(null);
|
|
}
|
|
}
|
|
}}
|
|
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>
|
|
{!isPublicView && ( // Hide add member button in public view
|
|
<button
|
|
onClick={() => {
|
|
if (!currentTour) return;
|
|
if (canInvite) setIsAddMemberOpen(true);
|
|
}}
|
|
disabled={!canInvite}
|
|
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
|
|
canInvite ? '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 // Always show quote if public view, otherwise show financial widget if has access
|
|
onClick={() => hasFinanceAccess && setActiveTab('expense')}
|
|
className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
{hasFinanceAccess ? (
|
|
<>
|
|
<div>
|
|
<p className="text-indigo-100 text-[10px] font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại (Nhấn để xem chi tiết)</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>
|
|
</>
|
|
) : ( // If no finance access or is public view, show quote
|
|
<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 ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
|
|
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, isStart, isEnd) => {
|
|
setTargetLegId(legId);
|
|
setEditingLocation(null);
|
|
setIsStartPointAction(!!isStart);
|
|
setIsEndPointAction(!!isEnd);
|
|
setIsAddLocationOpen(true);
|
|
}} onEditLocation={(loc) => {
|
|
setEditingLocation(loc);
|
|
setTargetLegId(loc.legId);
|
|
setMapCenter([loc.latitude, loc.longitude]);
|
|
|
|
// Kiểm tra xem địa điểm đang sửa có phải là điểm mốc đặc biệt không (dựa trên timestamp 1970)
|
|
const isStart = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
|
|
const isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0;
|
|
setIsStartPointAction(!!isStart);
|
|
setIsEndPointAction(!!isEnd);
|
|
|
|
setIsAddLocationOpen(true);
|
|
}}
|
|
onQuickNote={(locName: string) => handleQuickNote(locName)}
|
|
onSuccess={() => fetchTour(tourId)}
|
|
isPublicView={isPublicView}
|
|
/>
|
|
) : (
|
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
|
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
|
|
{!isPublicView && (
|
|
<div className="absolute top-3 right-3 z-[1001] w-48 md:w-64">
|
|
<div className="relative group">
|
|
<input
|
|
type="text"
|
|
placeholder="Tìm địa điểm..."
|
|
className="w-full pl-8 pr-8 py-2 bg-white/95 backdrop-blur-md border border-white/20 rounded-xl shadow-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all text-xs font-bold"
|
|
value={searchQuery}
|
|
onChange={e => handleSearchLocation(e.target.value)}
|
|
/>
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-blue-500" />
|
|
{isSearching ? (
|
|
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 animate-spin text-blue-500" />
|
|
) : searchQuery && (
|
|
<button onClick={() => { setSearchQuery(''); setSearchResults([]); }} className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-red-500 transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Kết quả tìm kiếm */}
|
|
{searchResults.length > 0 && (
|
|
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-48 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
|
{searchResults.map((result, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => {
|
|
setMapCenter([parseFloat(result.lat), parseFloat(result.lon)]);
|
|
setSearchResults([]);
|
|
setSearchQuery('');
|
|
notify({
|
|
title: 'Tìm thấy địa điểm',
|
|
message: `Đã di chuyển bản đồ tới: ${result.namedetails?.name || result.display_name.split(',')[0]}`,
|
|
type: 'success'
|
|
});
|
|
}}
|
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors"
|
|
>
|
|
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
|
|
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Overlay thông tin lộ trình đề xuất (Lộ trình thông dụng nhất) */}
|
|
{selectedRouteInfo && drivingRoute.length > 0 && (
|
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl shadow-xl border border-white flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
|
|
<div className="flex items-center gap-1.5">
|
|
<Navigation className="w-3.5 h-3.5 text-blue-600 rotate-45" />
|
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">{selectedRouteInfo.label}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs font-bold text-blue-700 whitespace-nowrap">
|
|
<span>{selectedRouteInfo.distance} km</span>
|
|
<span className="text-gray-300">•</span>
|
|
<span>{selectedRouteInfo.duration}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<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 && !isPublicView && (
|
|
<>
|
|
<MapHoverTip canEdit={canEdit} />
|
|
<MapContextMenu onAction={handleMapAction} onOpen={() => setRouteMenu(null)} />
|
|
</>
|
|
)}
|
|
|
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
|
<MapTourBounds locations={allLocations} />
|
|
|
|
{/* Xử lý di chuyển tâm bản đồ về phía người dùng */}
|
|
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
|
|
|
{/* Xử lý xoay bản đồ */}
|
|
<MapRotationHandler rotation={mapRotation} />
|
|
|
|
{/* Hiển thị vị trí hiện tại của người dùng */}
|
|
{userLocation && (
|
|
<Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}>
|
|
<Popup>
|
|
<div className="text-xs font-bold text-blue-600">Bạn đang ở đây</div>
|
|
</Popup>
|
|
</Marker>
|
|
)}
|
|
|
|
{/* Vẽ tất cả lộ trình: Vẽ các đường phụ trước, đường chính sau để hiển thị đè lên trên */}
|
|
{routes.length > 0 ? (
|
|
[...routes]
|
|
.map((r, i) => ({ data: r, index: i }))
|
|
.sort((a, b) => {
|
|
if (a.index === selectedRouteIndex) return 1;
|
|
if (b.index === selectedRouteIndex) return -1;
|
|
return 0;
|
|
})
|
|
.map(({ data, index }) => (
|
|
<Polyline
|
|
key={`polyline-${index}-${index === selectedRouteIndex ? 'active' : 'alt'}-${routeKey}`}
|
|
positions={data.geometry.coordinates.map((c: any) => [c[1], c[0]])}
|
|
color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
|
|
weight={index === selectedRouteIndex ? 6 : 12}
|
|
opacity={index === selectedRouteIndex ? 1 : 0.35}
|
|
dashArray={index === selectedRouteIndex ? undefined : "15, 15"}
|
|
smoothFactor={1}
|
|
eventHandlers={{
|
|
click: (e) => {
|
|
const originalEvent = (e as any).originalEvent;
|
|
if (originalEvent) L.DomEvent.stopPropagation(originalEvent);
|
|
setRouteMenu(null);
|
|
setSelectedRouteIndex(index);
|
|
},
|
|
contextmenu: (e) => {
|
|
const originalEvent = (e as any).originalEvent;
|
|
if (originalEvent) {
|
|
L.DomEvent.stopPropagation(originalEvent);
|
|
L.DomEvent.preventDefault(originalEvent);
|
|
// Đánh dấu để MapContextMenu biết đã có Layer xử lý
|
|
(originalEvent as any)._routeTriggered = true;
|
|
}
|
|
|
|
setRouteMenu({ x: (e as any).containerPoint.x, y: (e as any).containerPoint.y, index });
|
|
},
|
|
mouseover: (e) => {
|
|
if (index !== selectedRouteIndex) {
|
|
(e.target as L.Polyline).setStyle({ opacity: 0.7, weight: 14, color: '#64748b' });
|
|
}
|
|
},
|
|
mouseout: (e) => {
|
|
if (index !== selectedRouteIndex) {
|
|
(e.target as L.Polyline).setStyle({ opacity: 0.35, weight: 12, color: '#94a3b8' });
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
))
|
|
) : allLocations.length > 1 && (
|
|
/* Vẽ đường thẳng nét đứt nếu không lấy được dữ liệu lộ trình thực tế */
|
|
<Polyline
|
|
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
|
|
color="#3b82f6"
|
|
weight={3}
|
|
dashArray="5, 10"
|
|
smoothFactor={1.5}
|
|
/>
|
|
)}
|
|
|
|
<MarkerClusterGroup key={`cluster-group-${allLocations.length}`} chunkedLoading>
|
|
{allLocations.map((loc: any, index: number) => {
|
|
const isStart = startPoint && startPoint.id === loc.id;
|
|
const isEnd = endPoint && endPoint.id === loc.id;
|
|
|
|
// Lấy icon tương ứng từ mapIcons memoized
|
|
const icon = isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit;
|
|
|
|
// Tính quãng đường từ điểm trước đó (A -> B) để hiển thị tại điểm B
|
|
const prevLoc = index > 0 ? allLocations[index - 1] : null;
|
|
const distanceToPrev = prevLoc
|
|
? calculateDistance(prevLoc.latitude, prevLoc.longitude, loc.latitude, loc.longitude).toFixed(1)
|
|
: null;
|
|
const drivingDist = index > 0 ? segmentDistances[index - 1] : null;
|
|
|
|
return (
|
|
<Marker key={`marker-${loc.id}-${isStart ? 'start' : isEnd ? 'end' : 'visit'}`} position={[loc.latitude, loc.longitude]} icon={icon}>
|
|
<Popup>
|
|
<div className="p-1">
|
|
<div className="font-bold text-gray-900 leading-tight mb-0.5">{loc.name}</div>
|
|
<div className="text-[10px] text-gray-400 mb-2 uppercase tracking-widest">{loc.type}</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<button
|
|
onClick={() => handleQuickNote(loc.name)}
|
|
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-black transition-all border border-amber-100"
|
|
>
|
|
<FileText className="w-3 h-3" />
|
|
GHI CHÚ NHANH
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setCommentLocationId(loc.id);
|
|
setCommentLocationName(loc.name);
|
|
setIsCommentModalOpen(true);
|
|
}}
|
|
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100"
|
|
>
|
|
<MessageSquare className="w-3 h-3" />
|
|
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`}
|
|
</button>
|
|
</div>
|
|
|
|
{(drivingDist || (distanceToPrev && distanceToPrev !== "0.0")) && (
|
|
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-2 animate-in fade-in slide-in-from-bottom-1">
|
|
<div className="p-1.5 bg-blue-50 rounded-lg">
|
|
<Navigation className="w-3 h-3 text-blue-600 rotate-45" />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-[9px] font-black text-blue-400 uppercase leading-none tracking-tighter mb-0.5">
|
|
{drivingDist
|
|
? `${travelMode === 'driving' ? 'Đường ô tô' : travelMode === 'bike' ? 'Đường xe máy' : 'Đường đi bộ'} từ điểm trước`
|
|
: 'Khoảng cách chim bay'}
|
|
</span>
|
|
<span className="text-xs font-black text-blue-700">{drivingDist ? drivingDist.toFixed(1) : distanceToPrev} km</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
})}
|
|
</MarkerClusterGroup>
|
|
</MapContainer>
|
|
|
|
{/* Menu ngữ cảnh khi click chuột phải vào con đường */}
|
|
{routeMenu && (
|
|
<div
|
|
ref={routeMenuRef}
|
|
className="absolute z-[2001] bg-white rounded-2xl shadow-2xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
|
|
style={{ top: routeMenu.y, left: routeMenu.x }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
setSelectedRouteIndex(routeMenu.index);
|
|
setRouteMenu(null);
|
|
notify({
|
|
title: 'Đã chọn đường đi',
|
|
message: `Hệ thống đã chuyển sang Lựa chọn ${routeMenu.index + 1}`,
|
|
type: 'success'
|
|
});
|
|
}}
|
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 text-sm font-bold text-blue-600 flex items-center gap-2 transition-colors rounded-xl"
|
|
>
|
|
<Navigation className="w-4 h-4 rotate-45" /> Đi đường này
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Overlay điều khiển trên bản đồ */}
|
|
<div className="absolute top-3 left-3 z-[1001] flex flex-col gap-1.5">
|
|
<button
|
|
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
|
|
className="w-9 h-9 bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-white text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center shrink-0"
|
|
>
|
|
{isMapControlsOpen ? (
|
|
<X className="w-4 h-4 text-gray-400" />
|
|
) : (
|
|
travelMode === 'driving' ? <Car className="w-4 h-4" /> :
|
|
travelMode === 'bike' ? <Bike className="w-4 h-4" /> :
|
|
<Footprints className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
|
|
{isMapControlsOpen && (
|
|
<div className="bg-white/90 backdrop-blur-md p-1 rounded-xl shadow-xl border border-white flex flex-col gap-1 animate-in slide-in-from-top-2 duration-300 items-center">
|
|
<button
|
|
onClick={() => { setTravelMode('driving'); setIsMapControlsOpen(false); }}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${travelMode === 'driving' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
|
title="Ô tô"
|
|
>
|
|
<Car className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setTravelMode('bike'); setIsMapControlsOpen(false); }}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${travelMode === 'bike' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
|
title="Xe máy / Xe đạp"
|
|
>
|
|
<Bike className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${travelMode === 'foot' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
|
title="Đi bộ"
|
|
>
|
|
<Footprints className="w-3.5 h-3.5" />
|
|
</button>
|
|
|
|
{/* Nút La bàn / Xoay bản đồ */}
|
|
<button
|
|
onClick={() => {
|
|
if (mapRotation !== 0) {
|
|
setMapRotation(0);
|
|
setIsHeadingMode(false);
|
|
} else {
|
|
setIsHeadingMode(!isHeadingMode);
|
|
}
|
|
}}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border-t border-gray-100 mt-0.5 ${isHeadingMode ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
|
title={isHeadingMode ? "Dừng xoay (Khóa hướng Bắc)" : "Tự động xoay theo hướng di chuyển"}
|
|
>
|
|
<Compass className="w-3.5 h-3.5 transition-transform duration-500" style={{ transform: `rotate(${mapRotation}deg)` }} />
|
|
</button>
|
|
|
|
{/* Nút Tìm tôi */}
|
|
<button
|
|
onClick={() => { setLocateTrigger(prev => prev + 1); setIsMapControlsOpen(false); }}
|
|
disabled={!userLocation}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border-t border-gray-100 mt-0.5 ${!userLocation ? 'opacity-30 cursor-not-allowed' : 'text-blue-600 hover:bg-blue-50'}`}
|
|
title="Vị trí của tôi"
|
|
>
|
|
<LocateFixed className="w-3.5 h-3.5" />
|
|
</button>
|
|
|
|
{/* Danh sách lộ trình rút gọn */}
|
|
{routes.length > 0 && (
|
|
<div className="pt-1 border-t border-gray-100 flex flex-col gap-1">
|
|
<div className="flex flex-col gap-1 max-h-[160px] overflow-y-auto pr-1 custom-scrollbar">
|
|
{routes.map((route, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => { setSelectedRouteIndex(idx); setIsMapControlsOpen(false); }}
|
|
title={`${(route.distance / 1000).toFixed(1)} km - ${Math.round(route.duration / 60)}p`}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border ${
|
|
selectedRouteIndex === idx
|
|
? 'bg-blue-600 text-white border-blue-600 shadow-sm'
|
|
: 'bg-gray-50 text-gray-600 border-gray-100'
|
|
}`}
|
|
>
|
|
<span className="text-[10px] font-bold">{idx + 1}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Chỉ báo đang tìm đường */}
|
|
{isRoutingLoading && (
|
|
<div className="bg-white/90 backdrop-blur-md px-3 py-2 rounded-xl shadow-lg border border-white flex items-center gap-2 animate-pulse animate-in slide-in-from-left-2">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-600" />
|
|
<span className="text-[10px] font-black text-gray-500 uppercase tracking-tighter">Đang tìm đường tối ưu...</span>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'expense' && (
|
|
<div className="animate-in fade-in slide-in-from-bottom-4">
|
|
<ExpenseManager />
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'photo' && (
|
|
<div className="animate-in fade-in">
|
|
{/* Main container for the new layout */}
|
|
<div className="flex flex-col md:flex-row gap-4">
|
|
{/* Left Column: Leg List */}
|
|
<div className="md:w-1/4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex-shrink-0">
|
|
<h3 className="text-sm font-bold text-gray-800 mb-3">Chặng của Tour</h3>
|
|
<div className="space-y-2">
|
|
<button
|
|
onClick={() => {
|
|
setSelectedLegIdForPhoto('all');
|
|
setSelectedPhotoForDisplay(null); // Reset selected photo when changing leg
|
|
}}
|
|
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
|
|
selectedLegIdForPhoto === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Tất cả ảnh
|
|
</button>
|
|
{legs.map(leg => (
|
|
<button
|
|
key={leg.id}
|
|
onClick={() => {
|
|
setSelectedLegIdForPhoto(leg.id);
|
|
setSelectedPhotoForDisplay(null); // Reset selected photo when changing leg
|
|
}}
|
|
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
|
|
selectedLegIdForPhoto === leg.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Chặng {leg.sequence}: {leg.note || 'Không ghi chú'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column: Large Photo Display */}
|
|
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
|
|
{selectedPhotoForDisplay ? (
|
|
<div className="relative w-full h-full flex items-center justify-center">
|
|
<img
|
|
src={selectedPhotoForDisplay.imageUrl}
|
|
alt="Selected Tour Photo"
|
|
className="max-w-full max-h-[calc(100vh-200px)] object-contain rounded-xl shadow-md"
|
|
/>
|
|
{/* Optional: Add delete button for the large photo */}
|
|
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeletePhoto(selectedPhotoForDisplay.id);
|
|
}}
|
|
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-colors"
|
|
title="Xóa ảnh này"
|
|
>
|
|
<Trash2 className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-gray-400">
|
|
<ImageIcon className="w-16 h-16 mx-auto mb-4" />
|
|
<p className="text-lg font-medium">Chọn một ảnh để xem chi tiết</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Row: Thumbnails */}
|
|
<div className="mt-4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4">
|
|
<h3 className="text-sm font-bold text-gray-800 mb-3">
|
|
{selectedLegIdForPhoto === 'all' ? 'Tất cả ảnh' : `Ảnh của Chặng ${legs.find(l => l.id === selectedLegIdForPhoto)?.sequence || ''}`}
|
|
</h3>
|
|
{filteredPhotos.length > 0 ? (
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 overflow-x-auto pb-2">
|
|
{filteredPhotos.map((photo: any) => (
|
|
<div
|
|
key={photo.id}
|
|
onClick={() => setSelectedPhotoForDisplay(photo)}
|
|
className={`aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border-2 ${
|
|
selectedPhotoForDisplay?.id === photo.id ? 'border-blue-500' : 'border-transparent'
|
|
} hover:border-blue-300 transition-all cursor-pointer`}
|
|
>
|
|
<img
|
|
src={photo.imageUrl}
|
|
alt="Thumbnail"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="py-10 text-center text-gray-400">
|
|
<ImageIcon className="w-12 h-12 mx-auto mb-3" />
|
|
<p className="text-md font-medium">Chưa có ảnh nào cho chặng này.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'settings' && !isPublicView && ( // Hide settings tab in public view
|
|
<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>
|
|
{isOwner && <div className="flex gap-2">
|
|
<button
|
|
disabled={joinRequestActionId === req.id}
|
|
onClick={async () => {
|
|
if (!currentTour) return;
|
|
const isConfirmed = await confirm({
|
|
title: 'Chấp nhận yêu cầu',
|
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
|
|
});
|
|
if (isConfirmed) {
|
|
setJoinRequestActionId(req.id);
|
|
try {
|
|
await acceptJoinRequest(currentTour.id, req.id);
|
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
} catch (e: any) {
|
|
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
|
|
} finally {
|
|
setJoinRequestActionId(null);
|
|
}
|
|
}
|
|
}}
|
|
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;
|
|
const isConfirmed = await confirm({
|
|
title: 'Từ chối yêu cầu',
|
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
|
|
});
|
|
if (isConfirmed) {
|
|
setJoinRequestActionId(req.id);
|
|
try {
|
|
await rejectJoinRequest(currentTour.id, req.id);
|
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
} catch (e: any) {
|
|
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
|
|
} finally {
|
|
setJoinRequestActionId(null);
|
|
}
|
|
}
|
|
}}
|
|
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>
|
|
|
|
{canEdit && (
|
|
<div className="p-6 bg-white rounded-3xl border border-gray-100 shadow-sm animate-in zoom-in-95">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Settings className="w-6 h-6 text-blue-500" />
|
|
<h3 className="text-lg font-bold text-gray-900">Thông tin cơ bản</h3>
|
|
</div>
|
|
|
|
<div className="space-y-4 mb-8">
|
|
{isOwner && (
|
|
<>
|
|
<div>
|
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Tiêu đề Tour</label>
|
|
<input
|
|
type="text"
|
|
value={titleInput}
|
|
onChange={(e) => setTitleInput(e.target.value)}
|
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold"
|
|
placeholder="Nhập tên chuyến đi..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Mô tả chuyến đi</label>
|
|
<textarea
|
|
value={descriptionInput}
|
|
onChange={(e) => setDescriptionInput(e.target.value)}
|
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[100px] resize-none"
|
|
placeholder="Viết vài dòng giới thiệu về hành trình này..."
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Users className="w-6 h-6 text-purple-500" />
|
|
<h3 className="text-md font-bold text-gray-800">Số lượng người tham gia</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div>
|
|
<label htmlFor="adultCount" className="block text-sm font-medium text-gray-700 mb-1">Số lượng người lớn</label>
|
|
<input
|
|
type="number"
|
|
id="adultCount"
|
|
value={adultCountInput}
|
|
onChange={(e) => setAdultCountInput(Number(e.target.value))}
|
|
min="0"
|
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="childCount" className="block text-sm font-medium text-gray-700 mb-1">Số lượng trẻ em</label>
|
|
<input
|
|
type="number"
|
|
id="childCount"
|
|
value={childCountInput}
|
|
onChange={(e) => setChildCountInput(Number(e.target.value))}
|
|
min="0"
|
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="childDiscount" className="block text-sm font-medium text-gray-700 mb-1">Giảm giá trẻ em (%)</label>
|
|
<input
|
|
type="number"
|
|
id="childDiscount"
|
|
value={childDiscountInput}
|
|
onChange={(e) => setChildDiscountInput(Number(e.target.value))}
|
|
min="0"
|
|
max="100"
|
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1 flex items-center gap-2">
|
|
<TagIcon className="w-3 h-3" /> Phân loại Tour
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{availableTags.map(tag => (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
onClick={() => setTagsInput(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])}
|
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all border ${
|
|
tagsInput.includes(tag)
|
|
? 'bg-blue-600 text-white border-blue-600'
|
|
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
|
|
}`}
|
|
>
|
|
{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2 mt-3">
|
|
<input
|
|
type="text"
|
|
value={customTag}
|
|
onChange={(e) => setCustomTag(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), setTagsInput(prev => customTag.trim() && !prev.includes(customTag.trim()) ? [...prev, customTag.trim()] : prev), setCustomTag(''))}
|
|
placeholder="Thêm nhãn tùy chỉnh..."
|
|
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => { if (customTag.trim() && !tagsInput.includes(customTag.trim())) { setTagsInput([...tagsInput, customTag.trim()]); setCustomTag(''); } }}
|
|
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-100"
|
|
>
|
|
Thêm
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleUpdateTourInfo}
|
|
className="w-full mt-6 px-4 py-4 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-100 active:scale-95"
|
|
>
|
|
Lưu thay đổi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Danger Zone - Khu vực dành cho các hành động quan trọng */}
|
|
<div className="p-6 bg-red-50 rounded-3xl border border-red-100 animate-in zoom-in-95">
|
|
<div className="flex items-center gap-3 mb-4 text-red-600">
|
|
<Trash2 className="w-6 h-6" />
|
|
<h3 className="text-lg font-bold">Vùng nguy hiểm</h3>
|
|
</div>
|
|
<p className="text-sm text-red-500 mb-6 font-medium">Một khi đã xóa, bạn sẽ không thể khôi phục lại dữ liệu của hành trình này.</p>
|
|
<button
|
|
onClick={handleDeleteTour}
|
|
className="w-full py-4 bg-red-600 hover:bg-red-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-red-100 active:scale-95"
|
|
>
|
|
Xóa Tour vĩnh viễn
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Floating Action Button (Mobile) */}
|
|
{((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
|
|
<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);
|
|
setIsStartPointAction(false);
|
|
setIsEndPointAction(false);
|
|
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
|
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
|
|
}}
|
|
className="bg-blue-600 text-white px-4 py-2.5 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold text-sm">
|
|
{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}
|
|
isPublicView={isPublicView} // Pass isPublicView
|
|
/>
|
|
)}
|
|
|
|
{/* Add Location Modal */}
|
|
{currentTour && (
|
|
<AddLocationModal
|
|
isOpen={isAddLocationOpen}
|
|
onClose={() => setIsAddLocationOpen(false)}
|
|
initialLegId={targetLegId || undefined}
|
|
isStartPoint={isStartPointAction}
|
|
isEndPoint={isEndPointAction}
|
|
editingLocation={editingLocation}
|
|
tourId={currentTour.id}
|
|
isPublicView={isPublicView} // Pass isPublicView
|
|
onSuccess={() => {
|
|
console.log("TourDetailPage: AddLocationModal onSuccess -> Re-fetching tour data.");
|
|
fetchTour(tourId); // Re-fetch tour data after adding/editing location
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Add Photo Modal */}
|
|
{currentTour && (
|
|
<AddPhotoModal
|
|
isOpen={isAddPhotoOpen}
|
|
onClose={() => setIsAddPhotoOpen(false)}
|
|
tourId={currentTour.id}
|
|
onSuccess={() => fetchTour(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) {
|
|
notify({ title: 'Thông báo', message: 'Không thể xóa thành viên', type: '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>
|
|
)}
|
|
<CommentModal
|
|
isOpen={isCommentModalOpen}
|
|
onClose={() => setIsCommentModalOpen(false)}
|
|
locationId={commentLocationId}
|
|
locationName={commentLocationName}
|
|
isPublicView={isPublicView} // Pass isPublicView
|
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
|
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
|
/>
|
|
</div>
|
|
);
|
|
}; |