Thêm tính năng trong mục 4.1 của ARCHITECTURE.md

This commit is contained in:
2026-06-13 20:14:52 +07:00
parent b54707823c
commit c51ddc34c7
13 changed files with 343 additions and 60 deletions
+27 -33
View File
@@ -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:
### 3.1. Users & Authentication
* `users`: Lưu thông tin định danh (`id`, `email`, `password_hash`, `name`, `avatar`).
### 3.1. User (Người dùng)
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)
* `legs` (Chặng): Một tour có nhiều chặng.
* `id` (PK), `tour_id` (FK), `sequence_number` (Thứ tự chặng: 1, 2, 3...), `notes`.
* `places` (Địa điểm trong chặng):
* `id` (PK), `leg_id` (FK), `name`, `address`, `latitude`, `longitude` (Dữ liệu PostGIS), `sequence_in_leg`.
* `arrival_time` (Dự kiến), `departure_time` (Dự kiến).
### 3.2. Tour & Phân quyền (RBAC)
Hệ thống sử dụng bảng trung gian để quản lý thành viên cho từng chuyến đi.
* `Tour`: `id`, `title`, `startDate`, `endDate`, `totalCost` (Decimal), `createdById` (FK -> User).
* `TourParticipant`: Quản lý vai trò thành viên trong Tour qua enum `ParticipantRole`:
* `OWNER`: Toàn quyền quản lý.
* `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í)
* `expenses`: Lưu vết chi phí cho từng địa điểm/chặng.
* `id` (PK), `leg_id` (FK), `place_id` (FK, nullable), `category` (`LODGING`, `DINING`, `TRANSPORT`, `OTHER`), `amount` (Số tiền), `currency`, `description`.
* `tour_cost_summaries`: Cấu hình phân chia tin ở cuối tour/chặng.
* `tour_id` (FK), `total_cost`, `adult_count`, `child_count`, `child_discount_percent` (Ví dụ: trẻ em giảm 30%).
### 3.3. Lộ trình (Leg & Location)
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).
* `Leg`: `id`, `tourId` (FK), `sequence` (Thứ tự chặng), `note`.
* `Location`: Tích hợp tính năng theo dõi tiến độ (Thay thế khái niệm `Task` cũ).
* 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 độ)
* `tasks`: Các hoạt động cần tích chọn hoàn thành.
* `id` (PK), `tour_id` (FK), `leg_id` (FK), `title`, `planned_timestamp`.
* `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.4. Chi phí (Expense)
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ể.
* `Expense`: `id` (UUID, PK), `leg_id` (UUID, FK), `location_id` (UUID, FK, Nullable), `category` (Enum: `ACCOMMODATION`, `FOOD`, `TRANSPORT`, `TICKET`, `OTHER`), `amount` (Decimal), `description` (Text).
### 3.6. Album Ảnh (Tách biệt logic kế hoạch)
* `photos`: Lưu trữ hình ảnh của tour gắn với địa điểm.
* `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`).
### 3.5. Album Ảnh (Photo)
Lưu trữ tài nguyên đa phương tiện gắn với bối cảnh chuyến đi.
* `Photo`: `id`, `tourId` (FK), `locationId` (FK, nullable), `uploaderId` (FK), `imageUrl`, `privacy`.
* Cấp độ bảo mật (`PrivacyLevel`): `PUBLIC`, `TOUR_ONLY`, `PRIVATE`.
---
+100 -10
View File
@@ -1,6 +1,6 @@
import React from 'react';
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';
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 = () => {
const { legs } = useTourStore();
const { legs, optimizeRouting, userRole } = useTourStore();
const toggleComplete = async (locationId: string) => {
// 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>
<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">
{/* Leg Header */}
<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">
Chặng {leg.sequence}
</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>
{/* 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="space-y-6 ml-2">
{leg.locations.map((location) => (
<div key={location.id} className="relative flex group">
{/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4">
<div className="ml-2">
{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; // 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
onClick={() => toggleComplete(location.id)}
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" />
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
</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 className="text-right flex flex-col items-end">
@@ -94,7 +164,26 @@ export const ItineraryTimeline = () => {
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
</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>
{leg.notes && (
@@ -103,7 +192,8 @@ export const ItineraryTimeline = () => {
</p>
)}
</div>
))}
);
})}
</div>
</div>
);
+37 -3
View File
@@ -1,6 +1,6 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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';
const TimeVariance = ({ planned, actual }) => {
if (!actual)
@@ -9,11 +9,45 @@ const TimeVariance = ({ planned, actual }) => {
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` })] }));
};
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 = () => {
const { legs } = useTourStore();
const { legs, optimizeRouting, userRole } = useTourStore();
const toggleComplete = async (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
+1 -1
View File
File diff suppressed because one or more lines are too long
+66 -1
View File
@@ -157,6 +157,71 @@ TourController = __decorate([
Controller('v1/tours'),
__metadata("design:paramtypes", [PrismaService])
], 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 {
constructor(prisma) {
this.prisma = prisma;
@@ -240,7 +305,7 @@ AppModule = __decorate([
signOptions: { expiresIn: '1d' },
}),
],
controllers: [AppController, AuthController, TourController, UserController],
controllers: [AppController, AuthController, TourController, UserController, RoutingController],
providers: [PrismaService, JwtStrategy],
exports: [PrismaService]
})
+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
+1 -1
View File
@@ -5,7 +5,7 @@ interface TourState {
userRole: string | null;
setTour: (tour: any) => void;
updateLegs: (legs: any[]) => void;
optimizeRouting: () => Promise<void>;
optimizeRouting: (legId: string) => Promise<void>;
fetchTour: (id: string) => Promise<void>;
fetchPublicTours: () => Promise<void>;
}
+11 -1
View File
@@ -19,7 +19,17 @@ export const useTourStore = create((set, get) => ({
const data = await response.json();
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
+1 -1
View File
@@ -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"}
+80 -1
View File
@@ -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')
@UseGuards(JwtAuthGuard, AdminGuard)
class UserController {
@@ -177,7 +256,7 @@ class UserController {
signOptions: { expiresIn: '1d' },
}),
],
controllers: [AppController, AuthController, TourController, UserController],
controllers: [AppController, AuthController, TourController, UserController, RoutingController],
providers: [PrismaService, JwtStrategy],
exports: [PrismaService]
})
+2 -3
View File
@@ -123,11 +123,10 @@ model Location {
model Expense {
id String @id @default(uuid())
legId String
locationId String?
legId String @map("leg_id")
locationId String? @map("location_id")
category ExpenseCategory
amount Decimal @db.Decimal(15, 2)
currency String @default("VND")
description String? @db.Text
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
+15 -3
View File
@@ -7,7 +7,7 @@ interface TourState {
userRole: string | null;
setTour: (tour: any) => void;
updateLegs: (legs: any[]) => void;
optimizeRouting: () => Promise<void>;
optimizeRouting: (legId: string) => Promise<void>;
fetchTour: (id: string) => Promise<void>;
fetchPublicTours: () => Promise<void>;
}
@@ -34,7 +34,19 @@ export const useTourStore = create<TourState>((set, get) => ({
const data = await response.json();
set({ publicTours: data });
},
optimizeRouting: async () => {
// Logic gọi API /api/v1/routing/optimize và cập nhật lại state
optimizeRouting: async (legId: string) => {
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 });
}
},
}));