Gán điểm kết thúc của chặng trước làm điểm khởi hành của chặng sau

This commit is contained in:
2026-06-14 10:11:33 +07:00
parent bf63510980
commit ec650fb45d
10 changed files with 111 additions and 16 deletions
+2 -1
View File
@@ -46,7 +46,8 @@ function MapTracker() {
}
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
const { publicTours, fetchPublicTours, setMapCenter } = useTourStore();
// Thêm fetchTour vào destructuring từ store
const { publicTours, fetchPublicTours, fetchTour, setMapCenter } = useTourStore();
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
const [initialViewState] = useState(() => {
+13 -2
View File
@@ -92,16 +92,26 @@ export const ItineraryTimeline = ({ onAddLocation }: { onAddLocation?: (legId: s
return acc;
}, 0);
// Xác định địa điểm cuối cùng của chặng trước đó để hiển thị tính liên tục
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
return (
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Leg Header */}
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
<div className="font-black text-blue-600 text-xl truncate flex-1 flex items-center gap-2">
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm">
{leg.sequence}
</span>
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
</div>
{prevLegLastLoc && (
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
<Navigation className="w-2.5 h-2.5 rotate-90" /> Tiếp nối từ {prevLegLastLoc.name}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
{['OWNER', 'MANAGER'].includes(userRole || '') && (
<>
@@ -156,7 +166,8 @@ export const ItineraryTimeline = ({ onAddLocation }: { onAddLocation?: (legId: s
</div>
{/* Vertical Line for the whole leg */}
<div className="absolute left-6 top-16 bottom-0 w-0.5 bg-blue-100 -z-0" />
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
<div className="ml-2">
{leg.locations.map((location, idx) => {
+1 -1
View File
@@ -38,7 +38,7 @@ function MapTracker() {
return null;
}
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }) => {
const { publicTours, fetchPublicTours, setMapCenter } = useTourStore();
const { publicTours, fetchPublicTours, fetchTour, setMapCenter } = useTourStore();
const [initialViewState] = useState(() => {
const saved = localStorage.getItem('map_view_state');
if (saved) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -65,7 +65,8 @@ export const ItineraryTimeline = ({ onAddLocation }) => {
}
return acc;
}, 0);
return (_jsxs("div", { className: "relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300", children: [_jsxs("div", { className: "sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2", children: [_jsxs("div", { className: "font-black text-blue-600 text-xl truncate flex-1 flex items-center gap-2", children: [_jsx("span", { className: "bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm", children: leg.sequence }), 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: () => onAddLocation?.(leg.id), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", title: "Th\u00EAm \u0111\u1ECBa \u0111i\u1EC3m v\u00E0o ch\u1EB7ng n\u00E0y", children: _jsx(Plus, { className: "w-4 h-4" }) }), _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-16 bottom-0 w-0.5 bg-blue-100 -z-0" }), _jsx("div", { className: "ml-2", children: leg.locations.map((location, idx) => {
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
return (_jsxs("div", { className: "relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300", children: [_jsxs("div", { className: "sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2", children: [_jsxs("div", { className: "font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm", children: leg.sequence }), leg.note || `Chi tiết Chặng ${leg.sequence}`] }), prevLegLastLoc && (_jsxs("div", { className: "flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10", children: [_jsx(Navigation, { className: "w-2.5 h-2.5 rotate-90" }), " Ti\u1EBFp n\u1ED1i t\u1EEB ", prevLegLastLoc.name] }))] }), _jsxs("div", { className: "flex items-center gap-2 ml-4", children: [['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAddLocation?.(leg.id), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", title: "Th\u00EAm \u0111\u1ECBa \u0111i\u1EC3m v\u00E0o ch\u1EB7ng n\u00E0y", children: _jsx(Plus, { className: "w-4 h-4" }) }), _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-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0` }), _jsx("div", { className: "ml-2", children: leg.locations.map((location, idx) => {
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
const distanceToNext = nextLocation
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
+1 -1
View File
File diff suppressed because one or more lines are too long
+39 -3
View File
@@ -449,14 +449,47 @@ let RoutingController = class RoutingController {
this.prisma = prisma;
}
async optimize(legId) {
const currentLeg = await this.prisma.leg.findUnique({
where: { id: legId },
});
if (!currentLeg)
throw new NotFoundException('Không tìm thấy chặng');
const locations = await this.prisma.location.findMany({
where: { legId },
});
if (locations.length <= 2)
return locations;
if (locations.length === 0)
return { locations: [], totalDistance: 0 };
let startAnchor = null;
const prevLeg = await this.prisma.leg.findFirst({
where: {
tourId: currentLeg.tourId,
sequence: currentLeg.sequence - 1
},
include: { locations: { orderBy: { plannedStart: 'asc' } } }
});
if (prevLeg?.locations?.length) {
startAnchor = prevLeg.locations[prevLeg.locations.length - 1];
}
if (locations.length <= 2 && !startAnchor)
return { locations, totalDistance: 0 };
const optimized = [];
const unvisited = [...locations];
let current = unvisited.sort((a, b) => (a.plannedStart?.getTime() || 0) - (b.plannedStart?.getTime() || 0)).shift();
let current;
if (startAnchor) {
let nearestIdx = 0;
let minDist = Infinity;
for (let i = 0; i < unvisited.length; i++) {
const d = calculateDistance(startAnchor.latitude, startAnchor.longitude, unvisited[i].latitude, unvisited[i].longitude);
if (d < minDist) {
minDist = d;
nearestIdx = i;
}
}
current = unvisited.splice(nearestIdx, 1)[0];
}
else {
current = unvisited.sort((a, b) => (a.plannedStart?.getTime() || 0) - (b.plannedStart?.getTime() || 0)).shift();
}
optimized.push(current);
while (unvisited.length > 0) {
let nearestIdx = 0;
@@ -472,6 +505,9 @@ let RoutingController = class RoutingController {
optimized.push(current);
}
let totalDistance = 0;
if (startAnchor) {
totalDistance += calculateDistance(startAnchor.latitude, startAnchor.longitude, optimized[0].latitude, optimized[0].longitude);
}
for (let i = 0; i < optimized.length - 1; i++) {
totalDistance += calculateDistance(optimized[i].latitude, optimized[i].longitude, optimized[i + 1].latitude, optimized[i + 1].longitude);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+48 -2
View File
@@ -383,20 +383,57 @@ class RoutingController {
@Post('optimize/:legId')
async optimize(@Param('legId', ParseUUIDPipe) legId: string) {
const currentLeg = await this.prisma.leg.findUnique({
where: { id: legId },
});
if (!currentLeg) throw new NotFoundException('Không tìm thấy chặng');
const locations = await this.prisma.location.findMany({
where: { legId },
});
if (locations.length <= 2) return locations;
if (locations.length === 0) return { locations: [], totalDistance: 0 };
// KHAI BÁO startAnchor ở đầu hàm để tránh lỗi scope TS2304
let startAnchor: any = null;
// Tìm địa điểm cuối cùng của chặng trước đó
const prevLeg = await this.prisma.leg.findFirst({
where: {
tourId: currentLeg.tourId,
sequence: currentLeg.sequence - 1
},
include: { locations: { orderBy: { plannedStart: 'asc' } } }
});
if (prevLeg?.locations?.length) {
startAnchor = prevLeg.locations[prevLeg.locations.length - 1];
}
if (locations.length <= 2 && !startAnchor) return { locations, totalDistance: 0 };
// Thuật toán Greedy TSP đơn giản để tối ưu hóa lộ trình
const optimized = [];
const unvisited = [...locations];
// Bắt đầu với địa điểm có thời gian dự kiến sớm nhất hiện tại
let current = unvisited.sort((a, b) =>
let current: any;
if (startAnchor) {
let nearestIdx = 0;
let minDist = Infinity;
for (let i = 0; i < unvisited.length; i++) {
const d = calculateDistance(startAnchor.latitude, startAnchor.longitude, unvisited[i].latitude, unvisited[i].longitude);
if (d < minDist) {
minDist = d;
nearestIdx = i;
}
}
current = unvisited.splice(nearestIdx, 1)[0];
} else {
current = unvisited.sort((a, b) =>
(a.plannedStart?.getTime() || 0) - (b.plannedStart?.getTime() || 0)
).shift()!;
}
optimized.push(current);
while (unvisited.length > 0) {
@@ -416,6 +453,15 @@ class RoutingController {
// Tính toán tổng quãng đường di chuyển của chặng (km)
let totalDistance = 0;
// Cộng thêm quãng đường từ chặng trước nối sang chặng này
if (startAnchor) {
totalDistance += calculateDistance(
startAnchor.latitude, startAnchor.longitude,
optimized[0].latitude, optimized[0].longitude
);
}
for (let i = 0; i < optimized.length - 1; i++) {
totalDistance += calculateDistance(
optimized[i].latitude, optimized[i].longitude,