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
+117 -16
View File
@@ -189,6 +189,9 @@ let TourRoleGuard = class TourRoleGuard {
if (!rolesToCheck.some(r => role === r)) { 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.'); 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; 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; return loc;
}); });
} }
@@ -432,6 +440,13 @@ let TourController = class TourController {
legId: firstLeg.id, legId: firstLeg.id,
plannedStart: new Date(0), 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) { async updateTourEndPoint(tourId, body, req) {
@@ -458,6 +473,13 @@ let TourController = class TourController {
legId: lastLeg.id, legId: lastLeg.id,
plannedEnd: new Date(0), 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) { async initializeLegs(tourId, body) {
@@ -491,6 +513,11 @@ let TourController = class TourController {
data: { legId: lastLeg.id } 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; return allLegs;
} }
async addLeg(tourId, body) { async addLeg(tourId, body) {
@@ -506,6 +533,13 @@ let TourController = class TourController {
sequence: tour.legs.length + 1, sequence: tour.legs.length + 1,
note: body.note || `Chặng ${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) { async updateTour(id, body) {
@@ -521,6 +555,13 @@ let TourController = class TourController {
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined, childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : 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) { async deleteTour(id) {
@@ -1013,10 +1054,11 @@ TourController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object]) __metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
], TourController); ], TourController);
let LocationController = class LocationController { let LocationController = class LocationController {
constructor(prisma) { constructor(prisma, cacheManager) {
this.prisma = prisma; this.prisma = prisma;
this.cacheManager = cacheManager;
} }
async updateLocation(id, body) { async updateLocation(id, body, req) {
const { expenseAmount, expenseCategory, ...data } = body; const { expenseAmount, expenseCategory, ...data } = body;
const location = await this.prisma.location.update({ const location = await this.prisma.location.update({
where: { id }, 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; return location;
} }
async deleteLocation(id) { async deleteLocation(id, req) {
await this.prisma.location.delete({ where: { id } }); 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 }; return { success: true };
} }
}; };
__decorate([ __decorate([
(0, common_1.Patch)(':id'), (0, common_1.Patch)(':id'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function), __metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object]), __metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], LocationController.prototype, "updateLocation", null); ], LocationController.prototype, "updateLocation", null);
__decorate([ __decorate([
(0, common_1.Delete)(':id'), (0, common_1.Delete)(':id'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function), __metadata("design:type", Function),
__metadata("design:paramtypes", [String]), __metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], LocationController.prototype, "deleteLocation", null); ], LocationController.prototype, "deleteLocation", null);
LocationController = __decorate([ LocationController = __decorate([
(0, common_1.Controller)('locations'), (0, common_1.Controller)('locations'),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (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); ], LocationController);
let LegController = class LegController { let LegController = class LegController {
constructor(prisma) { constructor(prisma, cacheManager) {
this.prisma = prisma; this.prisma = prisma;
this.cacheManager = cacheManager;
} }
async updateLeg(id, body) { async updateLeg(id, body, req) {
return this.prisma.leg.update({ return this.prisma.leg.update({
where: { id }, where: { id },
data: { data: {
@@ -1095,6 +1162,13 @@ let LegController = class LegController {
endDate: body.endDate ? new Date(body.endDate) : undefined, endDate: body.endDate ? new Date(body.endDate) : undefined,
description: body.description, 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) { async deleteLeg(id) {
@@ -1105,20 +1179,35 @@ let LegController = class LegController {
if (leg?._count.locations && leg._count.locations > 0) { 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.'); 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 }; return { success: true };
} }
}; };
__decorate([ __decorate([
(0, common_1.Patch)(':id'), (0, common_1.Patch)(':id'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()), __param(1, (0, common_1.Body)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function), __metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object]), __metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], LegController.prototype, "updateLeg", null); ], LegController.prototype, "updateLeg", null);
__decorate([ __decorate([
(0, common_1.Delete)(':id'), (0, common_1.Delete)(':id'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__metadata("design:type", Function), __metadata("design:type", Function),
__metadata("design:paramtypes", [String]), __metadata("design:paramtypes", [String]),
@@ -1128,7 +1217,8 @@ LegController = __decorate([
(0, common_1.Controller)('legs'), (0, common_1.Controller)('legs'),
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (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); ], LegController);
function calculateDistance(lat1, lon1, lat2, lon2) { function calculateDistance(lat1, lon1, lat2, lon2) {
const p = 0.017453292519943295; const p = 0.017453292519943295;
@@ -1139,10 +1229,11 @@ function calculateDistance(lat1, lon1, lat2, lon2) {
return 12742 * Math.asin(Math.sqrt(a)); return 12742 * Math.asin(Math.sqrt(a));
} }
let RoutingController = class RoutingController { let RoutingController = class RoutingController {
constructor(prisma) { constructor(prisma, cacheManager) {
this.prisma = prisma; this.prisma = prisma;
this.cacheManager = cacheManager;
} }
async optimize(legId) { async optimize(legId, req) {
const currentLeg = await this.prisma.leg.findUnique({ const currentLeg = await this.prisma.leg.findUnique({
where: { id: legId }, where: { id: legId },
}); });
@@ -1214,6 +1305,13 @@ let RoutingController = class RoutingController {
where: { legId }, where: { legId },
orderBy: { plannedStart: 'asc' } 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 { return {
locations: updatedLocations, locations: updatedLocations,
totalDistance: parseFloat(totalDistance.toFixed(2)) totalDistance: parseFloat(totalDistance.toFixed(2))
@@ -1222,16 +1320,19 @@ let RoutingController = class RoutingController {
}; };
__decorate([ __decorate([
(0, common_1.Post)('optimize/:legId'), (0, common_1.Post)('optimize/:legId'),
(0, common_1.UseGuards)(TourRoleGuard),
__param(0, (0, common_1.Param)('legId', common_1.ParseUUIDPipe)), __param(0, (0, common_1.Param)('legId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function), __metadata("design:type", Function),
__metadata("design:paramtypes", [String]), __metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], RoutingController.prototype, "optimize", null); ], RoutingController.prototype, "optimize", null);
RoutingController = __decorate([ RoutingController = __decorate([
(0, common_1.Controller)('routing'), (0, common_1.Controller)('routing'),
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (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); ], RoutingController);
let PhotoController = class PhotoController { let PhotoController = class PhotoController {
constructor(prisma) { constructor(prisma) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+112 -11
View File
@@ -178,6 +178,11 @@ export class TourRoleGuard implements CanActivate {
if (!rolesToCheck.some(r => role === r)) { 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.'); 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; 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'); 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({ return this.prisma.location.create({
// ...
data: { data: {
name: body.name, name: body.name,
address: body.address, 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, legId: firstLeg.id,
plannedStart: new Date(0), 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, legId: lastLeg.id,
plannedEnd: new Date(0), // Đánh dấu đây là điểm kết thúc đặc biệt 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 @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add legs
@@ -514,6 +546,13 @@ class TourController {
sequence: tour.legs.length + 1, sequence: tour.legs.length + 1,
note: body.note || `Chặng ${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, childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : 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') @Controller('locations')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
class LocationController { class LocationController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {}
@Patch(':id') @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 { expenseAmount, expenseCategory, ...data } = body;
const location = await this.prisma.location.update({ 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; return location;
} }
@Delete(':id') @Delete(':id')
async deleteLocation(@Param('id', ParseUUIDPipe) id: string) { @UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
await this.prisma.location.delete({ where: { id } }); 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 }; return { success: true };
} }
} }
@@ -996,10 +1066,11 @@ class LocationController {
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Leg operations @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Leg operations
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
class LegController { class LegController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {}
@Patch(':id') @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({ return this.prisma.leg.update({
where: { id }, where: { id },
data: { data: {
@@ -1009,10 +1080,18 @@ class LegController {
endDate: body.endDate ? new Date(body.endDate) : undefined, endDate: body.endDate ? new Date(body.endDate) : undefined,
description: body.description, 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') @Delete(':id')
@UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId
async deleteLeg(@Param('id', ParseUUIDPipe) id: string) { async deleteLeg(@Param('id', ParseUUIDPipe) id: string) {
const leg = await this.prisma.leg.findUnique({ const leg = await this.prisma.leg.findUnique({
where: { id }, 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.'); 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 }; 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 @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Routing operations
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
class RoutingController { class RoutingController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {}
@Post('optimize/:legId') @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({ const currentLeg = await this.prisma.leg.findUnique({
where: { id: legId }, where: { id: legId },
}); });
@@ -1148,6 +1242,13 @@ class RoutingController {
orderBy: { plannedStart: 'asc' } 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 { return {
locations: updatedLocations, locations: updatedLocations,
totalDistance: parseFloat(totalDistance.toFixed(2)) 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({ const [formData, setFormData] = useState({
name: '', name: '',
address: '', address: '',
@@ -294,6 +294,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} else { } else {
await addLocation(tourId, payload); 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(); onClose();
} catch (error) { } catch (error) {
notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' }); notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
+55 -33
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 { 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 { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm'; import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
@@ -41,8 +41,16 @@ const formatTravelTime = (minutes: number) => {
export const ItineraryTimeline = ({ export const ItineraryTimeline = ({
onAddLocation, onAddLocation,
onEditLocation, onEditLocation,
onQuickNote,
onSuccess,
isPublicView = false 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 // 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 currentTour = useTourStore(state => state.currentTour);
const legs = useTourStore(state => state.legs); const legs = useTourStore(state => state.legs);
@@ -160,6 +168,7 @@ export const ItineraryTimeline = ({
if (isConfirmed) { if (isConfirmed) {
try { try {
await deleteLeg(legId); await deleteLeg(legId);
onSuccess?.();
} catch (err: any) { } catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' }); notify({ title: 'Lỗi', message: err.message, type: 'error' });
} }
@@ -174,12 +183,20 @@ export const ItineraryTimeline = ({
if (isConfirmed) { if (isConfirmed) {
try { try {
await deleteLocation(id); await deleteLocation(id);
onSuccess?.();
} catch (err: any) { } catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' }); 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 ( return (
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen"> <div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
<div className="px-2 pt-4"> <div className="px-2 pt-4">
@@ -285,14 +302,13 @@ export const ItineraryTimeline = ({
<div className="ml-2"> <div className="ml-2">
{leg.locations.map((location, idx) => { {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 // Tìm vị trí của điểm này trong toàn bộ hành trình
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0]; const globalIdx = allLocations.findIndex(loc => loc.id === location.id);
const distanceToNext = nextLocation const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
: null;
const averageSpeed = 35; // km/h const distanceFromPrev = prevLocation
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null; ? calculateDistance(prevLocation.latitude, prevLocation.longitude, location.latitude, location.longitude)
: null;
const dwellMinutes = (location.plannedStart && location.plannedEnd) const dwellMinutes = (location.plannedStart && location.plannedEnd)
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart)) ? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
@@ -370,17 +386,28 @@ export const ItineraryTimeline = ({
</div> </div>
<div className="text-right flex flex-col items-end"> <div className="text-right flex flex-col items-end">
<button <div className="flex gap-1 mb-2">
onClick={() => { {onQuickNote && !isPublicView && (
setCommentLocationId(location.id); <button
setCommentLocationName(location.name); onClick={() => onQuickNote(location.name)}
setIsCommentModalOpen(true); 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"
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" >
> <FileText className="w-3 h-3" />
<MessageSquare className="w-3 h-3" /> </button>
BÌNH LUẬN {location._count?.comments > 0 && `(${location._count.comments})`} )}
</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"
>
<MessageSquare className="w-3 h-3" />
{location._count?.comments > 0 && `(${location._count.comments})`}
</button>
</div>
<div className="flex items-center text-sm font-black text-blue-600"> <div className="flex items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" /> <Clock className="w-3 h-3 mr-1" />
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'} {location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
@@ -408,18 +435,13 @@ export const ItineraryTimeline = ({
</div> </div>
</div> </div>
{distanceToNext !== null && travelTimeMinutes !== null && ( {/* Hiển thị quãng đường di chuyển từ điểm trước ĐẾN điểm hiện tại */}
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3"> {distanceFromPrev !== null && distanceFromPrev > 0 && (
<div className="w-8 flex justify-center"> <div className="ml-14 -mt-4 mb-6 flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-500">
<Navigation className="w-3 h-3 text-blue-400 rotate-180" /> <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">
</div> <Navigation className="w-3 h-3 rotate-45" />
<div className="flex items-center gap-2"> <span className="text-[10px] font-black uppercase tracking-tighter">
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100"> +{distanceFromPrev.toFixed(1)} km từ {prevLocation?.name.split(',')[0]}
{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> </span>
</div> </div>
</div> </div>
+15
View File
@@ -479,6 +479,21 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
onSuccess={(tour) => { 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); fetchTour(tour.id);
onViewTour(tour.id); onViewTour(tour.id);
}} }}
+147 -45
View File
@@ -51,27 +51,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', 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({ * Helper tính khoảng cách giữa 2 tọa độ (Haversine formula)
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>`, function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
iconSize: [24, 24], const p = 0.017453292519943295; // Math.PI / 180
iconAnchor: [12, 12] const c = Math.cos;
}); const a = 0.5 - c((lat2 - lat1) * p) / 2 +
c(lat1 * p) * c(lat2 * p) *
const END_ICON = L.divIcon({ (1 - c((lon2 - lon1) * p)) / 2;
className: 'custom-marker-e', return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
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]
});
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình // 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[] }) => { const MapTourBounds = ({ locations }: { locations: any[] }) => {
@@ -190,6 +180,28 @@ export const TourDetailPage = ({
const userRole = useTourStore(state => state.userRole); const userRole = useTourStore(state => state.userRole);
const mapCenter = useTourStore(state => state.mapCenter); 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 [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline'); const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false); 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' }); 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 // 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 startPoint = useMemo(() =>
const lastLeg = legs[legs.length - 1]; legs.flatMap(l => l.locations).find(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0),
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1]; [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 // Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
const tabs = [ const tabs = [
@@ -938,7 +1003,11 @@ export const TourDetailPage = ({
setTargetLegId(loc.legId); setTargetLegId(loc.legId);
setMapCenter([loc.latitude, loc.longitude]); setMapCenter([loc.latitude, loc.longitude]);
setIsAddLocationOpen(true); 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"> <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 */} {/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
@@ -1011,30 +1080,59 @@ export const TourDetailPage = ({
/> />
)} )}
<MarkerClusterGroup chunkedLoading> <MarkerClusterGroup key={`cluster-group-${allLocations.length}`} chunkedLoading>
{legs.flatMap(l => l.locations).map((loc: any) => { {allLocations.map((loc: any, index: number) => {
const isStart = startPoint?.id === loc.id; const isStart = startPoint && startPoint.id === loc.id;
const isEnd = endPoint?.id === loc.id; const isEnd = endPoint && 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; // 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 ( 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> <Popup>
<div className="p-1"> <div className="p-1">
<div className="font-bold text-gray-900">{loc.name}</div> <div className="font-bold text-gray-900 leading-tight mb-0.5">{loc.name}</div>
<div className="text-[10px] text-gray-500 mb-2 uppercase tracking-tight">{loc.type}</div> <div className="text-[10px] text-gray-400 mb-2 uppercase tracking-widest">{loc.type}</div>
<button
onClick={() => { <div className="flex flex-col gap-1">
setCommentLocationId(loc.id); <button
setCommentLocationName(loc.name); onClick={() => handleQuickNote(loc.name)}
setIsCommentModalOpen(true); 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"
}} >
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100" <FileText className="w-3 h-3" />
> GHI CHÚ NHANH
<MessageSquare className="w-3 h-3" /> </button>
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`} <button
</button> onClick={() => {
setCommentLocationId(loc.id);
setCommentLocationName(loc.name);
setIsCommentModalOpen(true);
}}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100"
>
<MessageSquare className="w-3 h-3" />
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> </div>
</Popup> </Popup>
</Marker> </Marker>
@@ -1410,6 +1508,10 @@ export const TourDetailPage = ({
editingLocation={editingLocation} editingLocation={editingLocation}
tourId={currentTour.id} tourId={currentTour.id}
isPublicView={isPublicView} // Pass isPublicView isPublicView={isPublicView} // Pass isPublicView
onSuccess={() => {
console.log("TourDetailPage: AddLocationModal onSuccess -> Re-fetching tour data.");
fetchTour(tourId); // Re-fetch tour data after adding/editing location
}}
/> />
)} )}