fix: thêm thành viên ngoài hệ thống vào Tour
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
|
||||
<option value="">-- Chọn người thanh toán --</option>
|
||||
{currentTour?.participants?.filter((p: any) => p.user)?.map((p: any) => {
|
||||
const name = p.user?.name;
|
||||
{currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.map((p: any) => {
|
||||
const name = p.user?.name || p.displayName;
|
||||
const email = p.user?.email;
|
||||
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
@@ -7,7 +7,7 @@ interface AddMemberModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
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 }>;
|
||||
onRemoveMember?: (userId: string) => Promise<void>;
|
||||
onMemberAdded?: () => void;
|
||||
@@ -29,12 +29,39 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
const confirm = useConfirm();
|
||||
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 visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
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;
|
||||
setActionLoading(reqId);
|
||||
try {
|
||||
@@ -162,9 +189,9 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<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">
|
||||
{participants.filter(p => p.user).map((p) => {
|
||||
{participants.filter(p => p.user || p.displayName).map((p) => {
|
||||
const rawToken = localStorage.getItem('token');
|
||||
let currentUserId: string | null = null;
|
||||
try {
|
||||
@@ -176,15 +203,16 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
const isCurrentUser = currentUserId && p.userId === currentUserId;
|
||||
const isOwner = p.role === 'OWNER';
|
||||
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
|
||||
const memberName = p.user?.name || p.displayName || p.userId || 'Thành viên';
|
||||
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="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>
|
||||
{canRemove && (
|
||||
<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"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
@@ -192,7 +220,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -217,7 +245,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
aria-label="Accept"
|
||||
>
|
||||
@@ -226,7 +254,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
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="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) => {
|
||||
const isSelected = selectedUser === u.id;
|
||||
return (
|
||||
|
||||
@@ -73,13 +73,19 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
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({
|
||||
title,
|
||||
description,
|
||||
startDate,
|
||||
endDate,
|
||||
memberIds,
|
||||
members: membersPayload,
|
||||
adultCount,
|
||||
childCount,
|
||||
childDiscount,
|
||||
@@ -97,18 +103,18 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
return (
|
||||
<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="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<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>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4 overflow-y-auto flex-1">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
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>
|
||||
<input
|
||||
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}
|
||||
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>
|
||||
<input
|
||||
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}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="relative">
|
||||
<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">
|
||||
{m.name}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember(m.id)}
|
||||
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"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none"
|
||||
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 className="space-y-2">
|
||||
<label className="block text-sm font-bold text-gray-700">Thành viên tham gia ({members.length})</label>
|
||||
{members.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3 mb-3 p-3 bg-gray-50 rounded-2xl border border-gray-100">
|
||||
{members.map((m) => {
|
||||
const initial = m.name?.charAt(0) || '?';
|
||||
return (
|
||||
<div key={m.id} className="flex flex-col items-center gap-1">
|
||||
<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">
|
||||
{initial}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember(m.id)}
|
||||
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"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{m.name}</span>
|
||||
</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>
|
||||
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1411,7 +1411,7 @@ export const TourDetailPage = ({
|
||||
const tourInfo = {
|
||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||
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",
|
||||
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 */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<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) => (
|
||||
<button
|
||||
key={p.userId || i}
|
||||
onClick={() => {
|
||||
setSelectedMember(p);
|
||||
setIsMemberDetailOpen(true);
|
||||
}}
|
||||
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"
|
||||
title={p.user?.name || p.userId}
|
||||
>
|
||||
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
||||
</button>
|
||||
))}
|
||||
{currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.slice(0, 5).map((p: any) => {
|
||||
const memberName = p.user?.name || p.displayName || 'Thành viên';
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setSelectedMember(p);
|
||||
setIsMemberDetailOpen(true);
|
||||
}}
|
||||
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"
|
||||
title={memberName}
|
||||
>
|
||||
{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
|
||||
<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">
|
||||
@@ -1589,18 +1596,16 @@ export const TourDetailPage = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isPublicView && ( // Hide add member button in public view
|
||||
{!isPublicView && canInvite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!currentTour) return;
|
||||
if (canInvite) setIsAddMemberOpen(true);
|
||||
setIsAddMemberOpen(true);
|
||||
}}
|
||||
disabled={!canInvite}
|
||||
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'
|
||||
}`}
|
||||
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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Thêm thành viên
|
||||
</button>
|
||||
)}
|
||||
</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="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">
|
||||
{selectedMember.user?.name?.charAt(0) || '?'}
|
||||
{selectedMember.user?.name?.charAt(0) || selectedMember.displayName?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-bold text-gray-900">{selectedMember.user?.name || 'Chưa đặt tên'}</div>
|
||||
<div className="text-xs text-gray-500">{selectedMember.user?.email}</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 || 'Thành viên ngoài hệ thống'}</div>
|
||||
<div className="text-[10px] font-semibold text-gray-500">{selectedMember.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2817,7 +2822,7 @@ export const TourDetailPage = ({
|
||||
onClick={async () => {
|
||||
if (!currentTour || !selectedMember) return;
|
||||
try {
|
||||
await removeMember(currentTour.id, selectedMember.userId);
|
||||
await removeMember(currentTour.id, selectedMember.userId || selectedMember.id);
|
||||
setIsMemberDetailOpen(false);
|
||||
} catch (e) {
|
||||
notify({ title: 'Thông báo', message: 'Không thể xóa thành viên', type: 'error' });
|
||||
|
||||
@@ -23,7 +23,7 @@ interface TourState {
|
||||
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
|
||||
updateTourEndPoint: (tourId: string, data: any) => 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>;
|
||||
updateMemberFamilyCount: (tourId: string, userId: string, adultCount: number, childCount: number) => Promise<void>;
|
||||
setActiveLegId: (id: string | null) => void;
|
||||
@@ -314,7 +314,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
||||
const { currentTour } = get();
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user