fix: thêm thành viên ngoài hệ thống vào Tour

This commit is contained in:
2026-06-20 17:56:56 +07:00
parent ae97f061e8
commit 52706cab7d
17 changed files with 533 additions and 141 deletions
+39
View File
@@ -0,0 +1,39 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5';
const ownerUserId = '5b2053bb-f523-4a11-817c-f47fef7322bb'; // owner@travel.com
// Check if owner@travel.com is already a participant
const existing = await prisma.tourParticipant.findFirst({
where: { tourId, userId: ownerUserId }
});
if (existing) {
await prisma.tourParticipant.update({
where: { id: existing.id },
data: { role: 'OWNER' }
});
console.log('Updated existing participant to OWNER');
} else {
// Demote current owner to MEMBER or just keep them
const result = await prisma.tourParticipant.create({
data: {
tourId,
userId: ownerUserId,
role: 'OWNER'
}
});
console.log('Created new OWNER participant:', result);
}
// Update tour createdById to ownerUserId
await prisma.tour.update({
where: { id: tourId },
data: { createdById: ownerUserId }
});
console.log('Updated tour creator to owner@travel.com');
}
main().catch(console.error).finally(() => prisma.$disconnect());
+77 -15
View File
@@ -555,7 +555,7 @@ let TourController = class TourController {
this.cacheManager = cacheManager; this.cacheManager = cacheManager;
} }
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, members } = body;
const tour = await this.prisma.tour.create({ const tour = await this.prisma.tour.create({
data: { data: {
title, title,
@@ -568,10 +568,19 @@ let TourController = class TourController {
childDiscount: childDiscount || 0, childDiscount: childDiscount || 0,
createdById: req.user.id, createdById: req.user.id,
participants: { participants: {
create: { create: [
userId: req.user.id, {
role: 'OWNER' userId: req.user.id,
} role: 'OWNER'
},
...(members && Array.isArray(members)
? members.map((m) => ({
userId: m.userId || null,
displayName: m.displayName || null,
role: m.role || 'MEMBER'
}))
: [])
]
}, },
legs: { legs: {
create: { create: {
@@ -610,6 +619,13 @@ let TourController = class TourController {
} }
}).then(async (loc) => { }).then(async (loc) => {
if (body.expenseAmount && Number(body.expenseAmount) > 0) { if (body.expenseAmount && Number(body.expenseAmount) > 0) {
let paidById = body.paidById || null;
if (paidById) {
const userExists = await this.prisma.user.findUnique({ where: { id: paidById } });
if (!userExists) {
paidById = null;
}
}
await this.prisma.expense.create({ await this.prisma.expense.create({
data: { data: {
amount: Number(body.expenseAmount), amount: Number(body.expenseAmount),
@@ -618,7 +634,7 @@ let TourController = class TourController {
legId: loc.legId, legId: loc.legId,
description: body.expenseDescription || `Chi phí tại ${loc.name}`, description: body.expenseDescription || `Chi phí tại ${loc.name}`,
note: body.expenseNote || null, note: body.expenseNote || null,
paidById: body.paidById || null, paidById,
} }
}); });
} }
@@ -794,7 +810,9 @@ let TourController = class TourController {
if (!tour) if (!tour)
throw new common_1.NotFoundException('Không tìm thấy tour'); throw new common_1.NotFoundException('Không tìm thấy tour');
for (const participant of tour.participants) { for (const participant of tour.participants) {
await this.cacheManager.del(`user-role:${participant.userId}:${id}`); if (participant.userId) {
await this.cacheManager.del(`user-role:${participant.userId}:${id}`);
}
} }
await this.cacheManager.del(`res-to-tour:${id}`); await this.cacheManager.del(`res-to-tour:${id}`);
for (const leg of tour.legs) { for (const leg of tour.legs) {
@@ -898,6 +916,25 @@ let TourController = class TourController {
async addMember(tourId, body, req) { async addMember(tourId, body, req) {
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY']; const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'];
const role = validRoles.includes(body.role) ? body.role : 'MEMBER'; const role = validRoles.includes(body.role) ? body.role : 'MEMBER';
if (body.displayName) {
const participation = await this.prisma.tourParticipant.create({
data: {
tourId,
role,
displayName: body.displayName,
},
include: { user: { select: { id: true, name: true, email: true } } },
});
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return participation;
}
if (!body.userId) {
throw new common_1.BadRequestException('Vui lòng cung cấp userId hoặc displayName');
}
await this.cacheManager.del(`user-role:${body.userId}:${tourId}`); await this.cacheManager.del(`user-role:${body.userId}:${tourId}`);
const participation = await this.prisma.tourParticipant.findUnique({ const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: body.userId } }, where: { tourId_userId: { tourId, userId: body.userId } },
@@ -941,7 +978,18 @@ let TourController = class TourController {
}); });
} }
async updateMemberCounts(tourId, userId, body, req) { async updateMemberCounts(tourId, userId, body, req) {
const isSelf = req.user.id === userId; const participant = await this.prisma.tourParticipant.findFirst({
where: {
OR: [
{ id: userId },
{ tourId, userId }
]
}
});
if (!participant) {
throw new common_1.NotFoundException('Không tìm thấy thành viên trong tour.');
}
const isSelf = req.user.id === participant.userId;
const requesterParticipation = await this.prisma.tourParticipant.findUnique({ const requesterParticipation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: req.user.id } }, where: { tourId_userId: { tourId, userId: req.user.id } },
}); });
@@ -966,14 +1014,16 @@ let TourController = class TourController {
data.role = body.role; data.role = body.role;
} }
} }
if (participant.userId) {
await this.cacheManager.del(`user-role:${participant.userId}:${tourId}`);
}
await Promise.all([ await Promise.all([
this.cacheManager.del(`user-role:${userId}:${tourId}`),
this.cacheManager.del(tourId), this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]); ]);
return this.prisma.tourParticipant.update({ return this.prisma.tourParticipant.update({
where: { tourId_userId: { tourId, userId } }, where: { id: participant.id },
data, data,
include: { user: { select: { id: true, name: true, email: true } } }, include: { user: { select: { id: true, name: true, email: true } } },
}); });
@@ -1090,15 +1140,27 @@ let TourController = class TourController {
return { success: true, message: 'Đã từ chối yêu cầu tham gia.' }; return { success: true, message: 'Đã từ chối yêu cầu tham gia.' };
} }
async removeMember(tourId, userId) { async removeMember(tourId, userId) {
const participation = await this.prisma.tourParticipant.findUnique({ const participant = await this.prisma.tourParticipant.findFirst({
where: { tourId_userId: { tourId, userId } }, where: {
OR: [
{ id: userId },
{ tourId, userId }
]
}
}); });
if (!participation) { if (!participant) {
throw new common_1.NotFoundException('Thành viên này không có trong tour'); throw new common_1.NotFoundException('Thành viên này không có trong tour');
} }
await this.cacheManager.del(`user-role:${userId}:${tourId}`); if (participant.userId) {
await this.cacheManager.del(`user-role:${participant.userId}:${tourId}`);
}
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
await this.prisma.tourParticipant.delete({ await this.prisma.tourParticipant.delete({
where: { tourId_userId: { tourId, userId } }, where: { id: participant.id },
}); });
return { message: 'Đã xóa thành viên khỏi tour' }; return { message: 'Đã xóa thành viên khỏi tour' };
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
/*
Warnings:
- The primary key for the `TourParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[tourId,userId]` on the table `TourParticipant` will be added. If there are existing duplicate values, this will fail.
- The required column `id` was added to the `TourParticipant` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "TourParticipant" DROP CONSTRAINT "TourParticipant_pkey",
ADD COLUMN "displayName" TEXT,
ADD COLUMN "id" TEXT;
UPDATE "TourParticipant" SET "id" = md5(random()::text);
ALTER TABLE "TourParticipant" ALTER COLUMN "id" SET NOT NULL,
ALTER COLUMN "userId" DROP NOT NULL;
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("id");
-- CreateIndex
CREATE UNIQUE INDEX "TourParticipant_tourId_userId_key" ON "TourParticipant"("tourId", "userId");
+81 -16
View File
@@ -531,7 +531,7 @@ class TourController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@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, members } = body;
const tour = await this.prisma.tour.create({ const tour = await this.prisma.tour.create({
data: { data: {
title, title,
@@ -544,10 +544,19 @@ class TourController {
childDiscount: childDiscount || 0, childDiscount: childDiscount || 0,
createdById: req.user.id, createdById: req.user.id,
participants: { participants: {
create: { create: [
userId: req.user.id, {
role: 'OWNER' userId: req.user.id,
} role: 'OWNER'
},
...(members && Array.isArray(members)
? members.map((m: any) => ({
userId: m.userId || null,
displayName: m.displayName || null,
role: m.role || 'MEMBER'
}))
: [])
]
}, },
legs: { legs: {
create: { create: {
@@ -594,6 +603,13 @@ class TourController {
} }
}).then(async (loc) => { }).then(async (loc) => {
if (body.expenseAmount && Number(body.expenseAmount) > 0) { if (body.expenseAmount && Number(body.expenseAmount) > 0) {
let paidById = body.paidById || null;
if (paidById) {
const userExists = await this.prisma.user.findUnique({ where: { id: paidById } });
if (!userExists) {
paidById = null;
}
}
await this.prisma.expense.create({ await this.prisma.expense.create({
data: { data: {
amount: Number(body.expenseAmount), amount: Number(body.expenseAmount),
@@ -602,7 +618,7 @@ class TourController {
legId: loc.legId, legId: loc.legId,
description: body.expenseDescription || `Chi phí tại ${loc.name}`, description: body.expenseDescription || `Chi phí tại ${loc.name}`,
note: body.expenseNote || null, note: body.expenseNote || null,
paidById: body.paidById || null, paidById,
} }
}); });
} }
@@ -832,7 +848,9 @@ class TourController {
// --- BẮT ĐẦU DỌN DẸP CACHE --- // --- BẮT ĐẦU DỌN DẸP CACHE ---
// a. Xóa cache vai trò của tất cả thành viên trong tour này // a. Xóa cache vai trò của tất cả thành viên trong tour này
for (const participant of tour.participants) { for (const participant of tour.participants) {
await this.cacheManager.del(`user-role:${participant.userId}:${id}`); if (participant.userId) {
await this.cacheManager.del(`user-role:${participant.userId}:${id}`);
}
} }
// b. Xóa cache mapping tài nguyên (Leg, Location, Photo) về Tour này // b. Xóa cache mapping tài nguyên (Leg, Location, Photo) về Tour này
@@ -961,10 +979,31 @@ class TourController {
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add members @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add members
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/members') @Post(':tourId/members')
async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId: string; role?: string }, @Req() req: any) { async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string; displayName?: string; role?: string }, @Req() req: any) {
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const; const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const;
const role = validRoles.includes(body.role as any) ? body.role as 'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY' : 'MEMBER'; const role = validRoles.includes(body.role as any) ? body.role as 'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY' : 'MEMBER';
if (body.displayName) {
const participation = await this.prisma.tourParticipant.create({
data: {
tourId,
role,
displayName: body.displayName,
},
include: { user: { select: { id: true, name: true, email: true } } },
});
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return participation;
}
if (!body.userId) {
throw new BadRequestException('Vui lòng cung cấp userId hoặc displayName');
}
// Xóa cache khi thay đổi quyền hạn hoặc thêm thành viên mới // Xóa cache khi thay đổi quyền hạn hoặc thêm thành viên mới
await this.cacheManager.del(`user-role:${body.userId}:${tourId}`); await this.cacheManager.del(`user-role:${body.userId}:${tourId}`);
@@ -1021,7 +1060,19 @@ class TourController {
@Body() body: { adultCount?: number; childCount?: number; role?: string }, @Body() body: { adultCount?: number; childCount?: number; role?: string },
@Req() req: any @Req() req: any
) { ) {
const isSelf = req.user.id === userId; const participant = await this.prisma.tourParticipant.findFirst({
where: {
OR: [
{ id: userId },
{ tourId, userId }
]
}
});
if (!participant) {
throw new NotFoundException('Không tìm thấy thành viên trong tour.');
}
const isSelf = req.user.id === participant.userId;
// Tìm quyền hạn của người gửi yêu cầu trong tour này // 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({ const requesterParticipation = await this.prisma.tourParticipant.findUnique({
@@ -1050,15 +1101,17 @@ class TourController {
} }
// Xóa cache liên quan // Xóa cache liên quan
if (participant.userId) {
await this.cacheManager.del(`user-role:${participant.userId}:${tourId}`);
}
await Promise.all([ await Promise.all([
this.cacheManager.del(`user-role:${userId}:${tourId}`),
this.cacheManager.del(tourId), this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]); ]);
return this.prisma.tourParticipant.update({ return this.prisma.tourParticipant.update({
where: { tourId_userId: { tourId, userId } }, where: { id: participant.id },
data, data,
include: { user: { select: { id: true, name: true, email: true } } }, include: { user: { select: { id: true, name: true, email: true } } },
}); });
@@ -1208,17 +1261,29 @@ class TourController {
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Delete(':tourId/members/:userId') @Delete(':tourId/members/:userId')
async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) { async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) {
const participation = await this.prisma.tourParticipant.findUnique({ const participant = await this.prisma.tourParticipant.findFirst({
where: { tourId_userId: { tourId, userId } }, where: {
OR: [
{ id: userId },
{ tourId, userId }
]
}
}); });
if (!participation) { if (!participant) {
throw new NotFoundException('Thành viên này không có trong tour'); throw new NotFoundException('Thành viên này không có trong tour');
} }
await this.cacheManager.del(`user-role:${userId}:${tourId}`); if (participant.userId) {
await this.cacheManager.del(`user-role:${participant.userId}:${tourId}`);
}
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
await this.prisma.tourParticipant.delete({ await this.prisma.tourParticipant.delete({
where: { tourId_userId: { tourId, userId } }, where: { id: participant.id },
}); });
return { message: 'Đã xóa thành viên khỏi tour' }; return { message: 'Đã xóa thành viên khỏi tour' };
} }
+28
View File
@@ -0,0 +1,28 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'owner@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Tour ID from seed
const res = await fetch(`http://localhost:3001/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ displayName: 'Offline Member X', role: 'MEMBER' })
});
console.log('Add status:', res.status);
const data = await res.json();
console.log('Add response:', data);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
+14
View File
@@ -0,0 +1,14 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const users = await prisma.user.findMany();
console.log('--- Users in DB ---');
console.log(users);
const participants = await prisma.tourParticipant.findMany();
console.log('--- Tour Participants in DB ---');
console.log(participants);
}
main().catch(console.error).finally(() => prisma.$disconnect());
+17
View File
@@ -0,0 +1,17 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tours = await prisma.tour.findMany({
include: {
participants: {
include: {
user: { select: { email: true, name: true } }
}
}
}
});
console.log(JSON.stringify(tours, null, 2));
}
main().finally(() => prisma.$disconnect());
+36
View File
@@ -0,0 +1,36 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Use existing tour ID from seed
console.log('Inserting first manual member...');
try {
const p1 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 1'
}
});
console.log('Inserted:', p1);
} catch (err) {
console.error('Failed to insert first:', err);
}
console.log('Inserting second manual member...');
try {
const p2 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 2'
}
});
console.log('Inserted:', p2);
} catch (err) {
console.error('Failed to insert second:', err);
}
}
main().catch(console.error).finally(() => prisma.$disconnect());
+22
View File
@@ -0,0 +1,22 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'photomember@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const usersRes = await fetch('http://localhost:3001/api/v1/users?q=owner@travel.com', {
headers: { 'Authorization': `Bearer ${token}` }
});
console.log('Users status:', usersRes.status);
const usersData = await usersRes.json();
console.log('Users data:', usersData);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -498,12 +498,12 @@ 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?.filter((p: any) => p.user)?.map((p: any) => { {currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.map((p: any) => {
const name = p.user?.name; const name = p.user?.name || p.displayName;
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(' ');
return ( return (
<option key={p.userId} value={p.userId}>{label || p.userId}</option> <option key={p.id} value={p.userId || p.id}>{label || p.userId || p.id}</option>
); );
})} })}
</select> </select>
+61 -12
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react'; import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
import { useConfirm } from '@/hooks/useConfirm'; import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
@@ -7,7 +7,7 @@ interface AddMemberModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
tourId: string; tourId: string;
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>; participants?: Array<{ id: string; userId?: string | null; role: string; displayName?: string | null; user?: { id: string; name: string; email: string } | null }>;
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>; joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
onRemoveMember?: (userId: string) => Promise<void>; onRemoveMember?: (userId: string) => Promise<void>;
onMemberAdded?: () => void; onMemberAdded?: () => void;
@@ -29,12 +29,39 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const confirm = useConfirm(); const confirm = useConfirm();
const notify = useNotification(); const notify = useNotification();
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]); const participantIds = useMemo(() => new Set(participants.map((p) => p.userId).filter(Boolean) as string[]), [participants]);
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]); const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]); const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER'; const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
const handleManualAdd = async () => {
const name = query.trim();
if (!name) return;
setSubmitting(true);
setSubmitError('');
try {
const res = await fetch(`/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ displayName: name, role }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || data.error || 'Thao tác thất bại');
}
await onMemberAdded?.();
onClose();
} catch (err: any) {
setSubmitError(err.message || 'Thao tác thất bại');
} finally {
setSubmitting(false);
}
};
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true); setLoading(true);
setFetchError(''); setFetchError('');
@@ -87,7 +114,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
} }
}; };
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject', userName: string) => { const handleRequestAction = async (reqId: string, action: 'accept' | 'reject') => {
if (!onMemberAdded) return; if (!onMemberAdded) return;
setActionLoading(reqId); setActionLoading(reqId);
try { try {
@@ -162,9 +189,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.filter(p => p.user).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 || p.displayName).length})</p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{participants.filter(p => p.user).map((p) => { {participants.filter(p => p.user || p.displayName).map((p) => {
const rawToken = localStorage.getItem('token'); const rawToken = localStorage.getItem('token');
let currentUserId: string | null = null; let currentUserId: string | null = null;
try { try {
@@ -176,15 +203,16 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const isCurrentUser = currentUserId && p.userId === currentUserId; const isCurrentUser = currentUserId && p.userId === currentUserId;
const isOwner = p.role === 'OWNER'; const isOwner = p.role === 'OWNER';
const canRemove = onRemoveMember && !isCurrentUser && !isOwner; const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
const memberName = p.user?.name || p.displayName || p.userId || 'Thành viên';
return ( return (
<div key={p.userId} className="flex flex-col items-center gap-1"> <div key={p.id} className="flex flex-col items-center gap-1">
<div className="relative"> <div className="relative">
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden"> <div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
{p.user?.name?.charAt(0) || '?'} {memberName.charAt(0)}
</div> </div>
{canRemove && ( {canRemove && (
<button <button
onClick={() => handleRemove(p.userId, p.user?.name || p.userId)} onClick={() => handleRemove(p.userId || p.id, memberName)}
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white" className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
aria-label="Remove item" aria-label="Remove item"
> >
@@ -192,7 +220,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</button> </button>
)} )}
</div> </div>
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{p.user?.name || p.userId}</span> <span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{memberName}</span>
</div> </div>
); );
})} })}
@@ -217,7 +245,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<button <button
type="button" type="button"
disabled={actionLoading === req.id} disabled={actionLoading === req.id}
onClick={() => handleRequestAction(req.id, 'accept', req.user?.name || req.userId)} onClick={() => handleRequestAction(req.id, 'accept')}
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50" className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
aria-label="Accept" aria-label="Accept"
> >
@@ -226,7 +254,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<button <button
type="button" type="button"
disabled={actionLoading === req.id} disabled={actionLoading === req.id}
onClick={() => handleRequestAction(req.id, 'reject', req.user?.name || req.userId)} onClick={() => handleRequestAction(req.id, 'reject')}
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1" className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
aria-label="Reject" aria-label="Reject"
> >
@@ -293,6 +321,27 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div> <div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
) : ( ) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1"> <div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
{query.trim() && canCreateDirectly && (
<button
type="button"
onClick={handleManualAdd}
disabled={submitting}
className="w-full flex items-center gap-3 p-3 rounded-2xl border border-dashed border-blue-300 hover:border-blue-400 bg-blue-50/20 text-blue-700 transition-all text-left mb-2"
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
+
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold">Thêm thành viên thủ công</div>
<div className="text-[11px] text-gray-500">Thêm "{query.trim()}" trực tiếp vào danh sách thành viên</div>
</div>
{submitting ? (
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
) : (
<span className="px-2.5 py-1 bg-blue-600 text-white rounded-xl text-xs font-bold transition-all">Thêm</span>
)}
</button>
)}
{visibleUsers.map((u) => { {visibleUsers.map((u) => {
const isSelected = selectedUser === u.id; const isSelected = selectedUser === u.id;
return ( return (
+78 -50
View File
@@ -73,13 +73,19 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
try { try {
const memberIds = members.map((m) => m.id); const membersPayload = members.map((m) => {
if (m.isManual) {
return { displayName: m.name };
} else {
return { userId: m.id };
}
});
const tour = await createTour({ const tour = await createTour({
title, title,
description, description,
startDate, startDate,
endDate, endDate,
memberIds, members: membersPayload,
adultCount, adultCount,
childCount, childCount,
childDiscount, childDiscount,
@@ -97,18 +103,18 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
return ( return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} /> <div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6"> <div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center mb-4"> <div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2> <h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors"></button> <button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors"></button>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="p-5 space-y-4 overflow-y-auto flex-1">
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label> <label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
<input <input
required required
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="VD: Khám phá Đà Lạt" placeholder="VD: Khám phá Đà Lạt"
@@ -170,7 +176,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label> <label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label>
<input <input
type="date" type="date"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => setStartDate(e.target.value)}
/> />
@@ -179,7 +185,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label> <label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
<input <input
type="date" type="date"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setEndDate(e.target.value)}
/> />
@@ -211,49 +217,71 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<p className="text-[10px] text-blue-400 italic font-medium">* Dùng đ tính toán đơn giá bình quân trong báo cáo chi phí.</p> <p className="text-[10px] text-blue-400 italic font-medium">* Dùng đ tính toán đơn giá bình quân trong báo cáo chi phí.</p>
</div> </div>
<div> <div className="space-y-2">
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label> <label className="block text-sm font-bold text-gray-700">Thành viên tham gia ({members.length})</label>
<div className="flex flex-wrap gap-3"> {members.length > 0 && (
{members.map((m) => ( <div className="flex flex-wrap gap-3 mb-3 p-3 bg-gray-50 rounded-2xl border border-gray-100">
<div key={m.id} className="relative"> {members.map((m) => {
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden"> const initial = m.name?.charAt(0) || '?';
{m.name} return (
</div> <div key={m.id} className="flex flex-col items-center gap-1">
<button <div className="relative">
type="button" <div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
onClick={() => removeMember(m.id)} {initial}
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors" </div>
aria-label="Remove item" <button
> type="button"
<Trash2 size={12} /> onClick={() => removeMember(m.id)}
</button> className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div> aria-label="Remove item"
</div> >
))} <Trash2 size={10} />
</button>
<div className="relative"> </div>
<input <span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{m.name}</span>
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none" </div>
placeholder="Tìm email..." );
value={query} })}
onChange={(e) => searchUsers(e.target.value)}
/>
{results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto">
{results.map((u) => (
<button
key={u.id}
type="button"
onClick={() => confirmAddMember(u)}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50"
>
<span className="font-bold text-gray-900">{u.name}</span>
<span className="block text-xs text-gray-500">{u.email}</span>
</button>
))}
</div>
)}
</div> </div>
)}
<div className="relative">
<input
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm font-bold text-gray-800"
placeholder="Tìm email hoặc nhập tên thành viên ngoài hệ thống..."
value={query}
onChange={(e) => searchUsers(e.target.value)}
/>
{(results.length > 0 || query.trim()) && (
<div className="absolute bottom-full mb-2 left-0 right-0 bg-white border border-gray-100 rounded-2xl shadow-xl z-20 max-h-48 overflow-y-auto p-2 space-y-1">
{query.trim() && (
<button
type="button"
onClick={() => {
const name = query.trim();
setMembers((prev) => (prev.some((m) => m.name.toLowerCase() === name.toLowerCase()) ? prev : [...prev, { id: `manual-${Date.now()}`, name, isManual: true }]));
setQuery('');
setResults([]);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 rounded-xl text-blue-600 font-bold flex items-center gap-2"
>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-sm font-black">+</span>
<span>Thêm thành viên ngoài hệ thống: "{query.trim()}"</span>
</button>
)}
{results.filter(u => !members.some(m => m.id === u.id)).map((u) => (
<button
key={u.id}
type="button"
onClick={() => confirmAddMember(u)}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 rounded-xl flex flex-col"
>
<span className="font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</span>
<span className="text-xs text-gray-500">{u.email}</span>
</button>
))}
</div>
)}
</div> </div>
{error && <p className="text-xs text-red-600 mt-2">{error}</p>} {error && <p className="text-xs text-red-600 mt-2">{error}</p>}
</div> </div>
File diff suppressed because one or more lines are too long
+30 -25
View File
@@ -1411,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?.filter((p: any) => p.user)?.length || 0, membersCount: currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.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"
}; };
@@ -1507,19 +1507,26 @@ 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?.filter((p: any) => p.user)?.slice(0, 5).map((p: any, i: number) => ( {currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.slice(0, 5).map((p: any) => {
<button const memberName = p.user?.name || p.displayName || 'Thành viên';
key={p.userId || i} return (
onClick={() => { <button
setSelectedMember(p); key={p.id}
setIsMemberDetailOpen(true); onClick={() => {
}} setSelectedMember(p);
className="w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform" setIsMemberDetailOpen(true);
title={p.user?.name || p.userId} }}
> className="w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform"
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" /> title={memberName}
</button> >
))} {p.user ? (
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
) : (
<span>{memberName.charAt(0)}</span>
)}
</button>
);
})}
{isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view {isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view
<div key={req.id} className="relative group"> <div key={req.id} className="relative group">
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg"> <div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
@@ -1589,18 +1596,16 @@ export const TourDetailPage = ({
</div> </div>
)} )}
</div> </div>
{!isPublicView && ( // Hide add member button in public view {!isPublicView && canInvite && (
<button <button
onClick={() => { onClick={() => {
if (!currentTour) return; if (!currentTour) return;
if (canInvite) setIsAddMemberOpen(true); setIsAddMemberOpen(true);
}} }}
disabled={!canInvite} className="flex items-center gap-1.5 px-3 py-2 rounded-full border border-white/20 bg-white/10 hover:bg-white/20 text-white font-bold text-xs transition-all ml-2 shadow-md hover:scale-105 active:scale-95"
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
canInvite ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
}`}
> >
<Plus className="w-4 h-4" /> <Plus className="w-3.5 h-3.5" />
Thêm thành viên
</button> </button>
)} )}
</div> </div>
@@ -2796,11 +2801,11 @@ export const TourDetailPage = ({
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5"> <div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold"> <div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold">
{selectedMember.user?.name?.charAt(0) || '?'} {selectedMember.user?.name?.charAt(0) || selectedMember.displayName?.charAt(0) || '?'}
</div> </div>
<div> <div>
<div className="text-base font-bold text-gray-900">{selectedMember.user?.name || 'Chưa đặt tên'}</div> <div className="text-base font-bold text-gray-900">{selectedMember.user?.name || selectedMember.displayName || 'Chưa đặt tên'}</div>
<div className="text-xs text-gray-500">{selectedMember.user?.email}</div> <div className="text-xs text-gray-500">{selectedMember.user?.email || 'Thành viên ngoài hệ thống'}</div>
<div className="text-[10px] font-semibold text-gray-500">{selectedMember.role}</div> <div className="text-[10px] font-semibold text-gray-500">{selectedMember.role}</div>
</div> </div>
</div> </div>
@@ -2817,7 +2822,7 @@ export const TourDetailPage = ({
onClick={async () => { onClick={async () => {
if (!currentTour || !selectedMember) return; if (!currentTour || !selectedMember) return;
try { try {
await removeMember(currentTour.id, selectedMember.userId); await removeMember(currentTour.id, selectedMember.userId || selectedMember.id);
setIsMemberDetailOpen(false); setIsMemberDetailOpen(false);
} catch (e) { } catch (e) {
notify({ title: 'Thông báo', message: 'Không thể xóa thành viên', type: 'error' }); notify({ title: 'Thông báo', message: 'Không thể xóa thành viên', type: 'error' });
+2 -2
View File
@@ -23,7 +23,7 @@ interface TourState {
updateTourStartPoint: (tourId: string, data: any) => Promise<void>; updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
updateTourEndPoint: (tourId: string, data: any) => Promise<void>; updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
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; displayName?: 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>; updateMemberFamilyCount: (tourId: string, userId: string, adultCount: number, childCount: number) => Promise<void>;
setActiveLegId: (id: string | null) => void; setActiveLegId: (id: string | null) => void;
@@ -314,7 +314,7 @@ 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);
}, },
addMember: async (tourId: string, member: { userId: string; role?: string }) => { addMember: async (tourId: string, member: { userId?: string; displayName?: string; role?: string }) => {
const response = await fetch(`/api/v1/tours/${tourId}/members`, { const response = await fetch(`/api/v1/tours/${tourId}/members`, {
method: 'POST', method: 'POST',
headers: { headers: {