Cho phép xóa tour và xóa chặng trong Tour Dashboard
This commit is contained in:
+38
-2
@@ -3,7 +3,7 @@ import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from './useTourStore.js';
|
||||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings } from 'lucide-react';
|
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react';
|
||||||
import { UserManagementModal } from './UserManagementModal.js';
|
import { UserManagementModal } from './UserManagementModal.js';
|
||||||
import { CreateTourModal } from './CreateTourModal.js';
|
import { CreateTourModal } from './CreateTourModal.js';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ function RecenterMap({ position }: { position: [number, number] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
|
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
|
||||||
const { publicTours, fetchPublicTours, fetchTour } = useTourStore();
|
const { publicTours, fetchPublicTours, fetchTour, updateTour, deleteTour } = useTourStore();
|
||||||
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
|
const [userPos, setUserPos] = useState<[number, number]>([10.7769, 106.7009]); // Mặc định TP.HCM
|
||||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
@@ -39,6 +39,27 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleEditTour = async (tour: any) => {
|
||||||
|
const newTitle = window.prompt("Nhập tên mới cho Tour:", tour.title);
|
||||||
|
if (newTitle && newTitle !== tour.title) {
|
||||||
|
try {
|
||||||
|
await updateTour(tour.id, { title: newTitle });
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTour = async (id: string) => {
|
||||||
|
if (window.confirm("Bạn có chắc chắn muốn xóa Tour này và toàn bộ dữ liệu liên quan?")) {
|
||||||
|
try {
|
||||||
|
await deleteTour(id);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full relative">
|
<div className="h-screen w-full relative">
|
||||||
{/* Nút quay lại */}
|
{/* Nút quay lại */}
|
||||||
@@ -133,6 +154,21 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
className="text-xs font-bold text-blue-600 flex items-center gap-1">
|
className="text-xs font-bold text-blue-600 flex items-center gap-1">
|
||||||
Xem chi tiết hình ảnh <ImageIcon className="w-3 h-3" />
|
Xem chi tiết hình ảnh <ImageIcon className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="flex gap-2 mt-3 pt-3 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditTour(tour)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-bold hover:bg-blue-100 transition-colors">
|
||||||
|
<Edit2 className="w-3 h-3" /> Sửa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteTour(tour.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 py-1.5 bg-red-50 text-red-600 rounded-lg text-[10px] font-bold hover:bg-red-100 transition-colors">
|
||||||
|
<Trash2 className="w-3 h-3" /> Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
|
|||||||
+72
-12
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation } from 'lucide-react';
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus } from 'lucide-react';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from './useTourStore.js';
|
||||||
|
|
||||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||||
@@ -36,12 +36,37 @@ const formatTravelTime = (minutes: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ItineraryTimeline = () => {
|
export const ItineraryTimeline = () => {
|
||||||
const { legs, optimizeRouting, userRole, activeLegId, setActiveLegId } = useTourStore();
|
const { currentTour, legs, optimizeRouting, userRole, activeLegId, setActiveLegId, addLeg, updateLeg, deleteLeg } = useTourStore();
|
||||||
|
|
||||||
const toggleComplete = async (locationId: string) => {
|
const toggleComplete = async (locationId: string) => {
|
||||||
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
||||||
console.log("Toggle status for location:", locationId);
|
console.log("Toggle status for location:", locationId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddLeg = async () => {
|
||||||
|
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
|
||||||
|
if (note && currentTour) {
|
||||||
|
await addLeg(currentTour.id, { note });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditLeg = async (leg: any) => {
|
||||||
|
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
|
||||||
|
if (note !== null) {
|
||||||
|
await updateLeg(leg.id, { note });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLeg = async (legId: string) => {
|
||||||
|
if (window.confirm("Bạn có chắc chắn muốn xóa chặng này?")) {
|
||||||
|
try {
|
||||||
|
await deleteLeg(legId);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const activeLeg = legs.find(l => l.id === activeLegId) || legs[0];
|
const activeLeg = legs.find(l => l.id === activeLegId) || legs[0];
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||||
@@ -61,6 +86,14 @@ export const ItineraryTimeline = () => {
|
|||||||
Chặng {leg.sequence}
|
Chặng {leg.sequence}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||||
|
<button
|
||||||
|
onClick={handleAddLeg}
|
||||||
|
className="flex-shrink-0 p-2.5 rounded-2xl bg-white text-blue-600 border border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center w-12"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,11 +114,28 @@ export const ItineraryTimeline = () => {
|
|||||||
<>
|
<>
|
||||||
{/* Leg Header */}
|
{/* Leg Header */}
|
||||||
<div className="flex items-center mb-4 px-2">
|
<div className="flex items-center mb-4 px-2">
|
||||||
<div className="font-black text-gray-900 text-lg">
|
<div className="font-black text-gray-900 text-lg truncate flex-1">
|
||||||
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
|
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
|
||||||
</div>
|
</div>
|
||||||
{leg.totalDistance !== undefined && (
|
<div className="flex items-center gap-2 ml-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 ml-4">
|
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditLeg(leg)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLeg(leg.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{leg.totalDistance !== undefined && (
|
||||||
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
<div className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100">
|
<div className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100">
|
||||||
{leg.totalDistance} km
|
{leg.totalDistance} km
|
||||||
</div>
|
</div>
|
||||||
@@ -93,15 +143,16 @@ export const ItineraryTimeline = () => {
|
|||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
~ {formatTravelTime(Math.round((leg.totalDistance / 35) * 60))}
|
~ {formatTravelTime(Math.round((leg.totalDistance / 35) * 60))}
|
||||||
</div>
|
</div>
|
||||||
{totalDwellMinutes > 0 && (
|
|
||||||
<div className="text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
Dừng: {formatTravelTime(totalDwellMinutes)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
{totalDwellMinutes > 0 && (
|
||||||
|
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Dừng: {formatTravelTime(totalDwellMinutes)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => optimizeRouting(leg.id)}
|
onClick={() => optimizeRouting(leg.id)}
|
||||||
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
|
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
|
||||||
@@ -128,6 +179,9 @@ export const ItineraryTimeline = () => {
|
|||||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||||
|
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={location.id}>
|
<div key={location.id}>
|
||||||
@@ -152,6 +206,12 @@ export const ItineraryTimeline = () => {
|
|||||||
}`}>
|
}`}>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
|
{isStartPoint && (
|
||||||
|
<span className="inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm bắt đầu</span>
|
||||||
|
)}
|
||||||
|
{isEndPoint && (
|
||||||
|
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||||
|
)}
|
||||||
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
||||||
{location.name}
|
{location.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
+29
-2
@@ -63,7 +63,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
||||||
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||||
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
const { currentTour, fetchTour, fetchPublicTours, publicTours, userRole, legs } = useTourStore();
|
const { currentTour, fetchTour, fetchPublicTours, publicTours, userRole, legs, addLocation } = useTourStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -82,6 +82,33 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
}
|
}
|
||||||
}, [publicTours, currentTour, fetchTour]);
|
}, [publicTours, currentTour, fetchTour]);
|
||||||
|
|
||||||
|
const handleMapAction = async (action: string, latlng: L.LatLng) => {
|
||||||
|
if (!currentTour || legs.length === 0) return;
|
||||||
|
|
||||||
|
let targetLegId = legs[0].id; // Mặc định là chặng đầu
|
||||||
|
let defaultName = "Địa điểm mới";
|
||||||
|
|
||||||
|
if (action === 'START') defaultName = "Điểm bắt đầu";
|
||||||
|
if (action === 'END') {
|
||||||
|
targetLegId = legs[legs.length - 1].id;
|
||||||
|
defaultName = "Điểm kết thúc";
|
||||||
|
}
|
||||||
|
if (action.startsWith('ADD_TO_LEG_')) {
|
||||||
|
targetLegId = action.replace('ADD_TO_LEG_', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = window.prompt("Nhập tên địa điểm:", defaultName);
|
||||||
|
if (name) {
|
||||||
|
await addLocation(currentTour.id, {
|
||||||
|
name,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
legId: targetLegId,
|
||||||
|
type: 'VISIT'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
|
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
||||||
@@ -237,7 +264,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
||||||
<MapContainer center={[10.7769, 106.7009]} zoom={13} className="h-full w-full">
|
<MapContainer center={[10.7769, 106.7009]} zoom={13} className="h-full w-full">
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
<MapContextMenu onAction={(action, latlng) => console.log(action, latlng)} />
|
<MapContextMenu onAction={handleMapAction} />
|
||||||
|
|
||||||
{/* Vẽ đường Polyline nối các điểm */}
|
{/* Vẽ đường Polyline nối các điểm */}
|
||||||
{legs.map(leg => {
|
{legs.map(leg => {
|
||||||
|
|||||||
Vendored
+24
-3
@@ -4,7 +4,7 @@ import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from './useTourStore.js';
|
||||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings } from 'lucide-react';
|
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react';
|
||||||
import { UserManagementModal } from './UserManagementModal.js';
|
import { UserManagementModal } from './UserManagementModal.js';
|
||||||
import { CreateTourModal } from './CreateTourModal.js';
|
import { CreateTourModal } from './CreateTourModal.js';
|
||||||
const DefaultIcon = L.icon({
|
const DefaultIcon = L.icon({
|
||||||
@@ -22,7 +22,7 @@ function RecenterMap({ position }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }) => {
|
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }) => {
|
||||||
const { publicTours, fetchPublicTours, fetchTour } = useTourStore();
|
const { publicTours, fetchPublicTours, fetchTour, updateTour, deleteTour } = useTourStore();
|
||||||
const [userPos, setUserPos] = useState([10.7769, 106.7009]);
|
const [userPos, setUserPos] = useState([10.7769, 106.7009]);
|
||||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
@@ -30,6 +30,27 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }) => {
|
|||||||
fetchPublicTours();
|
fetchPublicTours();
|
||||||
navigator.geolocation.getCurrentPosition((pos) => setUserPos([pos.coords.latitude, pos.coords.longitude]), () => console.log("Không thể lấy vị trí người dùng"));
|
navigator.geolocation.getCurrentPosition((pos) => setUserPos([pos.coords.latitude, pos.coords.longitude]), () => console.log("Không thể lấy vị trí người dùng"));
|
||||||
}, []);
|
}, []);
|
||||||
|
const handleEditTour = async (tour) => {
|
||||||
|
const newTitle = window.prompt("Nhập tên mới cho Tour:", tour.title);
|
||||||
|
if (newTitle && newTitle !== tour.title) {
|
||||||
|
try {
|
||||||
|
await updateTour(tour.id, { title: newTitle });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeleteTour = async (id) => {
|
||||||
|
if (window.confirm("Bạn có chắc chắn muốn xóa Tour này và toàn bộ dữ liệu liên quan?")) {
|
||||||
|
try {
|
||||||
|
await deleteTour(id);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
return (_jsxs("div", { className: "h-screen w-full relative", children: [_jsx("button", { onClick: onBack, className: "absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all", children: _jsx(X, { className: "w-6 h-6 text-gray-800" }) }), onLogout && (_jsxs("button", { onClick: onLogout, className: "absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700", children: [_jsx(LogOut, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "\u0110\u0103ng xu\u1EA5t" })] })), user?.isAdmin && (_jsxs("button", { onClick: () => setIsAdminModalOpen(true), className: "absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Settings, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "Qu\u1EA3n l\u00FD h\u1EC7 th\u1ED1ng" })] })), user && (_jsxs("button", { onClick: () => setIsCreateModalOpen(true), className: "absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Navigation, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "T\u1EA1o Tour m\u1EDBi" })] })), _jsx("div", { className: "absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Navigation, { className: "w-4 h-4 text-blue-600" }), _jsx("span", { className: "font-bold text-gray-800", children: "\u0110ang kh\u00E1m ph\u00E1 khu v\u1EF1c c\u1EE7a b\u1EA1n" })] }) }), _jsxs(MapContainer, { center: userPos, zoom: 13, className: "h-full w-full", children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: '\u00A9 OpenStreetMap contributors' }), _jsx(RecenterMap, { position: userPos }), publicTours.map((tour) => {
|
return (_jsxs("div", { className: "h-screen w-full relative", children: [_jsx("button", { onClick: onBack, className: "absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all", children: _jsx(X, { className: "w-6 h-6 text-gray-800" }) }), onLogout && (_jsxs("button", { onClick: onLogout, className: "absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700", children: [_jsx(LogOut, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "\u0110\u0103ng xu\u1EA5t" })] })), user?.isAdmin && (_jsxs("button", { onClick: () => setIsAdminModalOpen(true), className: "absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Settings, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "Qu\u1EA3n l\u00FD h\u1EC7 th\u1ED1ng" })] })), user && (_jsxs("button", { onClick: () => setIsCreateModalOpen(true), className: "absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Navigation, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "T\u1EA1o Tour m\u1EDBi" })] })), _jsx("div", { className: "absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Navigation, { className: "w-4 h-4 text-blue-600" }), _jsx("span", { className: "font-bold text-gray-800", children: "\u0110ang kh\u00E1m ph\u00E1 khu v\u1EF1c c\u1EE7a b\u1EA1n" })] }) }), _jsxs(MapContainer, { center: userPos, zoom: 13, className: "h-full w-full", children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: '\u00A9 OpenStreetMap contributors' }), _jsx(RecenterMap, { position: userPos }), publicTours.map((tour) => {
|
||||||
const location = tour.legs?.[0]?.locations?.[0];
|
const location = tour.legs?.[0]?.locations?.[0];
|
||||||
if (!location)
|
if (!location)
|
||||||
@@ -48,7 +69,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }) => {
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
iconSize: [48, 48],
|
iconSize: [48, 48],
|
||||||
}), children: _jsx(Popup, { className: "custom-popup", children: _jsxs("div", { className: "p-1 max-w-[200px]", children: [_jsx("img", { src: tourImage, className: "w-full h-32 object-cover rounded-lg mb-2" }), _jsx("h4", { className: "font-bold text-gray-900 leading-tight mb-1", children: tour.title }), _jsxs("button", { onClick: () => onViewTour(tour.id), className: "text-xs font-bold text-blue-600 flex items-center gap-1", children: ["Xem chi ti\u1EBFt h\u00ECnh \u1EA3nh ", _jsx(ImageIcon, { className: "w-3 h-3" })] })] }) }) }, tour.id));
|
}), children: _jsx(Popup, { className: "custom-popup", children: _jsxs("div", { className: "p-1 max-w-[200px]", children: [_jsx("img", { src: tourImage, className: "w-full h-32 object-cover rounded-lg mb-2" }), _jsx("h4", { className: "font-bold text-gray-900 leading-tight mb-1", children: tour.title }), _jsxs("button", { onClick: () => onViewTour(tour.id), className: "text-xs font-bold text-blue-600 flex items-center gap-1", children: ["Xem chi ti\u1EBFt h\u00ECnh \u1EA3nh ", _jsx(ImageIcon, { className: "w-3 h-3" })] }), user && (_jsxs("div", { className: "flex gap-2 mt-3 pt-3 border-t border-gray-100", children: [_jsxs("button", { onClick: () => handleEditTour(tour), className: "flex-1 flex items-center justify-center gap-1 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-bold hover:bg-blue-100 transition-colors", children: [_jsx(Edit2, { className: "w-3 h-3" }), " S\u1EEDa"] }), _jsxs("button", { onClick: () => handleDeleteTour(tour.id), className: "flex-1 flex items-center justify-center gap-1 py-1.5 bg-red-50 text-red-600 rounded-lg text-[10px] font-bold hover:bg-red-100 transition-colors", children: [_jsx(Trash2, { className: "w-3 h-3" }), " X\u00F3a"] })] }))] }) }) }, tour.id));
|
||||||
})] }), _jsx(UserManagementModal, { isOpen: isAdminModalOpen, onClose: () => setIsAdminModalOpen(false) }), _jsx(CreateTourModal, { isOpen: isCreateModalOpen, onClose: () => setIsCreateModalOpen(false), onSuccess: (tour) => {
|
})] }), _jsx(UserManagementModal, { isOpen: isAdminModalOpen, onClose: () => setIsAdminModalOpen(false) }), _jsx(CreateTourModal, { isOpen: isCreateModalOpen, onClose: () => setIsCreateModalOpen(false), onSuccess: (tour) => {
|
||||||
fetchTour(tour.id);
|
fetchTour(tour.id);
|
||||||
onViewTour(tour.id);
|
onViewTour(tour.id);
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"ExploreMap.js","sourceRoot":"","sources":["../ExploreMap.tsx"],"names":[],"mappings":"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"}
|
{"version":3,"file":"ExploreMap.js","sourceRoot":"","sources":["../ExploreMap.tsx"],"names":[],"mappings":"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"}
|
||||||
Vendored
+31
-7
@@ -1,6 +1,6 @@
|
|||||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation } from 'lucide-react';
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus } from 'lucide-react';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from './useTourStore.js';
|
||||||
const TimeVariance = ({ planned, actual }) => {
|
const TimeVariance = ({ planned, actual }) => {
|
||||||
if (!actual)
|
if (!actual)
|
||||||
@@ -25,14 +25,36 @@ const formatTravelTime = (minutes) => {
|
|||||||
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||||
};
|
};
|
||||||
export const ItineraryTimeline = () => {
|
export const ItineraryTimeline = () => {
|
||||||
const { legs, optimizeRouting, userRole, activeLegId, setActiveLegId } = useTourStore();
|
const { currentTour, legs, optimizeRouting, userRole, activeLegId, setActiveLegId, addLeg, updateLeg, deleteLeg } = useTourStore();
|
||||||
const toggleComplete = async (locationId) => {
|
const toggleComplete = async (locationId) => {
|
||||||
console.log("Toggle status for location:", locationId);
|
console.log("Toggle status for location:", locationId);
|
||||||
};
|
};
|
||||||
|
const handleAddLeg = async () => {
|
||||||
|
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
|
||||||
|
if (note && currentTour) {
|
||||||
|
await addLeg(currentTour.id, { note });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleEditLeg = async (leg) => {
|
||||||
|
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
|
||||||
|
if (note !== null) {
|
||||||
|
await updateLeg(leg.id, { note });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeleteLeg = async (legId) => {
|
||||||
|
if (window.confirm("Bạn có chắc chắn muốn xóa chặng này?")) {
|
||||||
|
try {
|
||||||
|
await deleteLeg(legId);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
const activeLeg = legs.find(l => l.id === activeLegId) || legs[0];
|
const activeLeg = legs.find(l => l.id === activeLegId) || legs[0];
|
||||||
return (_jsxs("div", { className: "max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen", children: [_jsx("div", { className: "sticky top-[136px] z-20 bg-gray-50/80 backdrop-blur-sm pb-4 mb-4", children: _jsx("div", { className: "flex overflow-x-auto gap-2 px-2 no-scrollbar py-2", children: legs.map((leg) => (_jsxs("button", { onClick: () => setActiveLegId(leg.id), className: `flex-shrink-0 px-6 py-2.5 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border ${activeLegId === leg.id
|
return (_jsxs("div", { className: "max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen", children: [_jsx("div", { className: "sticky top-[136px] z-20 bg-gray-50/80 backdrop-blur-sm pb-4 mb-4", children: _jsxs("div", { className: "flex overflow-x-auto gap-2 px-2 no-scrollbar py-2", children: [legs.map((leg) => (_jsxs("button", { onClick: () => setActiveLegId(leg.id), className: `flex-shrink-0 px-6 py-2.5 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border ${activeLegId === leg.id
|
||||||
? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-100 scale-105'
|
? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-100 scale-105'
|
||||||
: 'bg-white text-gray-400 border-gray-100 hover:border-gray-200'}`, children: ["Ch\u1EB7ng ", leg.sequence] }, leg.id))) }) }), _jsx("div", { className: "px-2", children: activeLeg && (_jsx("div", { className: "relative animate-in fade-in slide-in-from-right-4 duration-300", children: (() => {
|
: 'bg-white text-gray-400 border-gray-100 hover:border-gray-200'}`, children: ["Ch\u1EB7ng ", leg.sequence] }, leg.id))), ['OWNER', 'MANAGER'].includes(userRole || '') && (_jsx("button", { onClick: handleAddLeg, className: "flex-shrink-0 p-2.5 rounded-2xl bg-white text-blue-600 border border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center w-12", children: _jsx(Plus, { className: "w-5 h-5" }) }))] }) }), _jsx("div", { className: "px-2", children: activeLeg && (_jsx("div", { className: "relative animate-in fade-in slide-in-from-right-4 duration-300", children: (() => {
|
||||||
const leg = activeLeg;
|
const leg = activeLeg;
|
||||||
const totalDwellMinutes = leg.locations.reduce((acc, loc) => {
|
const totalDwellMinutes = leg.locations.reduce((acc, loc) => {
|
||||||
if (loc.plannedStart && loc.plannedEnd) {
|
if (loc.plannedStart && loc.plannedEnd) {
|
||||||
@@ -40,7 +62,7 @@ export const ItineraryTimeline = () => {
|
|||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, 0);
|
}, 0);
|
||||||
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center mb-4 px-2", children: [_jsx("div", { className: "font-black text-gray-900 text-lg", children: leg.note || `Chi tiết Chặng ${leg.sequence}` }), leg.totalDistance !== undefined && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 ml-4", children: [_jsxs("div", { className: "text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100", children: [leg.totalDistance, " km"] }), _jsxs("div", { className: "text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "~ ", formatTravelTime(Math.round((leg.totalDistance / 35) * 60))] }), totalDwellMinutes > 0 && (_jsxs("div", { className: "text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "D\u1EEBng: ", formatTravelTime(totalDwellMinutes)] }))] })), ['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs("button", { onClick: () => optimizeRouting(leg.id), className: "ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all", children: [_jsx(Zap, { className: "w-3 h-3" }), "T\u1ED1i \u01B0u"] }))] }), _jsx("div", { className: "absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" }), _jsx("div", { className: "ml-2", children: leg.locations.map((location, idx) => {
|
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center mb-4 px-2", children: [_jsx("div", { className: "font-black text-gray-900 text-lg truncate flex-1", children: leg.note || `Chi tiết Chặng ${leg.sequence}` }), _jsxs("div", { className: "flex items-center gap-2 ml-4", children: [['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => handleEditLeg(leg), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", children: _jsx(Edit2, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleDeleteLeg(leg.id), className: "p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })), leg.totalDistance !== undefined && (_jsxs("div", { className: "hidden sm:flex items-center gap-2", children: [_jsxs("div", { className: "text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100", children: [leg.totalDistance, " km"] }), _jsxs("div", { className: "text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "~ ", formatTravelTime(Math.round((leg.totalDistance / 35) * 60))] })] })), totalDwellMinutes > 0 && (_jsxs("div", { className: "hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "D\u1EEBng: ", formatTravelTime(totalDwellMinutes)] }))] }), ['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (_jsxs("button", { onClick: () => optimizeRouting(leg.id), className: "ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all", children: [_jsx(Zap, { className: "w-3 h-3" }), "T\u1ED1i \u01B0u"] }))] }), _jsx("div", { className: "absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" }), _jsx("div", { className: "ml-2", children: leg.locations.map((location, idx) => {
|
||||||
const nextLocation = leg.locations[idx + 1];
|
const nextLocation = leg.locations[idx + 1];
|
||||||
const distanceToNext = nextLocation
|
const distanceToNext = nextLocation
|
||||||
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||||
@@ -50,7 +72,9 @@ export const ItineraryTimeline = () => {
|
|||||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
: null;
|
: null;
|
||||||
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [_jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("span", { className: "text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||||
|
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||||
|
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("span", { className: "text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||||
}) }), leg.note && (_jsxs("p", { className: "ml-14 mt-4 text-sm text-gray-400 italic", children: ["* ", leg.note] }))] }));
|
}) }), leg.note && (_jsxs("p", { className: "ml-14 mt-4 text-sm text-gray-400 italic", children: ["* ", leg.note] }))] }));
|
||||||
})() })) })] }));
|
})() })) })] }));
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+27
-2
@@ -24,7 +24,7 @@ export const TourDetailPage = ({ onBack }) => {
|
|||||||
const [activeTab, setActiveTab] = useState('plan');
|
const [activeTab, setActiveTab] = useState('plan');
|
||||||
const [viewMode, setViewMode] = useState('timeline');
|
const [viewMode, setViewMode] = useState('timeline');
|
||||||
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
const { currentTour, fetchTour, fetchPublicTours, publicTours, userRole, legs } = useTourStore();
|
const { currentTour, fetchTour, fetchPublicTours, publicTours, userRole, legs, addLocation } = useTourStore();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (publicTours.length === 0) {
|
if (publicTours.length === 0) {
|
||||||
@@ -38,6 +38,31 @@ export const TourDetailPage = ({ onBack }) => {
|
|||||||
fetchTour(publicTours[0].id);
|
fetchTour(publicTours[0].id);
|
||||||
}
|
}
|
||||||
}, [publicTours, currentTour, fetchTour]);
|
}, [publicTours, currentTour, fetchTour]);
|
||||||
|
const handleMapAction = async (action, latlng) => {
|
||||||
|
if (!currentTour || legs.length === 0)
|
||||||
|
return;
|
||||||
|
let targetLegId = legs[0].id;
|
||||||
|
let defaultName = "Địa điểm mới";
|
||||||
|
if (action === 'START')
|
||||||
|
defaultName = "Điểm bắt đầu";
|
||||||
|
if (action === 'END') {
|
||||||
|
targetLegId = legs[legs.length - 1].id;
|
||||||
|
defaultName = "Điểm kết thúc";
|
||||||
|
}
|
||||||
|
if (action.startsWith('ADD_TO_LEG_')) {
|
||||||
|
targetLegId = action.replace('ADD_TO_LEG_', '');
|
||||||
|
}
|
||||||
|
const name = window.prompt("Nhập tên địa điểm:", defaultName);
|
||||||
|
if (name) {
|
||||||
|
await addLocation(currentTour.id, {
|
||||||
|
name,
|
||||||
|
latitude: latlng.lat,
|
||||||
|
longitude: latlng.lng,
|
||||||
|
legId: targetLegId,
|
||||||
|
type: 'VISIT'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
||||||
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
||||||
@@ -61,7 +86,7 @@ export const TourDetailPage = ({ onBack }) => {
|
|||||||
};
|
};
|
||||||
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 h-64 w-full bg-blue-900", children: [_jsx("img", { src: tourInfo.coverImage, className: "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: "absolute bottom-16 left-0 right-0 p-6 text-white", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-2", children: [_jsx("h2", { className: "text-3xl font-black tracking-tight drop-shadow-md", children: tourInfo.title }), _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 -space-x-3", children: [currentTour?.participants?.slice(0, 5).map((p, i) => (_jsx("div", { 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", children: _jsx("img", { src: `https://i.pravatar.cc/100?u=${p.userId}`, alt: "Avatar" }) }, i))), 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", { className: "p-2 bg-white/10 hover:bg-white/20 rounded-full border border-white/20 backdrop-blur-sm transition-all ml-2", 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: `${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
|
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 h-64 w-full bg-blue-900", children: [_jsx("img", { src: tourInfo.coverImage, className: "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: "absolute bottom-16 left-0 right-0 p-6 text-white", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-2", children: [_jsx("h2", { className: "text-3xl font-black tracking-tight drop-shadow-md", children: tourInfo.title }), _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 -space-x-3", children: [currentTour?.participants?.slice(0, 5).map((p, i) => (_jsx("div", { 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", children: _jsx("img", { src: `https://i.pravatar.cc/100?u=${p.userId}`, alt: "Avatar" }) }, i))), 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", { className: "p-2 bg-white/10 hover:bg-white/20 rounded-full border border-white/20 backdrop-blur-sm transition-all ml-2", 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: `${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'
|
? '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, {})) : (_jsxs("div", { className: "h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative", children: [_jsxs(MapContainer, { center: [10.7769, 106.7009], zoom: 13, className: "h-full w-full", children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" }), _jsx(MapContextMenu, { onAction: (action, latlng) => console.log(action, latlng) }), legs.map(leg => {
|
: '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, {})) : (_jsxs("div", { className: "h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative", children: [_jsxs(MapContainer, { center: [10.7769, 106.7009], zoom: 13, className: "h-full w-full", children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" }), _jsx(MapContextMenu, { onAction: handleMapAction }), legs.map(leg => {
|
||||||
const positions = leg.locations.map((l) => [l.latitude, l.longitude]);
|
const positions = leg.locations.map((l) => [l.latitude, l.longitude]);
|
||||||
return _jsx(Polyline, { positions: positions, color: "#3b82f6", weight: 3, dashArray: "5, 10" }, leg.id);
|
return _jsx(Polyline, { positions: positions, color: "#3b82f6", weight: 3, dashArray: "5, 10" }, leg.id);
|
||||||
}), legs.flatMap(l => l.locations).map((loc) => (_jsx(Marker, { position: [loc.latitude, loc.longitude], 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: "p-8 text-center bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsx(Settings, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng qu\u1EA3n l\u00FD th\u00E0nh vi\u00EAn \u0111ang \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt..." })] }))] })] }), _jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { onClick: () => activeTab === 'plan' ? setIsAddLocationOpen(true) : null, 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(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), tourId: currentTour.id }))] }));
|
}), legs.flatMap(l => l.locations).map((loc) => (_jsx(Marker, { position: [loc.latitude, loc.longitude], 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: "p-8 text-center bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsx(Settings, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng qu\u1EA3n l\u00FD th\u00E0nh vi\u00EAn \u0111ang \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt..." })] }))] })] }), _jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { onClick: () => activeTab === 'plan' ? setIsAddLocationOpen(true) : null, 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(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), tourId: currentTour.id }))] }));
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,9 +7,9 @@ export declare class JwtStrategy extends JwtStrategy_base {
|
|||||||
private prisma;
|
private prisma;
|
||||||
constructor(prisma: PrismaService);
|
constructor(prisma: PrismaService);
|
||||||
validate(payload: any): Promise<{
|
validate(payload: any): Promise<{
|
||||||
email: string;
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
Vendored
+108
-3
@@ -130,9 +130,12 @@ let TourController = class TourController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async addLocation(tourId, body) {
|
async addLocation(tourId, body) {
|
||||||
const leg = await this.prisma.leg.findFirst({ where: { tourId } });
|
const legId = body.legId;
|
||||||
|
const leg = legId
|
||||||
|
? await this.prisma.leg.findUnique({ where: { id: legId } })
|
||||||
|
: await this.prisma.leg.findFirst({ where: { tourId } });
|
||||||
if (!leg)
|
if (!leg)
|
||||||
throw new NotFoundException('Không tìm thấy chặng nào trong tour');
|
throw new NotFoundException('Không tìm thấy chặng phù hợp để thêm địa điểm');
|
||||||
return this.prisma.location.create({
|
return this.prisma.location.create({
|
||||||
data: {
|
data: {
|
||||||
name: body.name,
|
name: body.name,
|
||||||
@@ -146,6 +149,37 @@ let TourController = class TourController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async addLeg(tourId, body) {
|
||||||
|
const tour = await this.prisma.tour.findUnique({
|
||||||
|
where: { id: tourId },
|
||||||
|
include: { legs: true }
|
||||||
|
});
|
||||||
|
if (!tour)
|
||||||
|
throw new NotFoundException('Không tìm thấy tour');
|
||||||
|
return this.prisma.leg.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
sequence: tour.legs.length + 1,
|
||||||
|
note: body.note || `Chặng ${tour.legs.length + 1}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateTour(id, body) {
|
||||||
|
return this.prisma.tour.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: body.title,
|
||||||
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async deleteTour(id) {
|
||||||
|
await this.prisma.tour.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
async getPublicTours() {
|
async getPublicTours() {
|
||||||
return this.prisma.tour.findMany({
|
return this.prisma.tour.findMany({
|
||||||
take: 20,
|
take: 20,
|
||||||
@@ -198,6 +232,32 @@ __decorate([
|
|||||||
__metadata("design:paramtypes", [String, Object]),
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], TourController.prototype, "addLocation", null);
|
], TourController.prototype, "addLocation", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard),
|
||||||
|
Post(':tourId/legs'),
|
||||||
|
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "addLeg", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard),
|
||||||
|
Patch(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "updateTour", null);
|
||||||
|
__decorate([
|
||||||
|
UseGuards(JwtAuthGuard),
|
||||||
|
Delete(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "deleteTour", null);
|
||||||
__decorate([
|
__decorate([
|
||||||
Get('explore'),
|
Get('explore'),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
@@ -215,6 +275,51 @@ TourController = __decorate([
|
|||||||
Controller('v1/tours'),
|
Controller('v1/tours'),
|
||||||
__metadata("design:paramtypes", [PrismaService])
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
], TourController);
|
], TourController);
|
||||||
|
let LegController = class LegController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async updateLeg(id, body) {
|
||||||
|
return this.prisma.leg.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
note: body.note,
|
||||||
|
sequence: body.sequence
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async deleteLeg(id) {
|
||||||
|
const leg = await this.prisma.leg.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { locations: true } } }
|
||||||
|
});
|
||||||
|
if (leg?._count.locations && leg._count.locations > 0) {
|
||||||
|
throw new BadRequestException('Không thể xóa chặng đang có địa điểm. Hãy xóa địa điểm trước.');
|
||||||
|
}
|
||||||
|
await this.prisma.leg.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Patch(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__param(1, Body()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LegController.prototype, "updateLeg", null);
|
||||||
|
__decorate([
|
||||||
|
Delete(':id'),
|
||||||
|
__param(0, Param('id', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], LegController.prototype, "deleteLeg", null);
|
||||||
|
LegController = __decorate([
|
||||||
|
Controller('v1/legs'),
|
||||||
|
UseGuards(JwtAuthGuard),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], LegController);
|
||||||
function calculateDistance(lat1, lon1, lat2, lon2) {
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||||
const p = 0.017453292519943295;
|
const p = 0.017453292519943295;
|
||||||
const c = Math.cos;
|
const c = Math.cos;
|
||||||
@@ -363,7 +468,7 @@ AppModule = __decorate([
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController],
|
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController],
|
||||||
providers: [PrismaService, JwtStrategy],
|
providers: [PrismaService, JwtStrategy],
|
||||||
exports: [PrismaService]
|
exports: [PrismaService]
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+5
@@ -7,6 +7,11 @@ interface TourState {
|
|||||||
setTour: (tour: any) => void;
|
setTour: (tour: any) => void;
|
||||||
updateLegs: (legs: any[]) => void;
|
updateLegs: (legs: any[]) => void;
|
||||||
createTour: (tourData: any) => Promise<any>;
|
createTour: (tourData: any) => Promise<any>;
|
||||||
|
updateTour: (id: string, data: any) => Promise<void>;
|
||||||
|
deleteTour: (id: string) => Promise<void>;
|
||||||
|
addLeg: (tourId: string, data: any) => Promise<void>;
|
||||||
|
updateLeg: (legId: string, data: any) => Promise<void>;
|
||||||
|
deleteLeg: (legId: string) => Promise<void>;
|
||||||
addLocation: (tourId: string, locationData: any) => Promise<void>;
|
addLocation: (tourId: string, locationData: any) => Promise<void>;
|
||||||
optimizeRouting: (legId: string) => Promise<void>;
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
setActiveLegId: (id: string | null) => void;
|
setActiveLegId: (id: string | null) => void;
|
||||||
|
|||||||
Vendored
+76
@@ -39,6 +39,82 @@ export const useTourStore = create((set, get) => ({
|
|||||||
});
|
});
|
||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
updateTour: async (id, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi cập nhật Tour');
|
||||||
|
get().fetchPublicTours();
|
||||||
|
},
|
||||||
|
deleteTour: async (id) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Lỗi khi xóa Tour');
|
||||||
|
}
|
||||||
|
if (get().currentTour?.id === id)
|
||||||
|
set({ currentTour: null });
|
||||||
|
get().fetchPublicTours();
|
||||||
|
},
|
||||||
|
addLeg: async (tourId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi thêm chặng');
|
||||||
|
get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
updateLeg: async (legId, data) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Lỗi khi cập nhật chặng');
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
deleteLeg: async (legId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Lỗi khi xóa chặng');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour)
|
||||||
|
get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
addLocation: async (tourId, locationData) => {
|
addLocation: async (tourId, locationData) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/locations`, {
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/locations`, {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -111,8 +111,12 @@ class TourController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post(':tourId/locations')
|
@Post(':tourId/locations')
|
||||||
async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) {
|
async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) {
|
||||||
const leg = await this.prisma.leg.findFirst({ where: { tourId } });
|
const legId = body.legId;
|
||||||
if (!leg) throw new NotFoundException('Không tìm thấy chặng nào trong tour');
|
const leg = legId
|
||||||
|
? await this.prisma.leg.findUnique({ where: { id: legId } })
|
||||||
|
: await this.prisma.leg.findFirst({ where: { tourId } });
|
||||||
|
|
||||||
|
if (!leg) throw new NotFoundException('Không tìm thấy chặng phù hợp để thêm địa điểm');
|
||||||
|
|
||||||
return this.prisma.location.create({
|
return this.prisma.location.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -128,6 +132,48 @@ class TourController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post(':tourId/legs')
|
||||||
|
async addLeg(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) {
|
||||||
|
const tour = await this.prisma.tour.findUnique({
|
||||||
|
where: { id: tourId },
|
||||||
|
include: { legs: true }
|
||||||
|
});
|
||||||
|
if (!tour) throw new NotFoundException('Không tìm thấy tour');
|
||||||
|
|
||||||
|
return this.prisma.leg.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
sequence: tour.legs.length + 1,
|
||||||
|
note: body.note || `Chặng ${tour.legs.length + 1}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Patch(':id')
|
||||||
|
async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||||
|
// Lưu ý: Trong thực tế nên kiểm tra xem user có phải là OWNER không
|
||||||
|
return this.prisma.tour.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: body.title,
|
||||||
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete(':id')
|
||||||
|
async deleteTour(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
// Xóa tour sẽ xóa cascade các Leg, Location, Expense nhờ config onDelete: Cascade trong schema
|
||||||
|
await this.prisma.tour.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
@Get('explore')
|
@Get('explore')
|
||||||
async getPublicTours() {
|
async getPublicTours() {
|
||||||
// Lấy các tour có ít nhất 1 ảnh và thông tin vị trí từ chặng đầu tiên
|
// Lấy các tour có ít nhất 1 ảnh và thông tin vị trí từ chặng đầu tiên
|
||||||
@@ -167,6 +213,38 @@ class TourController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('v1/legs')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
class LegController {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
async updateLeg(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||||
|
return this.prisma.leg.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
note: body.note,
|
||||||
|
sequence: body.sequence
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async deleteLeg(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const leg = await this.prisma.leg.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { locations: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (leg?._count.locations && leg._count.locations > 0) {
|
||||||
|
throw new BadRequestException('Không thể xóa chặng đang có địa điểm. Hãy xóa địa điểm trước.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.leg.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper tính khoảng cách giữa 2 tọa độ (Haversine formula)
|
* Helper tính khoảng cách giữa 2 tọa độ (Haversine formula)
|
||||||
*/
|
*/
|
||||||
@@ -302,7 +380,7 @@ class UserController {
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController],
|
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController],
|
||||||
providers: [PrismaService, JwtStrategy],
|
providers: [PrismaService, JwtStrategy],
|
||||||
exports: [PrismaService]
|
exports: [PrismaService]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ interface TourState {
|
|||||||
setTour: (tour: any) => void;
|
setTour: (tour: any) => void;
|
||||||
updateLegs: (legs: any[]) => void;
|
updateLegs: (legs: any[]) => void;
|
||||||
createTour: (tourData: any) => Promise<any>;
|
createTour: (tourData: any) => Promise<any>;
|
||||||
|
updateTour: (id: string, data: any) => Promise<void>;
|
||||||
|
deleteTour: (id: string) => Promise<void>;
|
||||||
|
addLeg: (tourId: string, data: any) => Promise<void>;
|
||||||
|
updateLeg: (legId: string, data: any) => Promise<void>;
|
||||||
|
deleteLeg: (legId: string) => Promise<void>;
|
||||||
addLocation: (tourId: string, locationData: any) => Promise<void>;
|
addLocation: (tourId: string, locationData: any) => Promise<void>;
|
||||||
optimizeRouting: (legId: string) => Promise<void>;
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
setActiveLegId: (id: string | null) => void;
|
setActiveLegId: (id: string | null) => void;
|
||||||
@@ -59,6 +64,82 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
updateTour: async (id: string, data: any) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Lỗi khi cập nhật Tour');
|
||||||
|
|
||||||
|
// Làm mới danh sách khám phá
|
||||||
|
get().fetchPublicTours();
|
||||||
|
},
|
||||||
|
deleteTour: async (id: string) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Lỗi khi xóa Tour');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset tour hiện tại nếu đang xem đúng tour vừa xóa
|
||||||
|
if (get().currentTour?.id === id) set({ currentTour: null });
|
||||||
|
get().fetchPublicTours();
|
||||||
|
},
|
||||||
|
addLeg: async (tourId: string, data: any) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Lỗi khi thêm chặng');
|
||||||
|
get().fetchTour(tourId);
|
||||||
|
},
|
||||||
|
updateLeg: async (legId: string, data: any) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Lỗi khi cập nhật chặng');
|
||||||
|
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
|
deleteLeg: async (legId: string) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Lỗi khi xóa chặng');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
addLocation: async (tourId: string, locationData: any) => {
|
addLocation: async (tourId: string, locationData: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/locations`, {
|
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/locations`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user