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
+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"}