fix: sửa tính năng hiển thị quãng đường tại điểm đến kế tiếp

This commit is contained in:
2026-06-17 15:37:18 +07:00
parent 7ad785fed9
commit c5474f1ff6
7 changed files with 450 additions and 107 deletions
+116 -15
View File
@@ -189,6 +189,9 @@ let TourRoleGuard = class TourRoleGuard {
if (!rolesToCheck.some(r => role === r)) {
throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này trong tour này.');
}
if (tourId) {
request.tourId = tourId;
}
return true;
}
};
@@ -405,6 +408,11 @@ let TourController = class TourController {
}
});
}
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return loc;
});
}
@@ -432,6 +440,13 @@ let TourController = class TourController {
legId: firstLeg.id,
plannedStart: new Date(0),
}
}).then(async (loc) => {
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return loc;
});
}
async updateTourEndPoint(tourId, body, req) {
@@ -458,6 +473,13 @@ let TourController = class TourController {
legId: lastLeg.id,
plannedEnd: new Date(0),
}
}).then(async (loc) => {
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return loc;
});
}
async initializeLegs(tourId, body) {
@@ -491,6 +513,11 @@ let TourController = class TourController {
data: { legId: lastLeg.id }
});
}
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return allLegs;
}
async addLeg(tourId, body) {
@@ -506,6 +533,13 @@ let TourController = class TourController {
sequence: tour.legs.length + 1,
note: body.note || `Chặng ${tour.legs.length + 1}`
}
}).then(async (leg) => {
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return leg;
});
}
async updateTour(id, body) {
@@ -521,6 +555,13 @@ let TourController = class TourController {
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
},
}).then(async (tour) => {
await Promise.all([
this.cacheManager.del(id),
this.cacheManager.del(`/api/v1/tours/${id}`),
this.cacheManager.del(`/api/v1/tours/${id}/public`),
]);
return tour;
});
}
async deleteTour(id) {
@@ -1013,10 +1054,11 @@ TourController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
], TourController);
let LocationController = class LocationController {
constructor(prisma) {
constructor(prisma, cacheManager) {
this.prisma = prisma;
this.cacheManager = cacheManager;
}
async updateLocation(id, body) {
async updateLocation(id, body, req) {
const { expenseAmount, expenseCategory, ...data } = body;
const location = await this.prisma.location.update({
where: { id },
@@ -1054,38 +1096,63 @@ let LocationController = class LocationController {
});
}
}
if (req.tourId) {
await Promise.all([
this.cacheManager.del(req.tourId),
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
]);
}
return location;
}
async deleteLocation(id) {
async deleteLocation(id, req) {
try {
await this.prisma.location.delete({ where: { id } });
}
catch (e) {
}
await this.cacheManager.del(`res-to-tour:${id}`);
if (req.tourId) {
await Promise.all([
this.cacheManager.del(req.tourId),
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
]);
}
return { success: true };
}
};
__decorate([
(0, common_1.Patch)(':id'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object]),
__metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise)
], LocationController.prototype, "updateLocation", null);
__decorate([
(0, common_1.Delete)(':id'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String]),
__metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise)
], LocationController.prototype, "deleteLocation", null);
LocationController = __decorate([
(0, common_1.Controller)('locations'),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
__param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
], LocationController);
let LegController = class LegController {
constructor(prisma) {
constructor(prisma, cacheManager) {
this.prisma = prisma;
this.cacheManager = cacheManager;
}
async updateLeg(id, body) {
async updateLeg(id, body, req) {
return this.prisma.leg.update({
where: { id },
data: {
@@ -1095,6 +1162,13 @@ let LegController = class LegController {
endDate: body.endDate ? new Date(body.endDate) : undefined,
description: body.description,
}
}).then(async (leg) => {
await Promise.all([
this.cacheManager.del(req.tourId),
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
]);
return leg;
});
}
async deleteLeg(id) {
@@ -1105,20 +1179,35 @@ let LegController = class LegController {
if (leg?._count.locations && leg._count.locations > 0) {
throw new common_1.BadRequestException('Không thể xóa chặng đang có địa điểm. Hãy xóa địa điểm trước.');
}
await this.prisma.leg.delete({ where: { id } });
try {
const deletedLeg = await this.prisma.leg.delete({ where: { id } });
await this.cacheManager.del(`res-to-tour:${id}`);
if (deletedLeg.tourId) {
await Promise.all([
this.cacheManager.del(deletedLeg.tourId),
this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}`),
this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}/public`),
]);
}
}
catch (e) {
}
return { success: true };
}
};
__decorate([
(0, common_1.Patch)(':id'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object]),
__metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise)
], LegController.prototype, "updateLeg", null);
__decorate([
(0, common_1.Delete)(':id'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String]),
@@ -1128,7 +1217,8 @@ LegController = __decorate([
(0, common_1.Controller)('legs'),
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
__param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
], LegController);
function calculateDistance(lat1, lon1, lat2, lon2) {
const p = 0.017453292519943295;
@@ -1139,10 +1229,11 @@ function calculateDistance(lat1, lon1, lat2, lon2) {
return 12742 * Math.asin(Math.sqrt(a));
}
let RoutingController = class RoutingController {
constructor(prisma) {
constructor(prisma, cacheManager) {
this.prisma = prisma;
this.cacheManager = cacheManager;
}
async optimize(legId) {
async optimize(legId, req) {
const currentLeg = await this.prisma.leg.findUnique({
where: { id: legId },
});
@@ -1214,6 +1305,13 @@ let RoutingController = class RoutingController {
where: { legId },
orderBy: { plannedStart: 'asc' }
});
if (req.tourId) {
await Promise.all([
this.cacheManager.del(req.tourId),
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
]);
}
return {
locations: updatedLocations,
totalDistance: parseFloat(totalDistance.toFixed(2))
@@ -1222,16 +1320,19 @@ let RoutingController = class RoutingController {
};
__decorate([
(0, common_1.Post)('optimize/:legId'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('legId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String]),
__metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise)
], RoutingController.prototype, "optimize", null);
RoutingController = __decorate([
(0, common_1.Controller)('routing'),
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
__param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
], RoutingController);
let PhotoController = class PhotoController {
constructor(prisma) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+111 -10
View File
@@ -178,6 +178,11 @@ export class TourRoleGuard implements CanActivate {
if (!rolesToCheck.some(r => role === r)) {
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này trong tour này.');
}
// Ensure tourId is attached to the request object for controllers to use
if (tourId) {
(request as any).tourId = tourId;
}
return true;
}
}
@@ -350,6 +355,7 @@ class TourController {
if (!leg) throw new NotFoundException('Không tìm thấy chặng phù hợp để thêm địa điểm');
return this.prisma.location.create({
// ...
data: {
name: body.name,
address: body.address,
@@ -374,7 +380,13 @@ class TourController {
}
});
}
return loc;
// Xóa triệt để các loại cache của Tour (cả key UUID và key URL của Interceptor)
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return loc; // Return the created location
});
}
@@ -413,6 +425,13 @@ class TourController {
legId: firstLeg.id,
plannedStart: new Date(0),
}
}).then(async (loc) => {
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return loc;
});
}
@@ -449,6 +468,13 @@ class TourController {
legId: lastLeg.id,
plannedEnd: new Date(0), // Đánh dấu đây là điểm kết thúc đặc biệt
}
}).then(async (loc) => {
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return loc;
});
}
@@ -495,7 +521,13 @@ class TourController {
});
}
return allLegs;
// Invalidate cache for the tour after initializing legs
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return allLegs; // Return all legs
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add legs
@@ -514,6 +546,13 @@ class TourController {
sequence: tour.legs.length + 1,
note: body.note || `Chặng ${tour.legs.length + 1}`
}
}).then(async (leg) => {
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return leg;
});
}
@@ -534,6 +573,13 @@ class TourController {
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
},
}).then(async (tour) => {
await Promise.all([
this.cacheManager.del(id),
this.cacheManager.del(`/api/v1/tours/${id}`),
this.cacheManager.del(`/api/v1/tours/${id}/public`),
]);
return tour;
});
}
@@ -938,10 +984,11 @@ class TourController {
@Controller('locations')
@UseGuards(JwtAuthGuard)
class LocationController {
constructor(private prisma: PrismaService) {}
constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {}
@Patch(':id')
async updateLocation(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
async updateLocation(@Param('id', ParseUUIDPipe) id: string, @Body() body: any, @Req() req: any) {
const { expenseAmount, expenseCategory, ...data } = body;
const location = await this.prisma.location.update({
@@ -982,12 +1029,35 @@ class LocationController {
}
}
if (req.tourId) {
await Promise.all([
this.cacheManager.del(req.tourId),
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
]);
}
return location;
}
@Delete(':id')
async deleteLocation(@Param('id', ParseUUIDPipe) id: string) {
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
async deleteLocation(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
try {
await this.prisma.location.delete({ where: { id } });
} catch (e) {
// Nếu bản ghi đã bị xóa trước đó, không ném lỗi 500 để đảm bảo tính an toàn (idempotency)
}
// Xóa mapping cache để Guard không bị đánh lừa ở lần truy cập sau
await this.cacheManager.del(`res-to-tour:${id}`);
if (req.tourId) {
await Promise.all([
this.cacheManager.del(req.tourId),
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
]);
}
return { success: true };
}
}
@@ -996,10 +1066,11 @@ class LocationController {
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Leg operations
@UseGuards(JwtAuthGuard, TourRoleGuard)
class LegController {
constructor(private prisma: PrismaService) {}
constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {}
@Patch(':id')
async updateLeg(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
async updateLeg(@Param('id', ParseUUIDPipe) id: string, @Body() body: any, @Req() req: any) {
return this.prisma.leg.update({
where: { id },
data: {
@@ -1009,10 +1080,18 @@ class LegController {
endDate: body.endDate ? new Date(body.endDate) : undefined,
description: body.description,
}
}).then(async (leg) => {
await Promise.all([
this.cacheManager.del(req.tourId),
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
]);
return leg;
});
}
@Delete(':id')
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
async deleteLeg(@Param('id', ParseUUIDPipe) id: string) {
const leg = await this.prisma.leg.findUnique({
where: { id },
@@ -1023,7 +1102,21 @@ class LegController {
throw new BadRequestException('Không thể xóa chặng đang có địa điểm. Hãy xóa địa điểm trước.');
}
await this.prisma.leg.delete({ where: { id } });
try {
const deletedLeg = await this.prisma.leg.delete({ where: { id } });
await this.cacheManager.del(`res-to-tour:${id}`);
if (deletedLeg.tourId) {
await Promise.all([
this.cacheManager.del(deletedLeg.tourId),
this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}`),
this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}/public`),
]);
}
} catch (e) {
// Idempotency
}
return { success: true };
}
}
@@ -1044,10 +1137,11 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Routing operations
@UseGuards(JwtAuthGuard, TourRoleGuard)
class RoutingController {
constructor(private prisma: PrismaService) {}
constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {}
@Post('optimize/:legId')
async optimize(@Param('legId', ParseUUIDPipe) legId: string) {
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
async optimize(@Param('legId', ParseUUIDPipe) legId: string, @Req() req: any) {
const currentLeg = await this.prisma.leg.findUnique({
where: { id: legId },
});
@@ -1148,6 +1242,13 @@ class RoutingController {
orderBy: { plannedStart: 'asc' }
});
if (req.tourId) {
await Promise.all([
this.cacheManager.del(req.tourId),
this.cacheManager.del(`/api/v1/tours/${req.tourId}`),
this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`),
]);
}
return {
locations: updatedLocations,
totalDistance: parseFloat(totalDistance.toFixed(2))
+3 -1
View File
@@ -61,7 +61,7 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
);
};
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isPublicView = false }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isPublicView?: boolean }) => {
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isPublicView = false, onSuccess }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isPublicView?: boolean, onSuccess?: () => void }) => {
const [formData, setFormData] = useState({
name: '',
address: '',
@@ -294,6 +294,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} else {
await addLocation(tourId, payload);
}
notify({ title: 'Thành công', message: editingLocation ? 'Đã cập nhật địa điểm.' : 'Đã thêm địa điểm mới.', type: 'success' });
onSuccess?.(); // Gọi callback onSuccess sau khi thành công
onClose();
} catch (error) {
notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
+46 -24
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare } from 'lucide-react';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
@@ -41,8 +41,16 @@ const formatTravelTime = (minutes: number) => {
export const ItineraryTimeline = ({
onAddLocation,
onEditLocation,
onQuickNote,
onSuccess,
isPublicView = false
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void, isPublicView?: boolean }) => {
}: {
onAddLocation?: (legId: string) => void,
onEditLocation?: (location: any) => void,
onQuickNote?: (name: string) => void,
onSuccess?: () => void,
isPublicView?: boolean
}) => {
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
const currentTour = useTourStore(state => state.currentTour);
const legs = useTourStore(state => state.legs);
@@ -160,6 +168,7 @@ export const ItineraryTimeline = ({
if (isConfirmed) {
try {
await deleteLeg(legId);
onSuccess?.();
} catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' });
}
@@ -174,12 +183,20 @@ export const ItineraryTimeline = ({
if (isConfirmed) {
try {
await deleteLocation(id);
onSuccess?.();
} catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' });
}
}
};
// Tạo mảng phẳng tất cả địa điểm để tính toán quãng đường liên tục giữa các chặng
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
useEffect(() => {
console.log("ItineraryTimeline: Legs updated", legs);
}, [legs]);
return (
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
<div className="px-2 pt-4">
@@ -285,14 +302,13 @@ export const ItineraryTimeline = ({
<div className="ml-2">
{leg.locations.map((location, idx) => {
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
const distanceToNext = nextLocation
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
: null;
// Tìm vị trí của điểm này trong toàn bộ hành trình
const globalIdx = allLocations.findIndex(loc => loc.id === location.id);
const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
const averageSpeed = 35; // km/h
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
const distanceFromPrev = prevLocation
? calculateDistance(prevLocation.latitude, prevLocation.longitude, location.latitude, location.longitude)
: null;
const dwellMinutes = (location.plannedStart && location.plannedEnd)
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
@@ -370,17 +386,28 @@ export const ItineraryTimeline = ({
</div>
<div className="text-right flex flex-col items-end">
<div className="flex gap-1 mb-2">
{onQuickNote && !isPublicView && (
<button
onClick={() => onQuickNote(location.name)}
className="flex items-center gap-1 px-2 py-1 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-bold transition-all border border-amber-100"
title="Ghi chú nhanh"
>
<FileText className="w-3 h-3" />
</button>
)}
<button
onClick={() => {
setCommentLocationId(location.id);
setCommentLocationName(location.name);
setIsCommentModalOpen(true);
}}
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100 mb-2"
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100"
>
<MessageSquare className="w-3 h-3" />
BÌNH LUẬN {location._count?.comments > 0 && `(${location._count.comments})`}
{location._count?.comments > 0 && `(${location._count.comments})`}
</button>
</div>
<div className="flex items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" />
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
@@ -408,18 +435,13 @@ export const ItineraryTimeline = ({
</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)}
{/* Hiển thị quãng đường di chuyển từ điểm trước ĐẾN điểm hiện tại */}
{distanceFromPrev !== null && distanceFromPrev > 0 && (
<div className="ml-14 -mt-4 mb-6 flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-500">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-xl border border-blue-100 shadow-sm">
<Navigation className="w-3 h-3 rotate-45" />
<span className="text-[10px] font-black uppercase tracking-tighter">
+{distanceFromPrev.toFixed(1)} km từ {prevLocation?.name.split(',')[0]}
</span>
</div>
</div>
+15
View File
@@ -479,6 +479,21 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={(tour) => {
// Tự động tạo ghi chú mới cho hành trình vừa tạo
const savedNotes = localStorage.getItem('my_journey_notes');
let notes = [];
try {
notes = savedNotes ? JSON.parse(savedNotes) : [];
} catch (e) { notes = []; }
const newTourNote = {
id: Date.now().toString(),
title: `Ghi chú của hành trình: ${tour.title}`,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${tour.title}</strong> của bạn tại đây...</p>`,
createdAt: new Date().toISOString()
};
localStorage.setItem('my_journey_notes', JSON.stringify([newTourNote, ...notes]));
fetchTour(tour.id);
onViewTour(tour.id);
}}
+136 -34
View File
@@ -51,27 +51,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
// Định nghĩa các static icons để ngăn chặn việc khởi tạo lại liên tục gây crash khi unmount
const START_ICON = L.divIcon({
className: 'custom-marker-s',
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const END_ICON = L.divIcon({
className: 'custom-marker-e',
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const VISIT_ICON = L.divIcon({
className: 'custom-marker-v',
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
/**
* 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
}
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
const MapTourBounds = ({ locations }: { locations: any[] }) => {
@@ -190,6 +180,28 @@ export const TourDetailPage = ({
const userRole = useTourStore(state => state.userRole);
const mapCenter = useTourStore(state => state.mapCenter);
// Định nghĩa Icons bên trong Component bằng useMemo để đảm bảo tính ổn định và tránh lỗi render
const mapIcons = useMemo(() => ({
start: L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-7 h-7 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">S</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
}),
end: L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-7 h-7 bg-green-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">E</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
}),
visit: L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-5 h-5 bg-indigo-500 rounded-full border-2 border-white shadow-lg hover:scale-125 transition-transform flex items-center justify-center"><div class="w-1.5 h-1.5 bg-white rounded-full opacity-50"></div></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
})
}), []);
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
@@ -594,10 +606,63 @@ export const TourDetailPage = ({
notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' });
}
};
// Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour
const handleQuickNote = (locationName: string) => {
if (isPublicView) return;
const content = window.prompt(`Ghi chú nhanh cho địa điểm: ${locationName}`);
if (!content || !content.trim()) return;
const storedUser = localStorage.getItem('user');
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
const userName = user.name || 'Thành viên';
const now = new Date().toLocaleString('vi-VN');
const noteTitle = `Ghi chú của hành trình: ${currentTour?.title}`;
const savedNotes = localStorage.getItem('my_journey_notes');
let notes = [];
try {
notes = savedNotes ? JSON.parse(savedNotes) : [];
} catch (e) { notes = []; }
let targetNote = notes.find((n: any) => n.title === noteTitle);
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp
const newContentLine = `
<div class="quick-note-box" style="border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; background: #fffbeb; border-radius: 8px; border: 1px solid #fef3c7; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} 👤 ${userName}</span>
</div>
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${locationName}:</strong> ${content}</p>
</div>
<p></p>
`;
if (targetNote) {
targetNote.content += newContentLine;
} else {
const newNote = {
id: Date.now().toString(),
title: noteTitle,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour?.title}</strong> của bạn tại đây...</p>` + newContentLine,
createdAt: new Date().toISOString()
};
notes.unshift(newNote);
}
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
};
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
const startPoint = legs[0]?.locations[0];
const lastLeg = legs[legs.length - 1];
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
const startPoint = useMemo(() =>
legs.flatMap(l => l.locations).find(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0),
[legs]
);
const endPoint = useMemo(() =>
legs.flatMap(l => l.locations).find(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0),
[legs]
);
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
const tabs = [
@@ -938,7 +1003,11 @@ export const TourDetailPage = ({
setTargetLegId(loc.legId);
setMapCenter([loc.latitude, loc.longitude]);
setIsAddLocationOpen(true);
}} isPublicView={isPublicView} />
}}
onQuickNote={(locName: string) => handleQuickNote(locName)}
onSuccess={() => fetchTour(tourId)}
isPublicView={isPublicView}
/>
) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
@@ -1011,19 +1080,35 @@ export const TourDetailPage = ({
/>
)}
<MarkerClusterGroup chunkedLoading>
{legs.flatMap(l => l.locations).map((loc: any) => {
const isStart = startPoint?.id === loc.id;
const isEnd = endPoint?.id === loc.id;
// Sử dụng các icon tĩnh đã định nghĩa ở trên
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
<MarkerClusterGroup key={`cluster-group-${allLocations.length}`} chunkedLoading>
{allLocations.map((loc: any, index: number) => {
const isStart = startPoint && startPoint.id === loc.id;
const isEnd = endPoint && endPoint.id === loc.id;
// Lấy icon tương ứng từ mapIcons memoized
const icon = isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit;
// Tính quãng đường từ điểm trước đó (A -> B) để hiển thị tại điểm B
const prevLoc = index > 0 ? allLocations[index - 1] : null;
const distanceToPrev = prevLoc
? calculateDistance(prevLoc.latitude, prevLoc.longitude, loc.latitude, loc.longitude).toFixed(1)
: null;
return (
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
<Marker key={`marker-${loc.id}-${isStart ? 'start' : isEnd ? 'end' : 'visit'}`} position={[loc.latitude, loc.longitude]} icon={icon}>
<Popup>
<div className="p-1">
<div className="font-bold text-gray-900">{loc.name}</div>
<div className="text-[10px] text-gray-500 mb-2 uppercase tracking-tight">{loc.type}</div>
<div className="font-bold text-gray-900 leading-tight mb-0.5">{loc.name}</div>
<div className="text-[10px] text-gray-400 mb-2 uppercase tracking-widest">{loc.type}</div>
<div className="flex flex-col gap-1">
<button
onClick={() => handleQuickNote(loc.name)}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-black transition-all border border-amber-100"
>
<FileText className="w-3 h-3" />
GHI CHÚ NHANH
</button>
<button
onClick={() => {
setCommentLocationId(loc.id);
@@ -1036,6 +1121,19 @@ export const TourDetailPage = ({
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`}
</button>
</div>
{distanceToPrev && distanceToPrev !== "0.0" && (
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-2 animate-in fade-in slide-in-from-bottom-1">
<div className="p-1.5 bg-blue-50 rounded-lg">
<Navigation className="w-3 h-3 text-blue-600 rotate-45" />
</div>
<div className="flex flex-col">
<span className="text-[9px] font-black text-blue-400 uppercase leading-none tracking-tighter mb-0.5">Khoảng cách từ chặng trước</span>
<span className="text-xs font-black text-blue-700">{distanceToPrev} km</span>
</div>
</div>
)}
</div>
</Popup>
</Marker>
);
@@ -1410,6 +1508,10 @@ export const TourDetailPage = ({
editingLocation={editingLocation}
tourId={currentTour.id}
isPublicView={isPublicView} // Pass isPublicView
onSuccess={() => {
console.log("TourDetailPage: AddLocationModal onSuccess -> Re-fetching tour data.");
fetchTour(tourId); // Re-fetch tour data after adding/editing location
}}
/>
)}