fix: sửa lỗi hiển thị logo trên trang pdf
This commit is contained in:
Vendored
+83
-16
@@ -516,11 +516,14 @@ let PublicTourController = class PublicTourController {
|
|||||||
expenses: {
|
expenses: {
|
||||||
include: {
|
include: {
|
||||||
location: { select: { name: true, plannedStart: true } },
|
location: { select: { name: true, plannedStart: true } },
|
||||||
paidBy: { select: { name: true } }
|
paidBy: { select: { id: true, name: true } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
locations: {
|
locations: {
|
||||||
orderBy: { plannedStart: 'asc' },
|
orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
],
|
||||||
include: { _count: { select: { comments: true } } }
|
include: { _count: { select: { comments: true } } }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -839,7 +842,10 @@ let TourController = class TourController {
|
|||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
locations: {
|
locations: {
|
||||||
orderBy: { plannedStart: 'asc' },
|
orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
],
|
||||||
include: { _count: { select: { comments: true } } }
|
include: { _count: { select: { comments: true } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -871,11 +877,14 @@ let TourController = class TourController {
|
|||||||
expenses: {
|
expenses: {
|
||||||
include: {
|
include: {
|
||||||
location: { select: { name: true, plannedStart: true } },
|
location: { select: { name: true, plannedStart: true } },
|
||||||
paidBy: { select: { name: true } }
|
paidBy: { select: { id: true, name: true } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
locations: {
|
locations: {
|
||||||
orderBy: { plannedStart: 'asc' },
|
orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
],
|
||||||
include: { _count: { select: { comments: true } } }
|
include: { _count: { select: { comments: true } } }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -931,6 +940,44 @@ let TourController = class TourController {
|
|||||||
include: { user: { select: { id: true, name: true, email: true } } },
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async updateMemberCounts(tourId, userId, body, req) {
|
||||||
|
const isSelf = req.user.id === userId;
|
||||||
|
const requesterParticipation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||||
|
});
|
||||||
|
const canManage = requesterParticipation?.role === 'OWNER' || requesterParticipation?.role === 'MANAGER';
|
||||||
|
if (!canManage && !isSelf) {
|
||||||
|
throw new common_1.ForbiddenException('Bạn không có quyền chỉnh sửa thông tin thành viên này.');
|
||||||
|
}
|
||||||
|
const data = {};
|
||||||
|
if (body.adultCount !== undefined) {
|
||||||
|
if (body.adultCount < 1)
|
||||||
|
throw new common_1.BadRequestException('Số lượng người lớn tối thiểu là 1.');
|
||||||
|
data.adultCount = Number(body.adultCount);
|
||||||
|
}
|
||||||
|
if (body.childCount !== undefined) {
|
||||||
|
if (body.childCount < 0)
|
||||||
|
throw new common_1.BadRequestException('Số lượng trẻ em không được âm.');
|
||||||
|
data.childCount = Number(body.childCount);
|
||||||
|
}
|
||||||
|
if (body.role !== undefined && canManage) {
|
||||||
|
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'];
|
||||||
|
if (validRoles.includes(body.role)) {
|
||||||
|
data.role = body.role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
this.cacheManager.del(`user-role:${userId}:${tourId}`),
|
||||||
|
this.cacheManager.del(tourId),
|
||||||
|
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||||
|
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||||
|
]);
|
||||||
|
return this.prisma.tourParticipant.update({
|
||||||
|
where: { tourId_userId: { tourId, userId } },
|
||||||
|
data,
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
async getJoinRequests(tourId, req) {
|
async getJoinRequests(tourId, req) {
|
||||||
const requests = await this.prisma.joinRequest.findMany({
|
const requests = await this.prisma.joinRequest.findMany({
|
||||||
where: { tourId, status: 'PENDING' },
|
where: { tourId, status: 'PENDING' },
|
||||||
@@ -1248,6 +1295,18 @@ __decorate([
|
|||||||
__metadata("design:paramtypes", [String, Object, Object]),
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], TourController.prototype, "addMember", null);
|
], TourController.prototype, "addMember", null);
|
||||||
|
__decorate([
|
||||||
|
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY),
|
||||||
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
|
||||||
|
(0, common_1.Patch)(':tourId/members/:userId'),
|
||||||
|
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||||
|
__param(1, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)),
|
||||||
|
__param(2, (0, common_1.Body)()),
|
||||||
|
__param(3, (0, common_1.Req)()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, String, Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourController.prototype, "updateMemberCounts", null);
|
||||||
__decorate([
|
__decorate([
|
||||||
(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),
|
||||||
@@ -1515,7 +1574,10 @@ let RoutingController = class RoutingController {
|
|||||||
tourId: currentLeg.tourId,
|
tourId: currentLeg.tourId,
|
||||||
sequence: currentLeg.sequence - 1
|
sequence: currentLeg.sequence - 1
|
||||||
},
|
},
|
||||||
include: { locations: { orderBy: { plannedStart: 'asc' } } }
|
include: { locations: { orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
] } }
|
||||||
});
|
});
|
||||||
if (prevLeg?.locations?.length) {
|
if (prevLeg?.locations?.length) {
|
||||||
startAnchor = prevLeg.locations[prevLeg.locations.length - 1];
|
startAnchor = prevLeg.locations[prevLeg.locations.length - 1];
|
||||||
@@ -1568,7 +1630,10 @@ let RoutingController = class RoutingController {
|
|||||||
})));
|
})));
|
||||||
const updatedLocations = await this.prisma.location.findMany({
|
const updatedLocations = await this.prisma.location.findMany({
|
||||||
where: { legId },
|
where: { legId },
|
||||||
orderBy: { plannedStart: 'asc' }
|
orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
if (req.tourId) {
|
if (req.tourId) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -1820,14 +1885,17 @@ let UserController = class UserController {
|
|||||||
async getAllUsers(req, q) {
|
async getAllUsers(req, q) {
|
||||||
const currentUserId = req.user?.id;
|
const currentUserId = req.user?.id;
|
||||||
const users = await this.prisma.user.findMany({
|
const users = await this.prisma.user.findMany({
|
||||||
where: q
|
where: {
|
||||||
? {
|
isAnonymous: false,
|
||||||
OR: [
|
...(q
|
||||||
{ name: { contains: q, mode: 'insensitive' } },
|
? {
|
||||||
{ email: { contains: q, mode: 'insensitive' } },
|
OR: [
|
||||||
],
|
{ name: { contains: q, mode: 'insensitive' } },
|
||||||
}
|
{ email: { contains: q, mode: 'insensitive' } },
|
||||||
: undefined,
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true }
|
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true }
|
||||||
});
|
});
|
||||||
return users.filter((u) => u.id !== currentUserId);
|
return users.filter((u) => u.id !== currentUserId);
|
||||||
@@ -1911,7 +1979,6 @@ let UserController = class UserController {
|
|||||||
};
|
};
|
||||||
__decorate([
|
__decorate([
|
||||||
(0, common_1.Get)(),
|
(0, common_1.Get)(),
|
||||||
(0, common_1.UseGuards)(admin_guard_1.AdminGuard),
|
|
||||||
__param(0, (0, common_1.Req)()),
|
__param(0, (0, common_1.Req)()),
|
||||||
__param(1, (0, common_1.Query)('q')),
|
__param(1, (0, common_1.Query)('q')),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Location" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TourParticipant" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -118,14 +118,18 @@ model JoinRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TourParticipant {
|
model TourParticipant {
|
||||||
|
id String @id @default(uuid())
|
||||||
tourId String
|
tourId String
|
||||||
userId String
|
userId String?
|
||||||
role ParticipantRole @default(MEMBER)
|
role ParticipantRole @default(MEMBER)
|
||||||
|
displayName String?
|
||||||
|
adultCount Int @default(1)
|
||||||
|
childCount Int @default(0)
|
||||||
|
|
||||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([tourId, userId])
|
@@unique([tourId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Leg {
|
model Leg {
|
||||||
@@ -162,6 +166,8 @@ model Location {
|
|||||||
expenses Expense[]
|
expenses Expense[]
|
||||||
photos Photo[]
|
photos Photo[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
model Expense {
|
model Expense {
|
||||||
|
|||||||
+85
-16
@@ -498,11 +498,14 @@ class PublicTourController {
|
|||||||
expenses: {
|
expenses: {
|
||||||
include: {
|
include: {
|
||||||
location: { select: { name: true, plannedStart: true } },
|
location: { select: { name: true, plannedStart: true } },
|
||||||
paidBy: { select: { name: true } }
|
paidBy: { select: { id: true, name: true } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
locations: {
|
locations: {
|
||||||
orderBy: { plannedStart: 'asc' },
|
orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
],
|
||||||
include: { _count: { select: { comments: true } } }
|
include: { _count: { select: { comments: true } } }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -896,7 +899,10 @@ class TourController {
|
|||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
locations: {
|
locations: {
|
||||||
orderBy: { plannedStart: 'asc' },
|
orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
],
|
||||||
include: { _count: { select: { comments: true } } }
|
include: { _count: { select: { comments: true } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -933,11 +939,14 @@ class TourController {
|
|||||||
expenses: {
|
expenses: {
|
||||||
include: {
|
include: {
|
||||||
location: { select: { name: true, plannedStart: true } },
|
location: { select: { name: true, plannedStart: true } },
|
||||||
paidBy: { select: { name: true } }
|
paidBy: { select: { id: true, name: true } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
locations: {
|
locations: {
|
||||||
orderBy: { plannedStart: 'asc' },
|
orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
],
|
||||||
include: { _count: { select: { comments: true } } }
|
include: { _count: { select: { comments: true } } }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1003,6 +1012,58 @@ class TourController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY)
|
||||||
|
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||||
|
@Patch(':tourId/members/:userId')
|
||||||
|
async updateMemberCounts(
|
||||||
|
@Param('tourId', ParseUUIDPipe) tourId: string,
|
||||||
|
@Param('userId', ParseUUIDPipe) userId: string,
|
||||||
|
@Body() body: { adultCount?: number; childCount?: number; role?: string },
|
||||||
|
@Req() req: any
|
||||||
|
) {
|
||||||
|
const isSelf = req.user.id === userId;
|
||||||
|
|
||||||
|
// Tìm quyền hạn của người gửi yêu cầu trong tour này
|
||||||
|
const requesterParticipation = await this.prisma.tourParticipant.findUnique({
|
||||||
|
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||||
|
});
|
||||||
|
const canManage = requesterParticipation?.role === 'OWNER' || requesterParticipation?.role === 'MANAGER';
|
||||||
|
|
||||||
|
if (!canManage && !isSelf) {
|
||||||
|
throw new ForbiddenException('Bạn không có quyền chỉnh sửa thông tin thành viên này.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
if (body.adultCount !== undefined) {
|
||||||
|
if (body.adultCount < 1) throw new BadRequestException('Số lượng người lớn tối thiểu là 1.');
|
||||||
|
data.adultCount = Number(body.adultCount);
|
||||||
|
}
|
||||||
|
if (body.childCount !== undefined) {
|
||||||
|
if (body.childCount < 0) throw new BadRequestException('Số lượng trẻ em không được âm.');
|
||||||
|
data.childCount = Number(body.childCount);
|
||||||
|
}
|
||||||
|
if (body.role !== undefined && canManage) {
|
||||||
|
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const;
|
||||||
|
if (validRoles.includes(body.role as any)) {
|
||||||
|
data.role = body.role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Xóa cache liên quan
|
||||||
|
await Promise.all([
|
||||||
|
this.cacheManager.del(`user-role:${userId}:${tourId}`),
|
||||||
|
this.cacheManager.del(tourId),
|
||||||
|
this.cacheManager.del(`/api/v1/tours/${tourId}`),
|
||||||
|
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return this.prisma.tourParticipant.update({
|
||||||
|
where: { tourId_userId: { tourId, userId } },
|
||||||
|
data,
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can view join requests
|
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can view join requests
|
||||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||||
@Get(':tourId/join-requests')
|
@Get(':tourId/join-requests')
|
||||||
@@ -1449,7 +1510,10 @@ class RoutingController {
|
|||||||
tourId: currentLeg.tourId,
|
tourId: currentLeg.tourId,
|
||||||
sequence: currentLeg.sequence - 1
|
sequence: currentLeg.sequence - 1
|
||||||
},
|
},
|
||||||
include: { locations: { orderBy: { plannedStart: 'asc' } } }
|
include: { locations: { orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
] } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prevLeg?.locations?.length) {
|
if (prevLeg?.locations?.length) {
|
||||||
@@ -1526,7 +1590,10 @@ class RoutingController {
|
|||||||
|
|
||||||
const updatedLocations = await this.prisma.location.findMany({
|
const updatedLocations = await this.prisma.location.findMany({
|
||||||
where: { legId },
|
where: { legId },
|
||||||
orderBy: { plannedStart: 'asc' }
|
orderBy: [
|
||||||
|
{ plannedStart: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ createdAt: 'asc' }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.tourId) {
|
if (req.tourId) {
|
||||||
@@ -1779,18 +1846,20 @@ class UserController {
|
|||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
async getAllUsers(@Req() req: any, @Query('q') q?: string) {
|
async getAllUsers(@Req() req: any, @Query('q') q?: string) {
|
||||||
const currentUserId = req.user?.id;
|
const currentUserId = req.user?.id;
|
||||||
const users = await this.prisma.user.findMany({
|
const users = await this.prisma.user.findMany({
|
||||||
where: q
|
where: {
|
||||||
? {
|
isAnonymous: false,
|
||||||
OR: [
|
...(q
|
||||||
{ name: { contains: q, mode: 'insensitive' as any } },
|
? {
|
||||||
{ email: { contains: q, mode: 'insensitive' as any } },
|
OR: [
|
||||||
],
|
{ name: { contains: q, mode: 'insensitive' as any } },
|
||||||
}
|
{ email: { contains: q, mode: 'insensitive' as any } },
|
||||||
: undefined,
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true }
|
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true }
|
||||||
});
|
});
|
||||||
return users.filter((u: any) => u.id !== currentUserId);
|
return users.filter((u: any) => u.id !== currentUserId);
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+212
File diff suppressed because one or more lines are too long
-211
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<title>Travel Planner</title>
|
<title>Travel Planner</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CtrQmjY1.js"></script>
|
<script type="module" crossorigin src="/assets/index-C3QeRTiI.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-i0kJVU1C.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-_0oFafPI.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -132,22 +132,36 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
[formData.latitude, formData.longitude]
|
[formData.latitude, formData.longitude]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng đó làm tham chiếu)
|
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng trước/hiện tại làm tham chiếu tiếp nối)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && !editingLocation && formData.legId && !formData.name) {
|
if (isOpen && !editingLocation && formData.legId && !formData.name) {
|
||||||
const selectedLeg = legs.find(l => l.id === formData.legId);
|
const selectedLeg = legs.find(l => l.id === formData.legId);
|
||||||
|
|
||||||
if (selectedLeg && selectedLeg.locations && selectedLeg.locations.length > 0) {
|
if (selectedLeg) {
|
||||||
// Di chuyển đến địa điểm cuối cùng của chặng để người dùng thấy điểm nối tiếp
|
if (selectedLeg.locations && selectedLeg.locations.length > 0) {
|
||||||
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
|
// Di chuyển đến địa điểm cuối cùng của chặng hiện tại để người dùng thấy điểm nối tiếp
|
||||||
setFormData(prev => ({
|
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
|
||||||
...prev,
|
setFormData(prev => ({
|
||||||
latitude: lastLoc.latitude,
|
...prev,
|
||||||
longitude: lastLoc.longitude
|
latitude: lastLoc.latitude,
|
||||||
}));
|
longitude: lastLoc.longitude
|
||||||
} else {
|
}));
|
||||||
// Nếu chặng chưa có điểm nào, mặc định dùng vị trí trung tâm hiện tại của tour
|
} else {
|
||||||
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
// Chặng trống -> Lấy địa điểm cuối của chặng trước làm tọa độ tiếp nối
|
||||||
|
const currentLegIdx = legs.findIndex(l => l.id === formData.legId);
|
||||||
|
const prevLeg = currentLegIdx > 0 ? legs[currentLegIdx - 1] : null;
|
||||||
|
if (prevLeg && prevLeg.locations && prevLeg.locations.length > 0) {
|
||||||
|
const lastLoc = prevLeg.locations[prevLeg.locations.length - 1];
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
latitude: lastLoc.latitude,
|
||||||
|
longitude: lastLoc.longitude
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Nếu không có chặng trước hoặc chặng trước trống, mặc định dùng vị trí trung tâm hiện tại của tour
|
||||||
|
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
|
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
|
||||||
@@ -484,7 +498,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||||
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
|
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
|
||||||
<option value="">-- Chọn người thanh toán --</option>
|
<option value="">-- Chọn người thanh toán --</option>
|
||||||
{currentTour?.participants?.map((p: any) => {
|
{currentTour?.participants?.filter((p: any) => p.user)?.map((p: any) => {
|
||||||
const name = p.user?.name;
|
const name = p.user?.name;
|
||||||
const email = p.user?.email;
|
const email = p.user?.email;
|
||||||
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
|
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface AddMemberModalProps {
|
|||||||
isPublicView?: boolean; // New prop to indicate public view
|
isPublicView?: boolean; // New prop to indicate public view
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole, isPublicView }) => {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -54,8 +54,12 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
fetchUsers();
|
const delayDebounceFn = setTimeout(() => {
|
||||||
}, [isOpen]);
|
fetchUsers();
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(delayDebounceFn);
|
||||||
|
}, [query, isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -158,9 +162,9 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
|
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p>
|
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.filter(p => p.user).length})</p>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{participants.map((p) => {
|
{participants.filter(p => p.user).map((p) => {
|
||||||
const rawToken = localStorage.getItem('token');
|
const rawToken = localStorage.getItem('token');
|
||||||
let currentUserId: string | null = null;
|
let currentUserId: string | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -255,6 +259,26 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="relative mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tìm kiếm theo tên hoặc địa chỉ email..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-10 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-200 rounded-full text-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{fetchError && (
|
{fetchError && (
|
||||||
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
||||||
{fetchError}
|
{fetchError}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -250,7 +250,7 @@ export const ItineraryTimeline = ({
|
|||||||
{canEdit && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAddLocation?.(leg.id)}
|
onClick={() => onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0)}
|
||||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||||
title="Thêm địa điểm vào chặng này"
|
title="Thêm địa điểm vào chặng này"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
|
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
|
||||||
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center"
|
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center relative"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsFullscreen(true);
|
setIsFullscreen(true);
|
||||||
@@ -411,8 +411,13 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
<img
|
<img
|
||||||
src={photo.imageUrl}
|
src={photo.imageUrl}
|
||||||
alt="Public Map Upload"
|
alt="Public Map Upload"
|
||||||
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none"
|
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none"
|
||||||
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
|
||||||
|
{!isAuthorized && (
|
||||||
|
<div className="absolute inset-0 bg-transparent select-none z-10" />
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -546,8 +546,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
className: 'custom-photo-bubble',
|
className: 'custom-photo-bubble',
|
||||||
html: `
|
html: `
|
||||||
<a href="${window.location.origin}/api/v1/public-photos/${latestPhoto.id}/share" onclick="event.preventDefault();" class="relative group block">
|
<a href="${window.location.origin}/api/v1/public-photos/${latestPhoto.id}/share" onclick="event.preventDefault();" class="relative group block">
|
||||||
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110 relative">
|
||||||
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover" />
|
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover select-none" draggable="false" />
|
||||||
|
<div class="absolute inset-0 bg-transparent select-none z-10"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
<div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||||
📸
|
📸
|
||||||
@@ -604,6 +605,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
|
|
||||||
const newTourNote = {
|
const newTourNote = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
|
tourId: tour.id,
|
||||||
title: `Ghi chú của hành trình: ${tour.title}`,
|
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>`,
|
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()
|
createdAt: new Date().toISOString()
|
||||||
|
|||||||
@@ -701,6 +701,22 @@ export const TourDetailPage = ({
|
|||||||
const [commentLocationName, setCommentLocationName] = useState('');
|
const [commentLocationName, setCommentLocationName] = useState('');
|
||||||
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// State cho Modal ghi chú nhanh
|
||||||
|
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(null);
|
||||||
|
const [quickNoteInput, setQuickNoteInput] = useState('');
|
||||||
|
|
||||||
|
// Đồng bộ hóa các ô nhập dữ liệu cài đặt với currentTour khi tour tải/cập nhật
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTour) {
|
||||||
|
setTitleInput(currentTour.title ?? '');
|
||||||
|
setDescriptionInput(currentTour.description ?? '');
|
||||||
|
setAdultCountInput(currentTour.adultCount ?? 0);
|
||||||
|
setChildCountInput(currentTour.childCount ?? 0);
|
||||||
|
setChildDiscountInput(currentTour.childDiscount ?? 0);
|
||||||
|
setTagsInput(currentTour.tags ?? []);
|
||||||
|
}
|
||||||
|
}, [currentTour]);
|
||||||
|
|
||||||
// State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình
|
// State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
@@ -1201,6 +1217,35 @@ export const TourDetailPage = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Đồng bộ tiêu đề ghi chú khi đổi tên chuyến đi
|
||||||
|
const syncTourNoteTitle = (tourId: string, newTitle: string) => {
|
||||||
|
const savedNotes = localStorage.getItem('my_journey_notes');
|
||||||
|
if (!savedNotes) return;
|
||||||
|
try {
|
||||||
|
let notes = JSON.parse(savedNotes);
|
||||||
|
if (Array.isArray(notes)) {
|
||||||
|
let updated = false;
|
||||||
|
const oldTitle = currentTour?.title || '';
|
||||||
|
notes = notes.map((n: any) => {
|
||||||
|
if (n.tourId === tourId || n.title === `Ghi chú của hành trình: ${oldTitle}`) {
|
||||||
|
n.tourId = tourId;
|
||||||
|
n.title = `Ghi chú của hành trình: ${newTitle}`;
|
||||||
|
if (oldTitle && n.content) {
|
||||||
|
n.content = n.content.split(`<strong>${oldTitle}</strong>`).join(`<strong>${newTitle}</strong>`);
|
||||||
|
}
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
if (updated) {
|
||||||
|
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error syncing tour note title:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Hàm xử lý cập nhật số lượng người tham gia
|
// Hàm xử lý cập nhật số lượng người tham gia
|
||||||
const handleUpdateTourInfo = async () => {
|
const handleUpdateTourInfo = async () => {
|
||||||
if (!currentTour) return;
|
if (!currentTour) return;
|
||||||
@@ -1218,31 +1263,40 @@ export const TourDetailPage = ({
|
|||||||
message: 'Đã cập nhật thông tin chuyến đi.',
|
message: 'Đã cập nhật thông tin chuyến đi.',
|
||||||
type: 'success'
|
type: 'success'
|
||||||
});
|
});
|
||||||
|
if (titleInput !== currentTour.title) {
|
||||||
|
syncTourNoteTitle(currentTour.id, titleInput);
|
||||||
|
}
|
||||||
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
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
|
|
||||||
|
// 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) => {
|
const handleQuickNote = (locationName: string) => {
|
||||||
if (isPublicView) return;
|
if (isPublicView) return;
|
||||||
|
setQuickNoteLocName(locationName);
|
||||||
|
setQuickNoteInput('');
|
||||||
|
};
|
||||||
|
|
||||||
const content = window.prompt(`Ghi chú nhanh cho địa điểm: ${locationName}`);
|
// Hàm xử lý submit ghi chú nhanh từ modal
|
||||||
if (!content || !content.trim()) return;
|
const submitQuickNote = () => {
|
||||||
|
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour) return;
|
||||||
|
const content = quickNoteInput;
|
||||||
|
|
||||||
const storedUser = localStorage.getItem('user');
|
const storedUser = localStorage.getItem('user');
|
||||||
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
|
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
|
||||||
const userName = user.name || 'Thành viên';
|
const userName = user.name || 'Thành viên';
|
||||||
const now = new Date().toLocaleString('vi-VN');
|
const now = new Date().toLocaleString('vi-VN');
|
||||||
|
|
||||||
const noteTitle = `Ghi chú của hành trình: ${currentTour?.title}`;
|
const noteTitle = `Ghi chú của hành trình: ${currentTour.title}`;
|
||||||
const savedNotes = localStorage.getItem('my_journey_notes');
|
const savedNotes = localStorage.getItem('my_journey_notes');
|
||||||
let notes = [];
|
let notes = [];
|
||||||
try {
|
try {
|
||||||
notes = savedNotes ? JSON.parse(savedNotes) : [];
|
notes = savedNotes ? JSON.parse(savedNotes) : [];
|
||||||
} catch (e) { notes = []; }
|
} catch (e) { notes = []; }
|
||||||
|
|
||||||
let targetNote = notes.find((n: any) => n.title === noteTitle);
|
let targetNote = notes.find((n: any) => n.tourId === currentTour.id || n.title === noteTitle);
|
||||||
|
|
||||||
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp
|
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp
|
||||||
const newContentLine = `
|
const newContentLine = `
|
||||||
@@ -1250,18 +1304,21 @@ export const TourDetailPage = ({
|
|||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
<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>
|
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} • 👤 ${userName}</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${locationName}:</strong> ${content}</p>
|
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${quickNoteLocName}:</strong> ${content}</p>
|
||||||
</div>
|
</div>
|
||||||
<p></p>
|
<p></p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (targetNote) {
|
if (targetNote) {
|
||||||
|
targetNote.tourId = currentTour.id;
|
||||||
|
targetNote.title = noteTitle;
|
||||||
targetNote.content += newContentLine;
|
targetNote.content += newContentLine;
|
||||||
} else {
|
} else {
|
||||||
const newNote = {
|
const newNote = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
|
tourId: currentTour.id,
|
||||||
title: noteTitle,
|
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,
|
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()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
notes.unshift(newNote);
|
notes.unshift(newNote);
|
||||||
@@ -1269,6 +1326,8 @@ export const TourDetailPage = ({
|
|||||||
|
|
||||||
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
||||||
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
|
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
|
||||||
|
setQuickNoteLocName(null);
|
||||||
|
setQuickNoteInput('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hàm xử lý xóa Tour vĩnh viễn
|
// Hàm xử lý xóa Tour vĩnh viễn
|
||||||
@@ -1352,7 +1411,7 @@ export const TourDetailPage = ({
|
|||||||
const tourInfo = {
|
const tourInfo = {
|
||||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||||
date: tourDateDisplay,
|
date: tourDateDisplay,
|
||||||
membersCount: currentTour?.participants?.length || 0,
|
membersCount: currentTour?.participants?.filter((p: any) => p.user)?.length || 0,
|
||||||
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||||
coverImage: getMostLikedPhoto(currentTour?.photos || [])?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
coverImage: getMostLikedPhoto(currentTour?.photos || [])?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||||
};
|
};
|
||||||
@@ -1448,7 +1507,7 @@ export const TourDetailPage = ({
|
|||||||
{/* Member Avatars Stack */}
|
{/* Member Avatars Stack */}
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
<div className="flex flex-wrap gap-2"> {/* Always show participants */}
|
<div className="flex flex-wrap gap-2"> {/* Always show participants */}
|
||||||
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
|
{currentTour?.participants?.filter((p: any) => p.user)?.slice(0, 5).map((p: any, i: number) => (
|
||||||
<button
|
<button
|
||||||
key={p.userId || i}
|
key={p.userId || i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -2682,6 +2741,54 @@ export const TourDetailPage = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Quick Note Modal */}
|
||||||
|
{quickNoteLocName && (
|
||||||
|
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => setQuickNoteLocName(null)} />
|
||||||
|
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-xl font-black text-gray-900">Ghi chú nhanh</h3>
|
||||||
|
<button onClick={() => setQuickNoteLocName(null)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-gray-700">
|
||||||
|
📍 Địa điểm: <span className="text-blue-600">{quickNoteLocName}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nội dung ghi chú</label>
|
||||||
|
<textarea
|
||||||
|
value={quickNoteInput}
|
||||||
|
onChange={(e) => setQuickNoteInput(e.target.value)}
|
||||||
|
placeholder="Nhập nội dung ghi chú nhanh..."
|
||||||
|
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setQuickNoteLocName(null)}
|
||||||
|
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submitQuickNote}
|
||||||
|
disabled={!quickNoteInput.trim()}
|
||||||
|
className="py-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Lưu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Member Detail Popover */}
|
{/* Member Detail Popover */}
|
||||||
{isMemberDetailOpen && selectedMember && (
|
{isMemberDetailOpen && selectedMember && (
|
||||||
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface TourState {
|
|||||||
optimizeRouting: (legId: string) => Promise<void>;
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
addMember: (tourId: string, member: { userId: string; role?: string }) => Promise<any>;
|
addMember: (tourId: string, member: { userId: string; role?: string }) => Promise<any>;
|
||||||
removeMember: (tourId: string, userId: string) => Promise<void>;
|
removeMember: (tourId: string, userId: string) => Promise<void>;
|
||||||
|
updateMemberFamilyCount: (tourId: string, userId: string, adultCount: number, childCount: number) => Promise<void>;
|
||||||
setActiveLegId: (id: string | null) => void;
|
setActiveLegId: (id: string | null) => void;
|
||||||
setMapCenter: (pos: [number, number]) => void;
|
setMapCenter: (pos: [number, number]) => void;
|
||||||
fetchTour: (id: string) => Promise<void>;
|
fetchTour: (id: string) => Promise<void>;
|
||||||
@@ -329,6 +330,22 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
const { currentTour } = get();
|
const { currentTour } = get();
|
||||||
if (currentTour) get().fetchTour(currentTour.id);
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
|
updateMemberFamilyCount: async (tourId: string, userId: string, adultCount: number, childCount: number) => {
|
||||||
|
const response = await fetch(`/api/v1/tours/${tourId}/members/${userId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ adultCount, childCount }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.message || data.error || 'Lỗi khi cập nhật thành viên');
|
||||||
|
}
|
||||||
|
const { currentTour } = get();
|
||||||
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
|
},
|
||||||
createJoinRequest: async (tourId: string, userId?: string) => {
|
createJoinRequest: async (tourId: string, userId?: string) => {
|
||||||
const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
|
const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user