Thêm tính năng trong mục 4.1 của ARCHITECTURE.md
This commit is contained in:
+27
-33
@@ -31,44 +31,38 @@ Hệ thống được thiết kế theo mô hình **Client-Server** kết hợp
|
|||||||
|
|
||||||
Dưới đây là các thực thể cốt lõi phục vụ tính năng:
|
Dưới đây là các thực thể cốt lõi phục vụ tính năng:
|
||||||
|
|
||||||
### 3.1. Users & Authentication
|
### 3.1. User (Người dùng)
|
||||||
* `users`: Lưu thông tin định danh (`id`, `email`, `password_hash`, `name`, `avatar`).
|
Lưu trữ thông tin định danh và trạng thái quản trị. Toàn bộ ID sử dụng định dạng **UUID**.
|
||||||
|
* `User`: `id`, `email`, `passwordHash`, `name`, `avatar`, `isAdmin`, `isBlocked`, `createdAt`.
|
||||||
|
|
||||||
### 3.2. Tour & Members (Quản lý đa người dùng & Phân quyền)
|
|
||||||
* `tours`: Thông tin tổng quan về chuyến đi.
|
|
||||||
* `id` (PK), `title`, `start_date`, `end_date`, `creator_id` (FK), `created_at`.
|
|
||||||
* `tour_members`: Bảng trung gian quản lý thành viên và quyền hạn (Bảo mật thông tin).
|
|
||||||
* `tour_id` (FK), `user_id` (FK).
|
|
||||||
* `role`: Định nghĩa các quyền cụ thể:
|
|
||||||
* `OWNER`: Toàn quyền (Người tạo).
|
|
||||||
* `EDITOR`: Sửa đổi kế hoạch, chi phí, xem/thêm ảnh.
|
|
||||||
* `MEMBER_PLAN_ONLY`: Chỉ xem/sửa kế hoạch, không xem được chi phí.
|
|
||||||
* `MEMBER_PHOTO_ONLY`: Chỉ được xem/đăng ảnh trong album, không thấy kế hoạch và chi phí.
|
|
||||||
* `VIEWER_EXTERNAL`: Người ngoài được share link, chỉ xem được ảnh công khai (tùy thuộc vào cài đặt privacy).
|
|
||||||
|
|
||||||
### 3.3. Itinerary & Map (Chặng & Địa điểm)
|
### 3.2. Tour & Phân quyền (RBAC)
|
||||||
* `legs` (Chặng): Một tour có nhiều chặng.
|
Hệ thống sử dụng bảng trung gian để quản lý thành viên cho từng chuyến đi.
|
||||||
* `id` (PK), `tour_id` (FK), `sequence_number` (Thứ tự chặng: 1, 2, 3...), `notes`.
|
* `Tour`: `id`, `title`, `startDate`, `endDate`, `totalCost` (Decimal), `createdById` (FK -> User).
|
||||||
* `places` (Địa điểm trong chặng):
|
* `TourParticipant`: Quản lý vai trò thành viên trong Tour qua enum `ParticipantRole`:
|
||||||
* `id` (PK), `leg_id` (FK), `name`, `address`, `latitude`, `longitude` (Dữ liệu PostGIS), `sequence_in_leg`.
|
* `OWNER`: Toàn quyền quản lý.
|
||||||
* `arrival_time` (Dự kiến), `departure_time` (Dự kiến).
|
* `MANAGER`: Quản lý nội dung và thành viên.
|
||||||
|
* `MEMBER`: Thành viên chính thức (Xem được chi phí).
|
||||||
|
* `MEMBER_NO_FINANCE`: Thành viên không được xem thông tin tài chính.
|
||||||
|
* `VIEWER_ONLY`: Chỉ xem lộ trình và ảnh.
|
||||||
|
|
||||||
### 3.4. Expenses (Chi phí)
|
### 3.3. Lộ trình (Leg & Location)
|
||||||
* `expenses`: Lưu vết chi phí cho từng địa điểm/chặng.
|
Một Tour được chia thành nhiều chặng di chuyển (Leg), mỗi chặng chứa danh sách các điểm đến (Location).
|
||||||
* `id` (PK), `leg_id` (FK), `place_id` (FK, nullable), `category` (`LODGING`, `DINING`, `TRANSPORT`, `OTHER`), `amount` (Số tiền), `currency`, `description`.
|
* `Leg`: `id`, `tourId` (FK), `sequence` (Thứ tự chặng), `note`.
|
||||||
* `tour_cost_summaries`: Cấu hình phân chia tiền ở cuối tour/chặng.
|
* `Location`: Tích hợp tính năng theo dõi tiến độ (Thay thế khái niệm `Task` cũ).
|
||||||
* `tour_id` (FK), `total_cost`, `adult_count`, `child_count`, `child_discount_percent` (Ví dụ: trẻ em giảm 30%).
|
* Tọa độ: `latitude`, `longitude`.
|
||||||
|
* Thời gian: `plannedStart`, `plannedEnd`, `actualStart`, `actualEnd`.
|
||||||
|
* Trạng thái (`LocationStatus`): `PENDING` (Chờ), `COMPLETED` (Hoàn thành).
|
||||||
|
* Loại địa điểm (`LocationType`): `MOVE`, `VISIT`, `REST`, `EAT`.
|
||||||
|
|
||||||
### 3.5. Tasks & Timeline Tracking (Theo dõi tiến độ)
|
### 3.4. Chi phí (Expense)
|
||||||
* `tasks`: Các hoạt động cần tích chọn hoàn thành.
|
Quản lý tài chính cho từng chặng hoặc gắn trực tiếp vào một địa điểm cụ thể.
|
||||||
* `id` (PK), `tour_id` (FK), `leg_id` (FK), `title`, `planned_timestamp`.
|
* `Expense`: `id` (UUID, PK), `leg_id` (UUID, FK), `location_id` (UUID, FK, Nullable), `category` (Enum: `ACCOMMODATION`, `FOOD`, `TRANSPORT`, `TICKET`, `OTHER`), `amount` (Decimal), `description` (Text).
|
||||||
* `is_completed` (Boolean).
|
|
||||||
* `completed_at` (Timestamp - Ghi lại lúc hệ thống tự động tích hoặc user chủ động tích).
|
|
||||||
* `trigger_type` (`AUTO_BY_TIME` hoặc `MANUAL_BY_USER`).
|
|
||||||
|
|
||||||
### 3.6. Album Ảnh (Tách biệt logic kế hoạch)
|
### 3.5. Album Ảnh (Photo)
|
||||||
* `photos`: Lưu trữ hình ảnh của tour gắn với địa điểm.
|
Lưu trữ tài nguyên đa phương tiện gắn với bối cảnh chuyến đi.
|
||||||
* `id` (PK), `tour_id` (FK), `place_id` (FK, nullable), `uploader_id` (FK), `image_url` (Đường dẫn S3), `captured_at` (Metadata từ ảnh hoặc thời gian tạo), `privacy_level` (`PUBLIC_IN_TOUR`, `PRIVATE_OWNER`).
|
* `Photo`: `id`, `tourId` (FK), `locationId` (FK, nullable), `uploaderId` (FK), `imageUrl`, `privacy`.
|
||||||
|
* Cấp độ bảo mật (`PrivacyLevel`): `PUBLIC`, `TOUR_ONLY`, `PRIVATE`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+100
-10
@@ -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 } from 'lucide-react';
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation } 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 }) => {
|
||||||
@@ -19,8 +19,24 @@ const TimeVariance = ({ planned, actual }: { planned: string, actual: string | n
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
|
||||||
|
const p = 0.017453292519943295; // Math.PI / 180
|
||||||
|
const c = Math.cos;
|
||||||
|
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||||
|
c(lat1 * p) * c(lat2 * p) *
|
||||||
|
(1 - c((lon2 - lon1) * p)) / 2;
|
||||||
|
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTravelTime = (minutes: number) => {
|
||||||
|
if (minutes < 60) return `${minutes} phút`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||||
|
};
|
||||||
|
|
||||||
export const ItineraryTimeline = () => {
|
export const ItineraryTimeline = () => {
|
||||||
const { legs } = useTourStore();
|
const { legs, optimizeRouting, userRole } = 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
|
||||||
@@ -32,24 +48,72 @@ export const ItineraryTimeline = () => {
|
|||||||
<h2 className="text-2xl font-bold text-gray-800 mb-8 px-2">Lộ trình chuyến đi</h2>
|
<h2 className="text-2xl font-bold text-gray-800 mb-8 px-2">Lộ trình chuyến đi</h2>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{legs.map((leg, legIdx) => (
|
{legs.map((leg, legIdx) => {
|
||||||
|
const totalDwellMinutes = leg.locations.reduce((acc: number, loc: any) => {
|
||||||
|
if (loc.plannedStart && loc.plannedEnd) {
|
||||||
|
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={leg.id} className="relative">
|
<div key={leg.id} className="relative">
|
||||||
{/* Leg Header */}
|
{/* Leg Header */}
|
||||||
<div className="flex items-center mb-4 px-2">
|
<div className="flex items-center mb-4 px-2">
|
||||||
<div className="bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm">
|
<div className="bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm">
|
||||||
Chặng {leg.sequence}
|
Chặng {leg.sequence}
|
||||||
</div>
|
</div>
|
||||||
|
{leg.totalDistance !== undefined && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 ml-3">
|
||||||
|
<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
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
~ {formatTravelTime(Math.round((leg.totalDistance / 35) * 60))}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Tối ưu đường đi
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="ml-4 h-[1px] flex-1 bg-gray-200" />
|
<div className="ml-4 h-[1px] flex-1 bg-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vertical Line for the whole leg */}
|
{/* Vertical Line for the whole leg */}
|
||||||
<div className="absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" />
|
<div className="absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" />
|
||||||
|
|
||||||
<div className="space-y-6 ml-2">
|
<div className="ml-2">
|
||||||
{leg.locations.map((location) => (
|
{leg.locations.map((location, idx) => {
|
||||||
<div key={location.id} className="relative flex group">
|
const nextLocation = leg.locations[idx + 1];
|
||||||
{/* Timeline Node */}
|
const distanceToNext = nextLocation
|
||||||
<div className="z-10 mt-1.5 mr-4">
|
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const averageSpeed = 35; // km/h (vận tốc trung bình giả định)
|
||||||
|
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||||
|
|
||||||
|
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||||
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={location.id}>
|
||||||
|
<div className="relative flex group mb-6">
|
||||||
|
{/* Timeline Node */}
|
||||||
|
<div className="z-10 mt-1.5 mr-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleComplete(location.id)}
|
onClick={() => toggleComplete(location.id)}
|
||||||
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
||||||
@@ -75,6 +139,12 @@ export const ItineraryTimeline = () => {
|
|||||||
<MapPin className="w-3 h-3 mr-1" />
|
<MapPin className="w-3 h-3 mr-1" />
|
||||||
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
|
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{dwellMinutes !== null && (
|
||||||
|
<div className="flex items-center text-xs text-amber-600 font-medium mt-1">
|
||||||
|
<Clock className="w-3 h-3 mr-1" />
|
||||||
|
<span>Thời gian dừng: {formatTravelTime(dwellMinutes)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right flex flex-col items-end">
|
<div className="text-right flex flex-col items-end">
|
||||||
@@ -94,7 +164,26 @@ export const ItineraryTimeline = () => {
|
|||||||
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
|
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
{distanceToNext !== null && travelTimeMinutes !== null && (
|
||||||
|
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3">
|
||||||
|
<div className="w-8 flex justify-center">
|
||||||
|
<Navigation className="w-3 h-3 text-blue-400 rotate-180" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
|
||||||
|
{distanceToNext.toFixed(2)} km
|
||||||
|
</span>
|
||||||
|
<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">
|
||||||
|
<Clock className="w-2.5 h-2.5" />
|
||||||
|
~ {formatTravelTime(travelTimeMinutes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{leg.notes && (
|
{leg.notes && (
|
||||||
@@ -103,7 +192,8 @@ export const ItineraryTimeline = () => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+37
-3
@@ -1,6 +1,6 @@
|
|||||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle } from 'lucide-react';
|
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation } 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)
|
||||||
@@ -9,11 +9,45 @@ const TimeVariance = ({ planned, actual }) => {
|
|||||||
const isLate = diff > 0;
|
const isLate = diff > 0;
|
||||||
return (_jsxs("div", { className: `flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`, children: [isLate ? _jsx(AlertCircle, { className: "w-3 h-3 mr-1" }) : _jsx(CheckCircle2, { className: "w-3 h-3 mr-1" }), _jsx("span", { children: isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút` })] }));
|
return (_jsxs("div", { className: `flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`, children: [isLate ? _jsx(AlertCircle, { className: "w-3 h-3 mr-1" }) : _jsx(CheckCircle2, { className: "w-3 h-3 mr-1" }), _jsx("span", { children: isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút` })] }));
|
||||||
};
|
};
|
||||||
|
const calculateDistance = (lat1, lon1, lat2, lon2) => {
|
||||||
|
const p = 0.017453292519943295;
|
||||||
|
const c = Math.cos;
|
||||||
|
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||||
|
c(lat1 * p) * c(lat2 * p) *
|
||||||
|
(1 - c((lon2 - lon1) * p)) / 2;
|
||||||
|
return 12742 * Math.asin(Math.sqrt(a));
|
||||||
|
};
|
||||||
|
const formatTravelTime = (minutes) => {
|
||||||
|
if (minutes < 60)
|
||||||
|
return `${minutes} phút`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||||
|
};
|
||||||
export const ItineraryTimeline = () => {
|
export const ItineraryTimeline = () => {
|
||||||
const { legs } = useTourStore();
|
const { legs, optimizeRouting, userRole } = useTourStore();
|
||||||
const toggleComplete = async (locationId) => {
|
const toggleComplete = async (locationId) => {
|
||||||
console.log("Toggle status for location:", locationId);
|
console.log("Toggle status for location:", locationId);
|
||||||
};
|
};
|
||||||
return (_jsxs("div", { className: "max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen", children: [_jsx("h2", { className: "text-2xl font-bold text-gray-800 mb-8 px-2", children: "L\u1ED9 tr\u00ECnh chuy\u1EBFn \u0111i" }), _jsx("div", { className: "space-y-8", children: legs.map((leg, legIdx) => (_jsxs("div", { className: "relative", children: [_jsxs("div", { className: "flex items-center mb-4 px-2", children: [_jsxs("div", { className: "bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm", children: ["Ch\u1EB7ng ", leg.sequence] }), _jsx("div", { className: "ml-4 h-[1px] flex-1 bg-gray-200" })] }), _jsx("div", { className: "absolute left-6 top-10 bottom-0 w-0.5 bg-gray-200 -z-0" }), _jsx("div", { className: "space-y-6 ml-2", children: leg.locations.map((location) => (_jsxs("div", { className: "relative flex group", 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 })] })] }), _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 })] })] }, location.id))) }), leg.notes && (_jsxs("p", { className: "ml-14 mt-4 text-sm text-gray-400 italic", children: ["* ", leg.notes] }))] }, leg.id))) })] }));
|
return (_jsxs("div", { className: "max-w-2xl mx-auto p-4 sm:p-6 bg-gray-50 min-h-screen", children: [_jsx("h2", { className: "text-2xl font-bold text-gray-800 mb-8 px-2", children: "L\u1ED9 tr\u00ECnh chuy\u1EBFn \u0111i" }), _jsx("div", { className: "space-y-8", children: legs.map((leg, legIdx) => {
|
||||||
|
const totalDwellMinutes = leg.locations.reduce((acc, loc) => {
|
||||||
|
if (loc.plannedStart && loc.plannedEnd) {
|
||||||
|
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
return (_jsxs("div", { className: "relative", children: [_jsxs("div", { className: "flex items-center mb-4 px-2", children: [_jsxs("div", { className: "bg-blue-600 text-white text-sm font-bold px-3 py-1 rounded-full shadow-sm", children: ["Ch\u1EB7ng ", leg.sequence] }), leg.totalDistance !== undefined && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 ml-3", 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 \u0111\u01B0\u1EDDng \u0111i"] })), _jsx("div", { className: "ml-4 h-[1px] flex-1 bg-gray-200" })] }), _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 distanceToNext = nextLocation
|
||||||
|
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||||
|
: null;
|
||||||
|
const averageSpeed = 35;
|
||||||
|
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||||
|
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||||
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
|
: 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));
|
||||||
|
}) }), leg.notes && (_jsxs("p", { className: "ml-14 mt-4 text-sm text-gray-400 italic", children: ["* ", leg.notes] }))] }, leg.id));
|
||||||
|
}) })] }));
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=ItineraryTimeline.js.map
|
//# sourceMappingURL=ItineraryTimeline.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+66
-1
@@ -157,6 +157,71 @@ TourController = __decorate([
|
|||||||
Controller('v1/tours'),
|
Controller('v1/tours'),
|
||||||
__metadata("design:paramtypes", [PrismaService])
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
], TourController);
|
], TourController);
|
||||||
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||||
|
const p = 0.017453292519943295;
|
||||||
|
const c = Math.cos;
|
||||||
|
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||||
|
c(lat1 * p) * c(lat2 * p) *
|
||||||
|
(1 - c((lon2 - lon1) * p)) / 2;
|
||||||
|
return 12742 * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
let RoutingController = class RoutingController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async optimize(legId) {
|
||||||
|
const locations = await this.prisma.location.findMany({
|
||||||
|
where: { legId },
|
||||||
|
});
|
||||||
|
if (locations.length <= 2)
|
||||||
|
return locations;
|
||||||
|
const optimized = [];
|
||||||
|
const unvisited = [...locations];
|
||||||
|
let current = unvisited.sort((a, b) => (a.plannedStart?.getTime() || 0) - (b.plannedStart?.getTime() || 0)).shift();
|
||||||
|
optimized.push(current);
|
||||||
|
while (unvisited.length > 0) {
|
||||||
|
let nearestIdx = 0;
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (let i = 0; i < unvisited.length; i++) {
|
||||||
|
const d = calculateDistance(current.latitude, current.longitude, unvisited[i].latitude, unvisited[i].longitude);
|
||||||
|
if (d < minDist) {
|
||||||
|
minDist = d;
|
||||||
|
nearestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = unvisited.splice(nearestIdx, 1)[0];
|
||||||
|
optimized.push(current);
|
||||||
|
}
|
||||||
|
let totalDistance = 0;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const baseTime = optimized[0].plannedStart || new Date();
|
||||||
|
await Promise.all(optimized.map((loc, index) => this.prisma.location.update({
|
||||||
|
where: { id: loc.id },
|
||||||
|
data: { plannedStart: new Date(baseTime.getTime() + index * 3600000) },
|
||||||
|
})));
|
||||||
|
const updatedLocations = await this.prisma.location.findMany({
|
||||||
|
where: { legId },
|
||||||
|
orderBy: { plannedStart: 'asc' }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
locations: updatedLocations,
|
||||||
|
totalDistance: parseFloat(totalDistance.toFixed(2))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
Post('optimize/:legId'),
|
||||||
|
__param(0, Param('legId', ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], RoutingController.prototype, "optimize", null);
|
||||||
|
RoutingController = __decorate([
|
||||||
|
Controller('v1/routing'),
|
||||||
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
|
], RoutingController);
|
||||||
let UserController = class UserController {
|
let UserController = class UserController {
|
||||||
constructor(prisma) {
|
constructor(prisma) {
|
||||||
this.prisma = prisma;
|
this.prisma = prisma;
|
||||||
@@ -240,7 +305,7 @@ AppModule = __decorate([
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController],
|
controllers: [AppController, AuthController, TourController, UserController, RoutingController],
|
||||||
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
+1
-1
@@ -5,7 +5,7 @@ interface TourState {
|
|||||||
userRole: string | null;
|
userRole: string | null;
|
||||||
setTour: (tour: any) => void;
|
setTour: (tour: any) => void;
|
||||||
updateLegs: (legs: any[]) => void;
|
updateLegs: (legs: any[]) => void;
|
||||||
optimizeRouting: () => Promise<void>;
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
fetchTour: (id: string) => Promise<void>;
|
fetchTour: (id: string) => Promise<void>;
|
||||||
fetchPublicTours: () => Promise<void>;
|
fetchPublicTours: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+11
-1
@@ -19,7 +19,17 @@ export const useTourStore = create((set, get) => ({
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
set({ publicTours: data });
|
set({ publicTours: data });
|
||||||
},
|
},
|
||||||
optimizeRouting: async () => {
|
optimizeRouting: async (legId) => {
|
||||||
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/routing/optimize/${legId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const { locations, totalDistance } = await response.json();
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour) {
|
||||||
|
const updatedLegs = currentTour.legs.map((l) => l.id === legId ? { ...l, locations: locations, totalDistance: totalDistance } : l);
|
||||||
|
set({ currentTour: { ...currentTour, legs: updatedLegs }, legs: updatedLegs });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
//# sourceMappingURL=useTourStore.js.map
|
//# sourceMappingURL=useTourStore.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"useTourStore.js","sourceRoot":"","sources":["../useTourStore.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAcjC,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAY,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IAC3D,WAAW,EAAE,IAAI;IACjB,IAAI,EAAE,EAAE;IACR,WAAW,EAAE,EAAE;IACf,QAAQ,EAAE,IAAI;IACd,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;IAC7C,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,CAAC;IACnC,SAAS,EAAE,KAAK,EAAE,EAAU,EAAE,EAAE;QAC9B,MAAM,QAAQ,GAAG,UAAU,MAAM,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC;QAC3D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,QAAQ,iBAAiB,EAAE,EAAE,CAAC,CAAC;QAC/D,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QAGnC,MAAM,IAAI,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,IAAI,aAAa,CAAC;QAC3D,GAAG,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACpE,CAAC;IACD,gBAAgB,EAAE,KAAK,IAAI,EAAE;QAC3B,MAAM,QAAQ,GAAG,UAAU,MAAM,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC;QAC3D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,QAAQ,uBAAuB,CAAC,CAAC;QACjE,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QACnC,GAAG,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;IAC7B,CAAC;IACD,eAAe,EAAE,KAAK,IAAI,EAAE;IAE5B,CAAC;CACF,CAAC,CAAC,CAAC"}
|
{"version":3,"file":"useTourStore.js","sourceRoot":"","sources":["../useTourStore.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAcjC,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAY,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IAC3D,WAAW,EAAE,IAAI;IACjB,IAAI,EAAE,EAAE;IACR,WAAW,EAAE,EAAE;IACf,QAAQ,EAAE,IAAI;IACd,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;IAC7C,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,CAAC;IACnC,SAAS,EAAE,KAAK,EAAE,EAAU,EAAE,EAAE;QAC9B,MAAM,QAAQ,GAAG,UAAU,MAAM,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC;QAC3D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,QAAQ,iBAAiB,EAAE,EAAE,CAAC,CAAC;QAC/D,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QAGnC,MAAM,IAAI,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,IAAI,aAAa,CAAC;QAC3D,GAAG,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACpE,CAAC;IACD,gBAAgB,EAAE,KAAK,IAAI,EAAE;QAC3B,MAAM,QAAQ,GAAG,UAAU,MAAM,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC;QAC3D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,QAAQ,uBAAuB,CAAC,CAAC;QACjE,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QACnC,GAAG,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;IAC7B,CAAC;IACD,eAAe,EAAE,KAAK,EAAE,KAAa,EAAE,EAAE;QACvC,MAAM,QAAQ,GAAG,UAAU,MAAM,CAAC,QAAQ,CAAC,QAAQ,OAAO,CAAC;QAC3D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,QAAQ,4BAA4B,KAAK,EAAE,EAAE;YAC3E,MAAM,EAAE,MAAM;SACf,CAAC,CAAC;QACH,MAAM,EAAE,SAAS,EAAE,aAAa,EAAE,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QAE3D,MAAM,EAAE,WAAW,EAAE,GAAG,GAAG,EAAE,CAAC;QAC9B,IAAI,WAAW,EAAE,CAAC;YAChB,MAAM,WAAW,GAAG,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAM,EAAE,EAAE,CAClD,CAAC,CAAC,EAAE,KAAK,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,SAAS,EAAE,SAAS,EAAE,aAAa,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,CAAC,CAClF,CAAC;YACF,GAAG,CAAC,EAAE,WAAW,EAAE,EAAE,GAAG,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC;QACjF,CAAC;IACH,CAAC;CACF,CAAC,CAAC,CAAC"}
|
||||||
@@ -121,6 +121,85 @@ class TourController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper tính khoảng cách giữa 2 tọa độ (Haversine formula)
|
||||||
|
*/
|
||||||
|
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||||
|
const p = 0.017453292519943295; // Math.PI / 180
|
||||||
|
const c = Math.cos;
|
||||||
|
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||||
|
c(lat1 * p) * c(lat2 * p) *
|
||||||
|
(1 - c((lon2 - lon1) * p)) / 2;
|
||||||
|
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('v1/routing')
|
||||||
|
class RoutingController {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Post('optimize/:legId')
|
||||||
|
async optimize(@Param('legId', ParseUUIDPipe) legId: string) {
|
||||||
|
const locations = await this.prisma.location.findMany({
|
||||||
|
where: { legId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (locations.length <= 2) return locations;
|
||||||
|
|
||||||
|
// 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) =>
|
||||||
|
(a.plannedStart?.getTime() || 0) - (b.plannedStart?.getTime() || 0)
|
||||||
|
).shift()!;
|
||||||
|
optimized.push(current);
|
||||||
|
|
||||||
|
while (unvisited.length > 0) {
|
||||||
|
let nearestIdx = 0;
|
||||||
|
let minDist = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < unvisited.length; i++) {
|
||||||
|
const d = calculateDistance(current.latitude, current.longitude, unvisited[i].latitude, unvisited[i].longitude);
|
||||||
|
if (d < minDist) {
|
||||||
|
minDist = d;
|
||||||
|
nearestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = unvisited.splice(nearestIdx, 1)[0];
|
||||||
|
optimized.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tính toán tổng quãng đường di chuyển của chặng (km)
|
||||||
|
let totalDistance = 0;
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật lại thời gian plannedStart trong DB để phản ánh thứ tự mới (mỗi điểm cách nhau 1 giờ giả định)
|
||||||
|
const baseTime = optimized[0].plannedStart || new Date();
|
||||||
|
await Promise.all(optimized.map((loc, index) =>
|
||||||
|
this.prisma.location.update({
|
||||||
|
where: { id: loc.id },
|
||||||
|
data: { plannedStart: new Date(baseTime.getTime() + index * 3600000) },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
const updatedLocations = await this.prisma.location.findMany({
|
||||||
|
where: { legId },
|
||||||
|
orderBy: { plannedStart: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
locations: updatedLocations,
|
||||||
|
totalDistance: parseFloat(totalDistance.toFixed(2))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('v1/users')
|
@Controller('v1/users')
|
||||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||||
class UserController {
|
class UserController {
|
||||||
@@ -177,7 +256,7 @@ class UserController {
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController],
|
controllers: [AppController, AuthController, TourController, UserController, RoutingController],
|
||||||
providers: [PrismaService, JwtStrategy],
|
providers: [PrismaService, JwtStrategy],
|
||||||
exports: [PrismaService]
|
exports: [PrismaService]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -123,11 +123,10 @@ model Location {
|
|||||||
|
|
||||||
model Expense {
|
model Expense {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
legId String
|
legId String @map("leg_id")
|
||||||
locationId String?
|
locationId String? @map("location_id")
|
||||||
category ExpenseCategory
|
category ExpenseCategory
|
||||||
amount Decimal @db.Decimal(15, 2)
|
amount Decimal @db.Decimal(15, 2)
|
||||||
currency String @default("VND")
|
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
|
|
||||||
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
+15
-3
@@ -7,7 +7,7 @@ interface TourState {
|
|||||||
userRole: string | null;
|
userRole: string | null;
|
||||||
setTour: (tour: any) => void;
|
setTour: (tour: any) => void;
|
||||||
updateLegs: (legs: any[]) => void;
|
updateLegs: (legs: any[]) => void;
|
||||||
optimizeRouting: () => Promise<void>;
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
fetchTour: (id: string) => Promise<void>;
|
fetchTour: (id: string) => Promise<void>;
|
||||||
fetchPublicTours: () => Promise<void>;
|
fetchPublicTours: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,19 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
set({ publicTours: data });
|
set({ publicTours: data });
|
||||||
},
|
},
|
||||||
optimizeRouting: async () => {
|
optimizeRouting: async (legId: string) => {
|
||||||
// Logic gọi API /api/v1/routing/optimize và cập nhật lại state
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/routing/optimize/${legId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const { locations, totalDistance } = await response.json();
|
||||||
|
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour) {
|
||||||
|
const updatedLegs = currentTour.legs.map((l: any) =>
|
||||||
|
l.id === legId ? { ...l, locations: locations, totalDistance: totalDistance } : l
|
||||||
|
);
|
||||||
|
set({ currentTour: { ...currentTour, legs: updatedLegs }, legs: updatedLegs });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
Reference in New Issue
Block a user