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:
Vendored
+116
-15
@@ -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) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+111
-10
@@ -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))
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user