Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6551da147 | |||
| 2afb2971da | |||
| 14ffbb8657 | |||
| fbe5ef873b | |||
| d6ee1dde74 | |||
| 288b40ad12 | |||
| 3a3296c340 | |||
| e664a3797e | |||
| 36418673db | |||
| 98e4a4a340 | |||
| bfbbb66747 | |||
| 1933b58f84 | |||
| 05dc80bb6d | |||
| 4d63bff214 | |||
| c5474f1ff6 | |||
| 7ad785fed9 | |||
| e66f242c2c |
Vendored
+125
-19
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -341,7 +344,7 @@ let TourController = class TourController {
|
|||||||
}
|
}
|
||||||
async createTour(body, req) {
|
async createTour(body, req) {
|
||||||
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
||||||
return this.prisma.tour.create({
|
const tour = await this.prisma.tour.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -371,6 +374,8 @@ let TourController = class TourController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await this.cacheManager.del(`/api/v1/tours/explore`);
|
||||||
|
return tour;
|
||||||
}
|
}
|
||||||
async addLocation(tourId, body, req) {
|
async addLocation(tourId, body, req) {
|
||||||
const legId = body.legId;
|
const legId = body.legId;
|
||||||
@@ -405,11 +410,16 @@ 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async updateTourStartPoint(tourId, body, req) {
|
async updateTourStartPoint(tourId, body, req) {
|
||||||
const { latitude, longitude, name } = body;
|
const { latitude, longitude, name, plannedEnd } = body;
|
||||||
console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||||
await this.prisma.location.deleteMany({
|
await this.prisma.location.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -431,11 +441,19 @@ let TourController = class TourController {
|
|||||||
type: 'MOVE',
|
type: 'MOVE',
|
||||||
legId: firstLeg.id,
|
legId: firstLeg.id,
|
||||||
plannedStart: new Date(0),
|
plannedStart: new Date(0),
|
||||||
|
plannedEnd: plannedEnd ? new Date(plannedEnd) : null,
|
||||||
}
|
}
|
||||||
|
}).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) {
|
||||||
const { latitude, longitude, name } = body;
|
const { latitude, longitude, name, plannedStart } = body;
|
||||||
console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||||
await this.prisma.location.deleteMany({
|
await this.prisma.location.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -456,8 +474,16 @@ let TourController = class TourController {
|
|||||||
longitude,
|
longitude,
|
||||||
type: 'MOVE',
|
type: 'MOVE',
|
||||||
legId: lastLeg.id,
|
legId: lastLeg.id,
|
||||||
|
plannedStart: plannedStart ? new Date(plannedStart) : null,
|
||||||
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 +517,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 +537,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 +559,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) {
|
||||||
@@ -564,6 +609,7 @@ let TourController = class TourController {
|
|||||||
await this.prisma.tour.delete({
|
await this.prisma.tour.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
await this.cacheManager.del(`/api/v1/tours/explore`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
async getPublicTours(req) {
|
async getPublicTours(req) {
|
||||||
@@ -1013,10 +1059,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 +1101,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 +1167,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 +1184,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 +1222,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 +1234,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 +1310,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 +1325,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) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+123
-14
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +308,7 @@ class TourController {
|
|||||||
@Post()
|
@Post()
|
||||||
async createTour(@Body() body: any, @Req() req: any) {
|
async createTour(@Body() body: any, @Req() req: any) {
|
||||||
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
||||||
return this.prisma.tour.create({
|
const tour = await this.prisma.tour.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -333,6 +338,8 @@ class TourController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await this.cacheManager.del(`/api/v1/tours/explore`);
|
||||||
|
return tour;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER) // Allow members to add locations
|
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER) // Allow members to add locations
|
||||||
@@ -350,6 +357,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 +382,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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +396,7 @@ class TourController {
|
|||||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||||
@Post(':tourId/start-point')
|
@Post(':tourId/start-point')
|
||||||
async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||||
const { latitude, longitude, name } = body;
|
const { latitude, longitude, name, plannedEnd } = body;
|
||||||
|
|
||||||
console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||||
|
|
||||||
@@ -412,7 +426,15 @@ class TourController {
|
|||||||
type: 'MOVE',
|
type: 'MOVE',
|
||||||
legId: firstLeg.id,
|
legId: firstLeg.id,
|
||||||
plannedStart: new Date(0),
|
plannedStart: new Date(0),
|
||||||
|
plannedEnd: plannedEnd ? new Date(plannedEnd) : null,
|
||||||
}
|
}
|
||||||
|
}).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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +442,7 @@ class TourController {
|
|||||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||||
@Post(':tourId/end-point')
|
@Post(':tourId/end-point')
|
||||||
async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
|
||||||
const { latitude, longitude, name } = body;
|
const { latitude, longitude, name, plannedStart } = body;
|
||||||
|
|
||||||
console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`);
|
||||||
|
|
||||||
@@ -447,8 +469,16 @@ class TourController {
|
|||||||
longitude,
|
longitude,
|
||||||
type: 'MOVE',
|
type: 'MOVE',
|
||||||
legId: lastLeg.id,
|
legId: lastLeg.id,
|
||||||
|
plannedStart: plannedStart ? new Date(plannedStart) : null,
|
||||||
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 +525,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 +550,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 +577,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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,6 +645,10 @@ class TourController {
|
|||||||
await this.prisma.tour.delete({
|
await this.prisma.tour.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Xóa cache explore để Tour biến mất ngay lập tức trên bản đồ cộng đồng
|
||||||
|
await this.cacheManager.del(`/api/v1/tours/explore`);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,10 +992,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 +1037,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 +1074,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 +1088,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 +1110,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 +1145,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 +1250,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))
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-leaflet-cluster": "^2.1.0",
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
|
"react-quill": "^2.0.0",
|
||||||
|
"react-quill-new": "^3.8.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ExploreMap } from './pages/ExploreMap';
|
|||||||
import { TourDetailPage } from './pages/TourDetailPage';
|
import { TourDetailPage } from './pages/TourDetailPage';
|
||||||
import { SignupPage } from './pages/SignupPage';
|
import { SignupPage } from './pages/SignupPage';
|
||||||
import { MyPhotosPage } from './pages/MyPhotosPage';
|
import { MyPhotosPage } from './pages/MyPhotosPage';
|
||||||
|
import { MyNotePage } from './pages/MyNotePage';
|
||||||
import { useTourStore } from './store/useTourStore';
|
import { useTourStore } from './store/useTourStore';
|
||||||
import { ConfirmProvider } from './hooks/useConfirm';
|
import { ConfirmProvider } from './hooks/useConfirm';
|
||||||
import { NotificationProvider } from './hooks/useNotification';
|
import { NotificationProvider } from './hooks/useNotification';
|
||||||
@@ -13,7 +14,7 @@ function App() {
|
|||||||
const viewTourId = params.get('viewTour');
|
const viewTourId = params.get('viewTour');
|
||||||
|
|
||||||
const [user, setUser] = useState<any>(null);
|
const [user, setUser] = useState<any>(null);
|
||||||
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos'>(viewTourId ? 'tourDetail' : 'landing');
|
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes'>(viewTourId ? 'tourDetail' : 'landing');
|
||||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||||
|
|
||||||
@@ -94,10 +95,15 @@ function App() {
|
|||||||
tourId={currentTourId!}
|
tourId={currentTourId!}
|
||||||
onBack={handleBackFromTourDetail}
|
onBack={handleBackFromTourDetail}
|
||||||
isPublicView={isPublicTourView}
|
isPublicView={isPublicTourView}
|
||||||
|
onOpenNotes={() => setCurrentPage('notes')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'notes') {
|
||||||
|
return <MyNotePage onBack={() => setCurrentPage('tourDetail')} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentPage === 'explore') {
|
if (currentPage === 'explore') {
|
||||||
return (
|
return (
|
||||||
<ExploreMap
|
<ExploreMap
|
||||||
|
|||||||
@@ -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, isStartPoint = false, isEndPoint = false, isPublicView = false, onSuccess }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isStartPoint?: boolean, isEndPoint?: boolean, isPublicView?: boolean, onSuccess?: () => void }) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -85,7 +85,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
const searchTimeout = useRef<any>(null);
|
const searchTimeout = useRef<any>(null);
|
||||||
|
|
||||||
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||||
const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore();
|
const { legs, addLocation, updateLocation, updateTourStartPoint, updateTourEndPoint, mapCenter, currentTour, userRole } = useTourStore();
|
||||||
const notify = useNotification();
|
const notify = useNotification();
|
||||||
|
|
||||||
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
||||||
@@ -108,7 +108,9 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
expenseDescription: expense?.description || '',
|
expenseDescription: expense?.description || '',
|
||||||
expenseNote: expense?.note || '',
|
expenseNote: expense?.note || '',
|
||||||
paidById: expense?.paidById || '',
|
paidById: expense?.paidById || '',
|
||||||
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '',
|
plannedStart: isStartPoint
|
||||||
|
? (editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : '')
|
||||||
|
: (editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : ''),
|
||||||
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
|
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -153,8 +155,13 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
// 2. Thực hiện các tính toán và hàm xử lý
|
// 2. Thực hiện các tính toán và hàm xử lý
|
||||||
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
||||||
const targetLeg = legs.find(l => l.id === currentLegId);
|
const targetLeg = legs.find(l => l.id === currentLegId);
|
||||||
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
const titleText = isStartPoint ? 'Thiết lập Điểm xuất phát' :
|
||||||
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
isEndPoint ? 'Thiết lập Điểm kết thúc' :
|
||||||
|
editingLocation ? `Sửa địa điểm: ${editingLocation.name}` :
|
||||||
|
(initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
||||||
|
const buttonText = isStartPoint ? 'Xác nhận Điểm xuất phát' :
|
||||||
|
isEndPoint ? 'Xác nhận Điểm kết thúc' :
|
||||||
|
editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
||||||
|
|
||||||
const handleSearchLocation = (query: string) => {
|
const handleSearchLocation = (query: string) => {
|
||||||
setFormData(prev => ({ ...prev, name: query }));
|
setFormData(prev => ({ ...prev, name: query }));
|
||||||
@@ -281,19 +288,41 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload: any = {
|
if (isStartPoint) {
|
||||||
...formData,
|
await updateTourStartPoint(tourId, {
|
||||||
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
|
name: formData.name || "Điểm xuất phát",
|
||||||
legId: currentLegId,
|
latitude: parseFloat(formData.latitude as any),
|
||||||
latitude: parseFloat(formData.latitude as any),
|
longitude: parseFloat(formData.longitude as any),
|
||||||
longitude: parseFloat(formData.longitude as any),
|
plannedEnd: formData.plannedStart,
|
||||||
};
|
});
|
||||||
|
} else if (isEndPoint) {
|
||||||
if (editingLocation) {
|
await updateTourEndPoint(tourId, {
|
||||||
await updateLocation(editingLocation.id, payload);
|
name: formData.name || "Điểm kết thúc",
|
||||||
|
latitude: parseFloat(formData.latitude as any),
|
||||||
|
longitude: parseFloat(formData.longitude as any),
|
||||||
|
plannedStart: formData.plannedStart,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await addLocation(tourId, payload);
|
const payload: any = {
|
||||||
|
...formData,
|
||||||
|
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
|
||||||
|
legId: currentLegId,
|
||||||
|
latitude: parseFloat(formData.latitude as any),
|
||||||
|
longitude: parseFloat(formData.longitude as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingLocation) {
|
||||||
|
await updateLocation(editingLocation.id, payload);
|
||||||
|
} else {
|
||||||
|
await addLocation(tourId, payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: isStartPoint ? 'Đã thiết lập điểm xuất phát.' : isEndPoint ? 'Đã thiết lập điểm kết thúc.' : 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' });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
|
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { useConfirm } from '@/hooks/useConfirm';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,7 +26,7 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
|
|||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [newComment, setNewComment] = useState('');
|
const [newComment, setNewComment] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const confirm = useConfirm();
|
const [confirmState, setConfirmState] = useState({ open: false, commentId: '' });
|
||||||
|
|
||||||
const userRole = useTourStore(state => state.userRole);
|
const userRole = useTourStore(state => state.userRole);
|
||||||
const currentUserId = React.useMemo(() => {
|
const currentUserId = React.useMemo(() => {
|
||||||
|
|||||||
@@ -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, Flag } 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, isStart?: boolean, isEnd?: boolean) => 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">
|
||||||
@@ -284,24 +301,69 @@ export const ItineraryTimeline = ({
|
|||||||
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
|
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
|
||||||
|
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
|
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
|
||||||
|
{legIdx === 0 && !leg.locations.some(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
|
||||||
|
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
||||||
|
<div className="z-10 mt-1.5 mr-4">
|
||||||
|
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-blue-200 flex items-center justify-center text-blue-400">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddLocation?.(leg.id, true)}
|
||||||
|
className="flex-1 bg-blue-50/20 p-4 rounded-xl border border-dashed border-blue-100 hover:border-blue-400 hover:bg-blue-50 transition-all flex items-center justify-between group"
|
||||||
|
>
|
||||||
|
<div className="text-left">
|
||||||
|
<span className="inline-block px-2 py-0.5 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm xuất phát</span>
|
||||||
|
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn để ghim điểm bắt đầu cho Tour...</h3>
|
||||||
|
</div>
|
||||||
|
<Plus className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nút thêm nhanh "Điểm kết thúc" cho Chặng cuối nếu chưa có */}
|
||||||
|
{legIdx === legs.length - 1 && !legs.some(l => l.locations.some(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
|
||||||
|
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
||||||
|
<div className="z-10 mt-1.5 mr-4">
|
||||||
|
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-red-200 flex items-center justify-center text-red-400">
|
||||||
|
<Flag className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddLocation?.(leg.id, false, true)}
|
||||||
|
className="flex-1 bg-red-50/20 p-4 rounded-xl border border-dashed border-red-100 hover:border-red-400 hover:bg-red-50 transition-all flex items-center justify-between group"
|
||||||
|
>
|
||||||
|
<div className="text-left">
|
||||||
|
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||||
|
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn để ghim điểm kết thúc cho Tour...</h3>
|
||||||
|
</div>
|
||||||
|
<Plus className="w-5 h-5 text-red-500 group-hover:scale-110 transition-transform" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{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)
|
||||||
|
|
||||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
|
||||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
||||||
|
|
||||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
// Nhận diện điểm mốc dựa trên timestamp đặc biệt (0) thay vì chỉ số mảng
|
||||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
const isStartPoint = location.plannedStart && new Date(location.plannedStart).getTime() === 0;
|
||||||
|
const isEndPoint = location.plannedEnd && new Date(location.plannedEnd).getTime() === 0;
|
||||||
|
|
||||||
|
const plannedTimeStr = isStartPoint ? location.plannedEnd : location.plannedStart;
|
||||||
|
const hasValidPlannedTime = plannedTimeStr && new Date(plannedTimeStr).getTime() !== 0;
|
||||||
|
|
||||||
|
const dwellMinutes = (location.plannedStart && location.plannedEnd && !isStartPoint && !isEndPoint)
|
||||||
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={location.id}>
|
<div key={location.id}>
|
||||||
@@ -370,27 +432,38 @@ 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') : '--:--'}
|
{hasValidPlannedTime ? format(parseISO(plannedTimeStr), 'HH:mm') : '--:--'}
|
||||||
</div>
|
</div>
|
||||||
{location.status === 'COMPLETED' && location.actualStart && (
|
{location.status === 'COMPLETED' && location.actualStart && (
|
||||||
<div className="text-[10px] text-gray-400 mt-1 italic">
|
<div className="text-[10px] text-gray-400 mt-1 italic">
|
||||||
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canEdit && !isStartPoint && !isEndPoint && ( // Only show edit/delete if canEdit
|
{canEdit && ( // Allow editing and deleting of all locations if user has edit permissions
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
@@ -404,22 +477,17 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logic tính toán độ lệch thời gian */}
|
{/* Logic tính toán độ lệch thời gian */}
|
||||||
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
|
<TimeVariance planned={hasValidPlannedTime ? plannedTimeStr : ''} actual={location.actualStart || null} />
|
||||||
</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>
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
|
|||||||
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||||
<RecenterMap position={userPos} />
|
<RecenterMap position={userPos} />
|
||||||
|
|
||||||
<MarkerClusterGroup chunkedLoading>
|
<MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
|
||||||
{filteredTours.map((tour) => {
|
{filteredTours.map((tour) => {
|
||||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { ChevronLeft, FileText, Plus, Search, Trash2, Loader2, Save, Calendar } from 'lucide-react';
|
||||||
|
import ReactQuill, { Quill } from 'react-quill-new'; // Nếu react-quill gặp lỗi với React 18, hãy dùng react-quill-new
|
||||||
|
import 'react-quill-new/dist/quill.snow.css';
|
||||||
|
|
||||||
|
// Cấu hình Lucide Icons cho Quill
|
||||||
|
const Icons = Quill.import('ui/icons');
|
||||||
|
Icons['bold'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>';
|
||||||
|
Icons['italic'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>';
|
||||||
|
Icons['underline'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>';
|
||||||
|
Icons['strike'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" y1="12" x2="20" y2="12"/></svg>';
|
||||||
|
Icons['link'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||||
|
Icons['image'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
|
||||||
|
Icons['clean'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.9-9.9c1-1 2.5-1 3.4 0l4.4 4.4c1 1 1 2.5 0 3.4L11 21Z"/><path d="m22 21-5.9-5.9"/><path d="M16 11l-5 5"/></svg>';
|
||||||
|
Icons['table'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></svg>';
|
||||||
|
Icons['header']['1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="m17 12 3-2v8"/></svg>';
|
||||||
|
Icons['header']['2'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>';
|
||||||
|
Icons['list']['bullet'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>';
|
||||||
|
Icons['list']['check'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg>';
|
||||||
|
Icons['align'][''] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>';
|
||||||
|
Icons['align']['center'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="10" x2="6" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="18" y1="18" x2="6" y2="18"/></svg>';
|
||||||
|
Icons['align']['right'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="10" x2="7" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="7" y2="18"/></svg>';
|
||||||
|
Icons['align']['justify'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>';
|
||||||
|
Icons['indent']['+1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="11 17 16 12 11 7"/><line x1="18" y1="12" x2="3" y2="12"/></svg>';
|
||||||
|
Icons['indent']['-1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 17 3 12 8 7"/><line x1="21" y1="12" x2="6" y2="12"/></svg>';
|
||||||
|
|
||||||
|
interface Note {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MyNotePage = ({ onBack }: { onBack: () => void }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const quillRef = useRef<ReactQuill>(null);
|
||||||
|
|
||||||
|
// State cho form ghi chú
|
||||||
|
const [noteForm, setNoteForm] = useState({ title: '', content: '' });
|
||||||
|
|
||||||
|
// Khôi phục ghi chú từ localStorage khi load trang
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('my_journey_notes');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
setNotes(parsed);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error("Lỗi parse notes:", e); }
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Tự động lưu ghi chú vào localStorage khi có thay đổi
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
||||||
|
}, [notes]);
|
||||||
|
|
||||||
|
// Cấu hình các công cụ định dạng cho ReactQuill
|
||||||
|
const quillModules = useMemo(() => ({
|
||||||
|
table: true,
|
||||||
|
toolbar: {
|
||||||
|
container: [
|
||||||
|
[{ 'header': 1 }, { 'header': 2 }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||||
|
[{ 'list': 'bullet' }, { 'list': 'check' }],
|
||||||
|
['link', 'image', 'table', 'clean'],
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
table: function() {
|
||||||
|
const rows = prompt('Nhập số hàng:', '3');
|
||||||
|
const cols = prompt('Nhập số cột:', '3');
|
||||||
|
if (rows && cols) {
|
||||||
|
const quill = (quillRef.current as any)?.getEditor();
|
||||||
|
if (quill) {
|
||||||
|
quill.getModule('table').insertTable(parseInt(rows), parseInt(cols));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const handleSaveNote = () => {
|
||||||
|
if (!noteForm.title.trim() && !noteForm.content.trim()) return;
|
||||||
|
|
||||||
|
const note: Note = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: noteForm.title || 'Ghi chú không tiêu đề',
|
||||||
|
content: noteForm.content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setNotes([note, ...notes]);
|
||||||
|
setIsCreating(false);
|
||||||
|
setNoteForm({ title: '', content: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteNote = (id: string) => {
|
||||||
|
if (window.confirm("Bạn có chắc chắn muốn xóa ghi chú này?")) {
|
||||||
|
setNotes(prev => prev.filter(n => n.id !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredNotes = (notes || []).filter(n => {
|
||||||
|
const title = (n.title || '').toLowerCase();
|
||||||
|
const content = (n.content || '').replace(/<[^>]*>/g, '').toLowerCase();
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
|
||||||
|
return title.includes(query) || content.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-4 flex items-center gap-4">
|
||||||
|
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
|
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-black text-gray-900">Ghi chú của tôi</h1>
|
||||||
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Sổ tay hành trình cá nhân</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 max-w-2xl mx-auto w-full">
|
||||||
|
{!isCreating && (
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tìm kiếm nội dung ghi chú..."
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
className="p-3 bg-amber-500 text-white rounded-2xl shadow-lg shadow-amber-200 hover:bg-amber-600 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<Plus className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin mb-4" />
|
||||||
|
<p className="font-bold">Đang tải ghi chú...</p>
|
||||||
|
</div>
|
||||||
|
) : isCreating ? (
|
||||||
|
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tiêu đề ghi chú..."
|
||||||
|
className="w-full px-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-lg font-bold"
|
||||||
|
value={noteForm.title}
|
||||||
|
onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden min-h-[500px] flex flex-col">
|
||||||
|
<ReactQuill
|
||||||
|
ref={quillRef}
|
||||||
|
theme="snow"
|
||||||
|
value={noteForm.content}
|
||||||
|
onChange={(content) => setNoteForm({ ...noteForm, content })}
|
||||||
|
modules={quillModules}
|
||||||
|
placeholder="Bắt đầu viết cảm nhận của bạn tại đây..."
|
||||||
|
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-editor]:flex-1 [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-gray-50 [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-gray-200 [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-gray-100 [&_.ql-stroke]:!stroke-gray-500 [&_.ql-fill]:!fill-gray-500 [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveNote}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 bg-amber-500 hover:bg-amber-600 text-white py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200"
|
||||||
|
>
|
||||||
|
<Save className="w-5 h-5" /> Lưu ghi chú
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsCreating(false); setNoteForm({ title: '', content: '' }); }}
|
||||||
|
className="px-6 py-4 bg-gray-100 hover:bg-gray-200 text-gray-500 rounded-2xl font-black uppercase tracking-widest text-xs transition-all"
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : filteredNotes.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{filteredNotes.map((note) => (
|
||||||
|
<div key={note.id} className="bg-white p-5 rounded-3xl border border-gray-100 shadow-sm hover:shadow-md transition-all group">
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-gray-900">{note.title}</h4>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-gray-400 font-bold uppercase mt-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(note.createdAt).toLocaleDateString('vi-VN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteNote(note.id)}
|
||||||
|
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-sm text-gray-600 line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-gray-200 [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify"
|
||||||
|
dangerouslySetInnerHTML={{ __html: note.content }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-24 bg-white rounded-[40px] border-2 border-dashed border-gray-100 shadow-inner">
|
||||||
|
<div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500">
|
||||||
|
<FileText className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
|
||||||
|
<p className="text-sm text-gray-400 max-w-xs mx-auto mt-2">
|
||||||
|
{searchQuery ? 'Hãy thử tìm kiếm với từ khóa khác.' : 'Hãy lưu lại những cảm nhận, lịch trình riêng hoặc các lưu ý quan trọng cho hành trình của bạn.'}
|
||||||
|
</p>
|
||||||
|
{!searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
className="mt-8 inline-flex items-center gap-2 bg-amber-500 hover:bg-amber-600 text-white px-8 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200 active:scale-95"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" /> Tạo ghi chú mới
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ import { useConfirm } from '@/hooks/useConfirm';
|
|||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { CommentModal } from '@/components/CommentModal';
|
import { CommentModal } from '@/components/CommentModal';
|
||||||
import { AddPhotoModal } from '@/components/AddPhotoModal';
|
import { AddPhotoModal } from '@/components/AddPhotoModal';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet';
|
||||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +27,13 @@ import {
|
|||||||
Map as MapIconLucide,
|
Map as MapIconLucide,
|
||||||
MapPin,
|
MapPin,
|
||||||
Search,
|
Search,
|
||||||
|
LocateFixed,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Compass,
|
||||||
|
Car,
|
||||||
|
Bike,
|
||||||
|
Navigation,
|
||||||
|
Footprints,
|
||||||
Flag,
|
Flag,
|
||||||
Clock,
|
Clock,
|
||||||
Check,
|
Check,
|
||||||
@@ -35,7 +41,8 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Share2,
|
Share2,
|
||||||
Tag as TagIcon,
|
Tag as TagIcon,
|
||||||
Trash2
|
Trash2,
|
||||||
|
FileText
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -50,27 +57,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[] }) => {
|
||||||
@@ -92,6 +89,68 @@ const MapTourBounds = ({ locations }: { locations: any[] }) => {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
// Component Helper để di chuyển tâm bản đồ về vị trí người dùng khi nhấn nút
|
||||||
|
const RecenterUser = ({ position, trigger }: { position: [number, number] | null, trigger: number }) => {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (position && trigger > 0) {
|
||||||
|
map.setView(position, 16, { animate: true });
|
||||||
|
}
|
||||||
|
}, [trigger, position, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
// Component Helper để xử lý xoay bản đồ theo hướng di chuyển hoặc hướng Bắc
|
||||||
|
const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
const container = map.getContainer();
|
||||||
|
// Xoay container bản đồ và scale nhẹ để tránh lộ khoảng trắng ở các góc khi xoay
|
||||||
|
container.style.transform = `rotate(${rotation}deg) scale(${rotation === 0 ? 1 : 1.2})`;
|
||||||
|
container.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
|
}, [rotation, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component Helper để hiển thị mẹo khi người dùng dừng chuột trên bản đồ quá 3 giây
|
||||||
|
const MapHoverTip = ({ canEdit }: { canEdit: boolean }) => {
|
||||||
|
const [tipPos, setTipPos] = useState<L.LatLng | null>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const timerRef = React.useRef<any>(null);
|
||||||
|
|
||||||
|
useMapEvents({
|
||||||
|
mousemove: (e) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
setVisible(false);
|
||||||
|
setTipPos(e.latlng);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setVisible(true);
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
mousedown: () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
setVisible(false);
|
||||||
|
},
|
||||||
|
dragstart: () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
setVisible(false);
|
||||||
|
},
|
||||||
|
contextmenu: () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!visible || !tipPos) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker position={tipPos} icon={L.divIcon({ className: 'opacity-0' })}>
|
||||||
|
<Tooltip direction="top" offset={[0, -10]} opacity={0.9} permanent>
|
||||||
|
<span className="text-[10px] font-bold text-blue-600 whitespace-nowrap">Mẹo: nhấn giữ chuột phải để ghim</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
};
|
||||||
// Menu ngữ cảnh cho bản đồ
|
// Menu ngữ cảnh cho bản đồ
|
||||||
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
|
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
|
||||||
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
||||||
@@ -171,19 +230,76 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBack: () => void, tourId: string, isPublicView?: boolean }) => {
|
export const TourDetailPage = ({
|
||||||
|
onBack,
|
||||||
|
tourId,
|
||||||
|
isPublicView = false,
|
||||||
|
onOpenNotes
|
||||||
|
}: {
|
||||||
|
onBack: () => void,
|
||||||
|
tourId: string,
|
||||||
|
isPublicView?: boolean,
|
||||||
|
onOpenNotes?: () => void
|
||||||
|
}) => {
|
||||||
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
||||||
const currentTour = useTourStore(state => state.currentTour);
|
const currentTour = useTourStore(state => state.currentTour);
|
||||||
const legs = useTourStore(state => state.legs);
|
const legs = useTourStore(state => state.legs);
|
||||||
const publicTours = useTourStore(state => state.publicTours);
|
const publicTours = useTourStore(state => state.publicTours);
|
||||||
const userRole = useTourStore(state => state.userRole);
|
const userRole = useTourStore(state => state.userRole);
|
||||||
const mapCenter = useTourStore(state => state.mapCenter);
|
const mapCenter = useTourStore(state => state.mapCenter);
|
||||||
|
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
||||||
|
|
||||||
|
// Đị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]
|
||||||
|
}),
|
||||||
|
user: L.divIcon({
|
||||||
|
className: '!bg-transparent !border-none',
|
||||||
|
html: `
|
||||||
|
<div class="relative">
|
||||||
|
<div class="w-4 h-4 bg-blue-500 rounded-full border-2 border-white shadow-lg z-10"></div>
|
||||||
|
<div class="absolute -inset-2 bg-blue-400 rounded-full opacity-40 animate-ping"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8]
|
||||||
|
})
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPublicView && navigator.geolocation) {
|
||||||
|
const watchId = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]),
|
||||||
|
(err) => console.warn("Lỗi định vị người dùng:", err),
|
||||||
|
{ enableHighAccuracy: true }
|
||||||
|
);
|
||||||
|
return () => navigator.geolocation.clearWatch(watchId);
|
||||||
|
}
|
||||||
|
}, [isPublicView]);
|
||||||
|
|
||||||
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);
|
||||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||||
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
||||||
|
const [isStartPointAction, setIsStartPointAction] = useState(false);
|
||||||
|
const [isEndPointAction, setIsEndPointAction] = useState(false);
|
||||||
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
||||||
const [editingLocation, setEditingLocation] = useState<any>(null);
|
const [editingLocation, setEditingLocation] = useState<any>(null);
|
||||||
const [selectedMember, setSelectedMember] = useState<any>(null);
|
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||||
@@ -245,6 +361,17 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [locateTrigger, setLocateTrigger] = useState(0);
|
||||||
|
const [isMapControlsOpen, setIsMapControlsOpen] = useState(false);
|
||||||
|
const [isHeadingMode, setIsHeadingMode] = useState(false);
|
||||||
|
const [mapRotation, setMapRotation] = useState(0);
|
||||||
|
const [isRoutingLoading, setIsRoutingLoading] = useState(false);
|
||||||
|
const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving');
|
||||||
|
const [routes, setRoutes] = useState<any[]>([]);
|
||||||
|
const [selectedRouteIndex, setSelectedRouteIndex] = useState(0);
|
||||||
|
const [routeMenu, setRouteMenu] = useState<{ x: number, y: number, index: number } | null>(null);
|
||||||
|
const [drivingRoute, setDrivingRoute] = useState<[number, number][]>([]);
|
||||||
|
const [segmentDistances, setSegmentDistances] = useState<number[]>([]);
|
||||||
|
|
||||||
const handleSearchLocation = async (query: string) => {
|
const handleSearchLocation = async (query: string) => {
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
@@ -338,6 +465,7 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
const fetchJoinRequests = useTourStore(state => state.fetchJoinRequests);
|
const fetchJoinRequests = useTourStore(state => state.fetchJoinRequests);
|
||||||
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
|
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
|
||||||
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
||||||
|
const deleteTour = useTourStore(state => state.deleteTour);
|
||||||
|
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const notify = useNotification();
|
const notify = useNotification();
|
||||||
@@ -395,6 +523,56 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
||||||
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||||
|
|
||||||
|
// Tạo key định danh cho lộ trình để buộc bản đồ vẽ lại khi dữ liệu thay đổi
|
||||||
|
const routeKey = useMemo(() => allLocations.map(l => `${l.id}-${l.latitude}-${l.longitude}`).join('|'), [allLocations]);
|
||||||
|
|
||||||
|
// Tự động tìm các quãng đường di chuyển thực tế theo phương tiện và vẽ lên bản đồ
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRoutes = async () => {
|
||||||
|
if (allLocations.length < 2) {
|
||||||
|
setRoutes([]);
|
||||||
|
setSelectedRouteIndex(0);
|
||||||
|
setDrivingRoute([]);
|
||||||
|
setSegmentDistances([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordsString = allLocations
|
||||||
|
.map(loc => `${loc.longitude},${loc.latitude}`)
|
||||||
|
.join(';');
|
||||||
|
|
||||||
|
setIsRoutingLoading(true);
|
||||||
|
try {
|
||||||
|
// Thêm alternatives=true để yêu cầu các phương án lộ trình khác từ OSRM (Lưu ý: OSRM thường chỉ trả về lộ trình thay thế cho 2 điểm tọa độ)
|
||||||
|
const response = await fetch(`https://router.project-osrm.org/route/v1/${travelMode}/${coordsString}?overview=full&geometries=geojson&alternatives=true`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code === 'Ok' && data.routes.length > 0) {
|
||||||
|
setRoutes(data.routes);
|
||||||
|
// Reset về lộ trình đầu tiên khi danh sách điểm đến thay đổi hoàn toàn
|
||||||
|
setSelectedRouteIndex(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Lỗi lấy lộ trình ${travelMode}:`, error);
|
||||||
|
} finally {
|
||||||
|
setIsRoutingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRoutes();
|
||||||
|
}, [allLocations, travelMode]);
|
||||||
|
|
||||||
|
// Cập nhật dữ liệu lộ trình hiển thị khi người dùng chọn phương án khác
|
||||||
|
useEffect(() => {
|
||||||
|
if (routes.length > 0 && routes[selectedRouteIndex]) {
|
||||||
|
const route = routes[selectedRouteIndex];
|
||||||
|
// OSRM trả về [lng, lat], cần đổi sang [lat, lng] cho Leaflet
|
||||||
|
const mappedCoords: [number, number][] = route.geometry.coordinates.map((c: any) => [c[1], c[0]]);
|
||||||
|
setDrivingRoute(mappedCoords);
|
||||||
|
// Lưu quãng đường từng chặng của lộ trình được chọn
|
||||||
|
setSegmentDistances(route.legs.map((leg: any) => leg.distance / 1000));
|
||||||
|
}
|
||||||
|
}, [selectedRouteIndex, routes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialViewState) {
|
if (initialViewState) {
|
||||||
setMapCenter(initialViewState.center);
|
setMapCenter(initialViewState.center);
|
||||||
@@ -583,10 +761,95 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
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' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hàm xử lý xóa Tour vĩnh viễn
|
||||||
|
const handleDeleteTour = async () => {
|
||||||
|
if (!currentTour) return;
|
||||||
|
const isConfirmed = await confirm({
|
||||||
|
title: 'Xóa Tour vĩnh viễn?',
|
||||||
|
message: 'Toàn bộ dữ liệu về lộ trình, chi phí và hình ảnh của chuyến đi này sẽ bị xóa bỏ hoàn toàn. Bạn có chắc chắn muốn thực hiện?'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isConfirmed) {
|
||||||
|
try {
|
||||||
|
await deleteTour(currentTour.id);
|
||||||
|
notify({ title: 'Thành công', message: 'Hành trình đã được xóa.', type: 'success' });
|
||||||
|
onBack(); // Quay về trang khám phá sau khi xóa thành công
|
||||||
|
} catch (error: any) {
|
||||||
|
notify({ title: 'Lỗi', message: error.message || 'Không thể xóa hành trình.', type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ngăn chặn sự kiện click trên menu lộ trình làm ảnh hưởng bản đồ
|
||||||
|
const routeMenuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (routeMenu && routeMenuRef.current) {
|
||||||
|
L.DomEvent.disableClickPropagation(routeMenuRef.current);
|
||||||
|
}
|
||||||
|
}, [routeMenu]);
|
||||||
|
|
||||||
|
// Đóng menu lộ trình khi chuyển tab hoặc thay đổi chế độ xem
|
||||||
|
useEffect(() => {
|
||||||
|
setRouteMenu(null);
|
||||||
|
}, [activeTab, viewMode]);
|
||||||
|
|
||||||
// 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 = [
|
||||||
@@ -642,16 +905,28 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4 flex-1 text-center">
|
<h1 className="text-lg font-bold text-gray-800 truncate px-4 flex-1 text-center">
|
||||||
{tourInfo.title}
|
{tourInfo.title}
|
||||||
</h1>
|
</h1>
|
||||||
{/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */}
|
<div className="flex items-center gap-1">
|
||||||
{canShare && (
|
{/* Nút Ghi chú: Chỉ hiển thị cho người dùng đã đăng nhập và không phải view công khai */}
|
||||||
<button
|
{!isPublicView && onOpenNotes && (
|
||||||
onClick={handleShare}
|
<button
|
||||||
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
|
onClick={onOpenNotes}
|
||||||
title="Chia sẻ tour"
|
className="p-2 hover:bg-amber-50 text-amber-600 rounded-full transition-colors"
|
||||||
>
|
title="Ghi chú của tôi"
|
||||||
<Share2 className="w-5 h-5" />
|
>
|
||||||
</button>
|
<FileText className="w-5 h-5" />
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */}
|
||||||
|
{canShare && (
|
||||||
|
<button
|
||||||
|
onClick={handleShare}
|
||||||
|
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
|
||||||
|
title="Chia sẻ tour"
|
||||||
|
>
|
||||||
|
<Share2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Tour Header Info */}
|
{/* Tour Header Info */}
|
||||||
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
||||||
@@ -906,16 +1181,29 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'timeline' ? (
|
{viewMode === 'timeline' ? (
|
||||||
<ItineraryTimeline onAddLocation={(legId) => {
|
<ItineraryTimeline onAddLocation={(legId, isStart, isEnd) => {
|
||||||
setTargetLegId(legId);
|
setTargetLegId(legId);
|
||||||
setEditingLocation(null);
|
setEditingLocation(null);
|
||||||
|
setIsStartPointAction(!!isStart);
|
||||||
|
setIsEndPointAction(!!isEnd);
|
||||||
setIsAddLocationOpen(true);
|
setIsAddLocationOpen(true);
|
||||||
}} onEditLocation={(loc) => {
|
}} onEditLocation={(loc) => {
|
||||||
setEditingLocation(loc);
|
setEditingLocation(loc);
|
||||||
setTargetLegId(loc.legId);
|
setTargetLegId(loc.legId);
|
||||||
setMapCenter([loc.latitude, loc.longitude]);
|
setMapCenter([loc.latitude, loc.longitude]);
|
||||||
|
|
||||||
|
// Kiểm tra xem địa điểm đang sửa có phải là điểm mốc đặc biệt không (dựa trên timestamp 1970)
|
||||||
|
const isStart = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
|
||||||
|
const isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0;
|
||||||
|
setIsStartPointAction(!!isStart);
|
||||||
|
setIsEndPointAction(!!isEnd);
|
||||||
|
|
||||||
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 */}
|
||||||
@@ -972,13 +1260,82 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
preferCanvas={true}
|
preferCanvas={true}
|
||||||
>
|
>
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
{canEdit && !isPublicView && <MapContextMenu onAction={handleMapAction} />} {/* Hide map context menu in public view */}
|
{canEdit && !isPublicView && (
|
||||||
|
<>
|
||||||
|
<MapHoverTip canEdit={canEdit} />
|
||||||
|
<MapContextMenu onAction={handleMapAction} onOpen={() => setRouteMenu(null)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
||||||
<MapTourBounds locations={allLocations} />
|
<MapTourBounds locations={allLocations} />
|
||||||
|
|
||||||
{/* Vẽ đường Polyline nối các điểm - Liên tục toàn bộ lộ trình xuyên suốt các chặng */}
|
{/* Xử lý di chuyển tâm bản đồ về phía người dùng */}
|
||||||
{allLocations.length > 1 && (
|
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
||||||
|
|
||||||
|
{/* Xử lý xoay bản đồ */}
|
||||||
|
<MapRotationHandler rotation={mapRotation} />
|
||||||
|
|
||||||
|
{/* Hiển thị vị trí hiện tại của người dùng */}
|
||||||
|
{userLocation && (
|
||||||
|
<Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}>
|
||||||
|
<Popup>
|
||||||
|
<div className="text-xs font-bold text-blue-600">Bạn đang ở đây</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vẽ tất cả lộ trình: Vẽ các đường phụ trước, đường chính sau để hiển thị đè lên trên */}
|
||||||
|
{routes.length > 0 ? (
|
||||||
|
[...routes]
|
||||||
|
.map((r, i) => ({ data: r, index: i }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.index === selectedRouteIndex) return 1;
|
||||||
|
if (b.index === selectedRouteIndex) return -1;
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.map(({ data, index }) => (
|
||||||
|
<Polyline
|
||||||
|
key={`route-${index}-${index === selectedRouteIndex ? 'active' : 'alt'}-${routeKey}-${routes.length}`}
|
||||||
|
positions={data.geometry.coordinates.map((c: any) => [c[1], c[0]])}
|
||||||
|
color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
|
||||||
|
weight={index === selectedRouteIndex ? 6 : 14}
|
||||||
|
opacity={index === selectedRouteIndex ? 1 : 0.4}
|
||||||
|
dashArray={index === selectedRouteIndex ? undefined : "15, 15"}
|
||||||
|
smoothFactor={1}
|
||||||
|
eventHandlers={{
|
||||||
|
click: (e) => {
|
||||||
|
const originalEvent = (e as any).originalEvent;
|
||||||
|
if (originalEvent) L.DomEvent.stopPropagation(originalEvent);
|
||||||
|
setRouteMenu(null);
|
||||||
|
setSelectedRouteIndex(index);
|
||||||
|
},
|
||||||
|
contextmenu: (e) => {
|
||||||
|
const originalEvent = (e as any).originalEvent;
|
||||||
|
if (originalEvent) {
|
||||||
|
L.DomEvent.stopPropagation(originalEvent);
|
||||||
|
L.DomEvent.preventDefault(originalEvent);
|
||||||
|
// Đánh dấu để MapContextMenu biết đã có Layer xử lý
|
||||||
|
(originalEvent as any)._routeTriggered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRouteMenu({ x: (e as any).containerPoint.x, y: (e as any).containerPoint.y, index });
|
||||||
|
},
|
||||||
|
mouseover: (e) => {
|
||||||
|
if (index !== selectedRouteIndex) {
|
||||||
|
(e.target as L.Polyline).setStyle({ opacity: 0.8, weight: 16, color: '#64748b' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseout: (e) => {
|
||||||
|
if (index !== selectedRouteIndex) {
|
||||||
|
(e.target as L.Polyline).setStyle({ opacity: 0.4, weight: 14, color: '#94a3b8' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : allLocations.length > 1 && (
|
||||||
|
/* Vẽ đường thẳng nét đứt nếu không lấy được dữ liệu lộ trình thực tế */
|
||||||
<Polyline
|
<Polyline
|
||||||
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
|
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
|
||||||
color="#3b82f6"
|
color="#3b82f6"
|
||||||
@@ -988,30 +1345,64 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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;
|
||||||
|
const drivingDist = index > 0 ? segmentDistances[index - 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>
|
||||||
|
|
||||||
|
{(drivingDist || (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">
|
||||||
|
{drivingDist
|
||||||
|
? `${travelMode === 'driving' ? 'Đường ô tô' : travelMode === 'bike' ? 'Đường xe máy' : 'Đường đi bộ'} từ điểm trước`
|
||||||
|
: 'Khoảng cách chim bay'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-black text-blue-700">{drivingDist ? drivingDist.toFixed(1) : distanceToPrev} km</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
@@ -1019,8 +1410,129 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
})}
|
})}
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
|
|
||||||
{isPublicView ? 'Xem chi tiết lộ trình' : 'Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm'}
|
{/* Menu ngữ cảnh khi click chuột phải vào con đường */}
|
||||||
|
{routeMenu && (
|
||||||
|
<div
|
||||||
|
ref={routeMenuRef}
|
||||||
|
className="absolute z-[2001] bg-white rounded-2xl shadow-2xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
|
||||||
|
style={{ top: routeMenu.y, left: routeMenu.x }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRouteIndex(routeMenu.index);
|
||||||
|
setRouteMenu(null);
|
||||||
|
notify({
|
||||||
|
title: 'Đã chọn đường đi',
|
||||||
|
message: `Hệ thống đã chuyển sang Lựa chọn ${routeMenu.index + 1}`,
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 text-sm font-bold text-blue-600 flex items-center gap-2 transition-colors rounded-xl"
|
||||||
|
>
|
||||||
|
<Navigation className="w-4 h-4 rotate-45" /> Đi đường này
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay điều khiển trên bản đồ */}
|
||||||
|
<div className="absolute top-4 left-4 z-[1001] flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
|
||||||
|
className="bg-white/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-white text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{isMapControlsOpen ? (
|
||||||
|
<X className="w-5 h-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
travelMode === 'driving' ? <Car className="w-5 h-5" /> :
|
||||||
|
travelMode === 'bike' ? <Bike className="w-5 h-5" /> :
|
||||||
|
<Footprints className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isMapControlsOpen && (
|
||||||
|
<div className="bg-white/90 backdrop-blur-md p-1.5 rounded-2xl shadow-xl border border-white flex flex-col gap-1.5 animate-in slide-in-from-top-2 duration-300">
|
||||||
|
<button
|
||||||
|
onClick={() => setTravelMode('driving')}
|
||||||
|
className={`p-2.5 rounded-xl transition-all ${travelMode === 'driving' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
||||||
|
title="Ô tô"
|
||||||
|
>
|
||||||
|
<Car className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTravelMode('bike')}
|
||||||
|
className={`p-2.5 rounded-xl transition-all ${travelMode === 'bike' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
||||||
|
title="Xe máy / Xe đạp"
|
||||||
|
>
|
||||||
|
<Bike className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTravelMode('foot')}
|
||||||
|
className={`p-2.5 rounded-xl transition-all ${travelMode === 'foot' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
||||||
|
title="Đi bộ"
|
||||||
|
>
|
||||||
|
<Footprints className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Nút La bàn / Xoay bản đồ */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (mapRotation !== 0) {
|
||||||
|
setMapRotation(0);
|
||||||
|
setIsHeadingMode(false);
|
||||||
|
} else {
|
||||||
|
setIsHeadingMode(!isHeadingMode);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`p-2.5 rounded-xl transition-all border-t border-gray-100 mt-1 ${isHeadingMode ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
||||||
|
title={isHeadingMode ? "Dừng xoay (Khóa hướng Bắc)" : "Tự động xoay theo hướng di chuyển"}
|
||||||
|
>
|
||||||
|
<Compass className="w-4 h-4 transition-transform duration-500" style={{ transform: `rotate(${mapRotation}deg)` }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Nút Tìm tôi */}
|
||||||
|
<button
|
||||||
|
onClick={() => setLocateTrigger(prev => prev + 1)}
|
||||||
|
disabled={!userLocation}
|
||||||
|
className={`p-2.5 rounded-xl transition-all border-t border-gray-100 mt-1 ${!userLocation ? 'opacity-30 cursor-not-allowed' : 'text-blue-600 hover:bg-blue-50'}`}
|
||||||
|
title="Vị trí của tôi"
|
||||||
|
>
|
||||||
|
<LocateFixed className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Danh sách lộ trình rút gọn */}
|
||||||
|
{routes.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-gray-100 flex flex-col gap-1 min-w-[120px]">
|
||||||
|
<div className="text-[9px] font-black text-gray-400 uppercase tracking-tighter px-1 mb-1">Lộ trình</div>
|
||||||
|
<div className="flex flex-col gap-1 max-h-[160px] overflow-y-auto pr-1 custom-scrollbar">
|
||||||
|
{routes.map((route, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setSelectedRouteIndex(idx)}
|
||||||
|
className={`p-2 rounded-xl text-left transition-all border ${
|
||||||
|
selectedRouteIndex === idx
|
||||||
|
? 'bg-blue-600 text-white border-blue-600 shadow-sm'
|
||||||
|
: 'bg-gray-50 text-gray-600 border-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-bold">#{idx + 1} - {(route.distance / 1000).toFixed(1)} km</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chỉ báo đang tìm đường */}
|
||||||
|
{isRoutingLoading && (
|
||||||
|
<div className="bg-white/90 backdrop-blur-md px-3 py-2 rounded-xl shadow-lg border border-white flex items-center gap-2 animate-pulse animate-in slide-in-from-left-2">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-600" />
|
||||||
|
<span className="text-[10px] font-black text-gray-500 uppercase tracking-tighter">Đang tìm đường tối ưu...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1338,9 +1850,19 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200">
|
{/* Danger Zone - Khu vực dành cho các hành động quan trọng */}
|
||||||
<Settings className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
<div className="p-6 bg-red-50 rounded-3xl border border-red-100 animate-in zoom-in-95">
|
||||||
<p className="text-gray-500 font-medium">Tính năng cài đặt khác đang được cập nhật...</p>
|
<div className="flex items-center gap-3 mb-4 text-red-600">
|
||||||
|
<Trash2 className="w-6 h-6" />
|
||||||
|
<h3 className="text-lg font-bold">Vùng nguy hiểm</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-500 mb-6 font-medium">Một khi đã xóa, bạn sẽ không thể khôi phục lại dữ liệu của hành trình này.</p>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteTour}
|
||||||
|
className="w-full py-4 bg-red-600 hover:bg-red-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-red-100 active:scale-95"
|
||||||
|
>
|
||||||
|
Xóa Tour vĩnh viễn
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1354,6 +1876,8 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
|
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
|
||||||
setEditingLocation(null);
|
setEditingLocation(null);
|
||||||
|
setIsStartPointAction(false);
|
||||||
|
setIsEndPointAction(false);
|
||||||
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
||||||
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
|
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
|
||||||
}}
|
}}
|
||||||
@@ -1384,9 +1908,15 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
|||||||
isOpen={isAddLocationOpen}
|
isOpen={isAddLocationOpen}
|
||||||
onClose={() => setIsAddLocationOpen(false)}
|
onClose={() => setIsAddLocationOpen(false)}
|
||||||
initialLegId={targetLegId || undefined}
|
initialLegId={targetLegId || undefined}
|
||||||
|
isStartPoint={isStartPointAction}
|
||||||
|
isEndPoint={isEndPointAction}
|
||||||
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
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Generated
+393
-2
@@ -13,7 +13,8 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.8"
|
"jspdf-autotable": "^5.0.8",
|
||||||
|
"react-quill": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^8.2.2"
|
"concurrently": "^8.2.2"
|
||||||
@@ -207,6 +208,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-leaflet-cluster": "^2.1.0",
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
|
"react-quill": "^2.0.0",
|
||||||
|
"react-quill-new": "^3.8.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
@@ -222,6 +225,62 @@
|
|||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"frontend/node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"frontend/node_modules/parchment": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"frontend/node_modules/quill": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"parchment": "^3.0.0",
|
||||||
|
"quill-delta": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=8.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"frontend/node_modules/quill-delta": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"frontend/node_modules/react-quill-new": {
|
||||||
|
"version": "3.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
||||||
|
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"quill": "~2.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"quill-delta": "^5.1.0",
|
||||||
|
"react": "^16 || ^17 || ^18 || ^19",
|
||||||
|
"react-dom": "^16 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
@@ -3035,6 +3094,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/quill": {
|
||||||
|
"version": "1.3.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
|
||||||
|
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parchment": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/raf": {
|
"node_modules/@types/raf": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
@@ -3777,6 +3845,24 @@
|
|||||||
"@redis/time-series": "1.1.0"
|
"@redis/time-series": "1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"get-intrinsic": "^1.3.0",
|
||||||
|
"set-function-length": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
@@ -4269,6 +4355,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-equal": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-arguments": "^1.1.1",
|
||||||
|
"is-date-object": "^1.0.5",
|
||||||
|
"is-regex": "^1.1.4",
|
||||||
|
"object-is": "^1.1.5",
|
||||||
|
"object-keys": "^1.1.1",
|
||||||
|
"regexp.prototype.flags": "^1.5.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -4292,6 +4398,40 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/define-data-property": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/define-properties": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"define-data-property": "^1.0.1",
|
||||||
|
"has-property-descriptors": "^1.0.0",
|
||||||
|
"object-keys": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -4762,6 +4902,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -4769,6 +4915,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
@@ -4983,6 +5135,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/functions-have-names": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/generic-pool": {
|
"node_modules/generic-pool": {
|
||||||
"version": "3.9.0",
|
"version": "3.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||||
@@ -5132,6 +5293,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-property-descriptors": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-symbols": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
@@ -5144,6 +5317,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hashery": {
|
"node_modules/hashery": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
|
||||||
@@ -5282,6 +5470,22 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arguments": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"has-tostringtag": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
@@ -5289,6 +5493,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-date-object": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"has-tostringtag": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-fullwidth-code-point": {
|
"node_modules/is-fullwidth-code-point": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
@@ -5315,6 +5535,24 @@
|
|||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-regex": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-unicode-supported": {
|
"node_modules/is-unicode-supported": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||||
@@ -5843,7 +6081,12 @@
|
|||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.clonedeep": {
|
"node_modules/lodash.clonedeep": {
|
||||||
@@ -5864,6 +6107,13 @@
|
|||||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isinteger": {
|
"node_modules/lodash.isinteger": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
@@ -6251,6 +6501,31 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-is": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.7",
|
||||||
|
"define-properties": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-keys": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -6318,6 +6593,12 @@
|
|||||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
|
"node_modules/parchment": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -6695,6 +6976,49 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quill": {
|
||||||
|
"version": "1.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
|
||||||
|
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"clone": "^2.1.1",
|
||||||
|
"deep-equal": "^1.0.1",
|
||||||
|
"eventemitter3": "^2.0.3",
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"parchment": "^1.1.4",
|
||||||
|
"quill-delta": "^3.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill-delta": {
|
||||||
|
"version": "3.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
|
||||||
|
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-equal": "^1.0.1",
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"fast-diff": "1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill/node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill/node_modules/eventemitter3": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/raf": {
|
"node_modules/raf": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
@@ -6783,6 +7107,21 @@
|
|||||||
"react-leaflet": "^4.0.0"
|
"react-leaflet": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-quill": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/quill": "^1.3.10",
|
||||||
|
"lodash": "^4.17.4",
|
||||||
|
"quill": "^1.3.7"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16 || ^17 || ^18",
|
||||||
|
"react-dom": "^16 || ^17 || ^18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
@@ -6913,6 +7252,26 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/regexp.prototype.flags": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.8",
|
||||||
|
"define-properties": "^1.2.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"set-function-name": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -7194,6 +7553,38 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-function-length": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"define-data-property": "^1.1.4",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-intrinsic": "^1.2.4",
|
||||||
|
"gopd": "^1.0.1",
|
||||||
|
"has-property-descriptors": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/set-function-name": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"define-data-property": "^1.1.4",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"functions-have-names": "^1.2.3",
|
||||||
|
"has-property-descriptors": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.8"
|
"jspdf-autotable": "^5.0.8",
|
||||||
|
"react-quill": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user