fix: khách mời join vào tour gán với tên thêm thủ công

This commit is contained in:
2026-06-21 07:37:20 +07:00
parent 55fba75fda
commit 392a4d4766
6 changed files with 544 additions and 6 deletions
+1
View File
@@ -1480,6 +1480,7 @@ let TourController = class TourController {
await Promise.all([ await Promise.all([
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/explore`) this.cacheManager.del(`/api/v1/tours/explore`)
]); ]);
return { success: true, message: 'Đã gán và hợp nhất thông tin thành viên thành công.' }; return { success: true, message: 'Đã gán và hợp nhất thông tin thành viên thành công.' };
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -1663,6 +1663,7 @@ class TourController {
await Promise.all([ await Promise.all([
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/explore`) this.cacheManager.del(`/api/v1/tours/explore`)
]); ]);
+2 -2
View File
@@ -142,7 +142,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
} }
}, [isOpen]); }, [isOpen]);
const handleRemove = async (userId: string, memberName: string) => { const handleRemove = async (memberIdOrUserId: string, memberName: string) => {
if (!onRemoveMember) return; if (!onRemoveMember) return;
const isConfirmed = await confirm({ const isConfirmed = await confirm({
title: 'Xóa thành viên', title: 'Xóa thành viên',
@@ -151,7 +151,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
if (isConfirmed) { if (isConfirmed) {
try { try {
await onRemoveMember(userId); await onRemoveMember(memberIdOrUserId);
} catch (err: any) { } catch (err: any) {
setSubmitError(err.message || 'Không thể xóa thành viên'); setSubmitError(err.message || 'Không thể xóa thành viên');
} }
+510
View File
@@ -0,0 +1,510 @@
import React, { useState, useMemo } from 'react';
import {
Users, User, Shield, ShieldAlert, ShieldCheck, Mail, Trash2,
Clock, Check, X, GitMerge, ArrowRight, Search, Plus, Sparkles
} from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
interface MembersTabProps {
tourId: string;
participants: any[];
joinRequests: any[];
userRole: string | null;
canManage: boolean;
isOwner: boolean;
onRemoveMember: (memberIdOrUserId: string) => Promise<void>;
onRefresh: () => void;
onOpenAddMember?: () => void;
}
export const MembersTab: React.FC<MembersTabProps> = ({
tourId,
participants,
joinRequests: initialJoinRequests,
canManage,
isOwner,
onRemoveMember,
onRefresh,
onOpenAddMember
}) => {
const confirm = useConfirm();
const notify = useNotification();
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
const [joinRequests, setJoinRequests] = useState<any[]>(initialJoinRequests);
const [mergingId, setMergingId] = useState<string | null>(null);
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
const [assigningManualMember, setAssigningManualMember] = useState<any | null>(null);
const [systemSearchQuery, setSystemSearchQuery] = useState('');
// Sync state with props
React.useEffect(() => {
setJoinRequests(initialJoinRequests);
}, [initialJoinRequests]);
// Separate system vs manual members
const systemMembers = useMemo(() => {
return participants.filter((p: any) => p.userId && p.user);
}, [participants]);
const manualMembers = useMemo(() => {
return participants.filter((p: any) => !p.userId && p.displayName);
}, [participants]);
// Auto-detect duplicate matches based on case-insensitive names
const duplicateMatches = useMemo(() => {
const matches: Array<{ manual: any; system: any }> = [];
manualMembers.forEach((m: any) => {
const match = systemMembers.find((s: any) => {
return s.user.name.trim().toLowerCase() === m.displayName.trim().toLowerCase();
});
if (match) {
matches.push({ manual: m, system: match });
}
});
return matches;
}, [systemMembers, manualMembers]);
// Filter system members for manual merge modal
const filteredSystemMembersForMerge = useMemo(() => {
if (!systemSearchQuery.trim()) return systemMembers;
return systemMembers.filter((s: any) =>
s.user.name.toLowerCase().includes(systemSearchQuery.toLowerCase()) ||
(s.user.email && s.user.email.toLowerCase().includes(systemSearchQuery.toLowerCase()))
);
}, [systemMembers, systemSearchQuery]);
// Handle merging logic
const handleMerge = async (manualParticipantId: string, systemUserId: string, manualName: string, systemName: string) => {
const isConfirmed = await confirm({
title: 'Hợp nhất thành viên',
message: `Bạn có chắc muốn hợp nhất thành viên thủ công "${manualName}" vào tài khoản "${systemName}"? Bản ghi thủ công sẽ bị xóa và các dữ liệu liên quan sẽ được gộp.`
});
if (!isConfirmed) return;
setMergingId(manualParticipantId);
try {
const res = await fetch(`/api/v1/tours/${tourId}/members/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
manualParticipantId,
systemUserId
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Hợp nhất thất bại.');
}
notify({ title: 'Thành công', message: 'Hợp nhất thành viên thành công!', type: 'success' });
setAssigningManualMember(null);
onRefresh();
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể hợp nhất', type: 'error' });
} finally {
setMergingId(null);
}
};
const getRoleBadge = (role: string) => {
switch (role) {
case 'OWNER':
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-red-50 text-red-700 border border-red-100 text-[10px] font-bold">
<ShieldAlert className="w-3 h-3 text-red-500" /> Trưởng đoàn
</span>
);
case 'MANAGER':
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-blue-50 text-blue-700 border border-blue-100 text-[10px] font-bold">
<ShieldCheck className="w-3 h-3 text-blue-500" /> Phó đoàn
</span>
);
case 'MEMBER':
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-green-50 text-green-700 border border-green-100 text-[10px] font-bold">
<Shield className="w-3 h-3 text-green-500" /> Thành viên
</span>
);
case 'MEMBER_NO_FINANCE':
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-gray-50 text-gray-600 border border-gray-100 text-[10px] font-bold">
<Shield className="w-3 h-3 text-gray-400" /> Thành viên (Không xem quỹ)
</span>
);
default:
return (
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-gray-100 text-gray-600 text-[10px] font-semibold">
{role}
</span>
);
}
};
// Get current user id to prevent self-deletion
const getCurrentUserId = () => {
const rawToken = localStorage.getItem('token');
if (!rawToken) return null;
try {
const payload = JSON.parse(atob(rawToken.split('.')[1]));
return payload.sub;
} catch {
return null;
}
};
const currentUserId = getCurrentUserId();
return (
<div className="space-y-6">
{/* Header and Actions */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-white p-5 rounded-3xl border border-gray-100 shadow-sm animate-in fade-in duration-300">
<div>
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
<Users className="w-6 h-6 text-indigo-600" /> Quản thành viên
</h2>
<p className="text-xs text-gray-500 mt-1">
Quản thành viên hệ thống, thành viên thủ công các yêu cầu tham gia chuyến đi.
</p>
</div>
{canManage && onOpenAddMember && (
<button
onClick={onOpenAddMember}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-2xl bg-indigo-600 hover:bg-indigo-700 active:scale-95 text-white font-bold text-xs transition-all shadow-md shadow-indigo-100"
>
<Plus className="w-4 h-4" />
Thêm & Mời thành viên
</button>
)}
</div>
{/* Auto-detect duplicates alert */}
{duplicateMatches.length > 0 && (
<div className="p-5 bg-amber-50 rounded-3xl border border-amber-200/60 text-amber-900 space-y-3 shadow-sm animate-in slide-in-from-top-4 duration-300">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-amber-500 animate-pulse" />
<h4 className="font-bold text-sm">Phát hiện trùng lặp tự đng</h4>
</div>
<p className="text-xs text-amber-700">
Hệ thống phát hiện thành viên đưc tạo thủ công trùng tên với tài khoản hệ thống mới gia nhập. Bạn nên gộp họ lại đ đng bộ thông tin chặng đi chi phí.
</p>
<div className="space-y-2 mt-2">
{duplicateMatches.map((match) => (
<div
key={match.manual.id}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-white rounded-2xl border border-amber-200 shadow-sm text-xs"
>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-gray-800">Thành viên thủ công: "{match.manual.displayName}"</span>
<ArrowRight className="w-3.5 h-3.5 text-amber-500" />
<span className="font-bold text-indigo-700">Tài khoản hệ thống: "{match.system.user.name}"</span>
</div>
<button
disabled={mergingId === match.manual.id}
onClick={() => handleMerge(match.manual.id, match.system.userId, match.manual.displayName, match.system.user.name)}
className="px-3.5 py-1.5 bg-amber-500 hover:bg-amber-600 disabled:bg-amber-300 text-white font-bold rounded-xl transition-all text-[11px] self-end sm:self-auto flex items-center gap-1"
>
<GitMerge className="w-3.5 h-3.5" />
{mergingId === match.manual.id ? 'Đang xử lý...' : 'Gán & Hợp nhất'}
</button>
</div>
))}
</div>
</div>
)}
{/* Pending requests */}
{joinRequests.length > 0 && (
<div className="p-5 bg-white rounded-3xl border border-gray-100 shadow-sm space-y-4 animate-in fade-in duration-300">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-indigo-500 animate-pulse" />
<h3 className="text-sm font-bold text-gray-900">Yêu cầu tham gia chờ duyệt</h3>
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">{joinRequests.length}</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{joinRequests.map((req: any) => (
<div key={req.id} className="flex items-center justify-between gap-3 p-3.5 rounded-2xl border border-gray-100 bg-gray-50/50">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-indigo-50 border border-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-sm">
{req.user?.name?.charAt(0) || '?'}
</div>
<div>
<div className="text-xs font-bold text-gray-800">{req.user?.name || req.userId}</div>
<div className="text-[10px] text-gray-500 mt-0.5">
{req.user?.email}
</div>
</div>
</div>
{isOwner && (
<div className="flex gap-1.5 shrink-0">
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
const isConfirmed = await confirm({
title: 'Chấp nhận yêu cầu',
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
});
if (!isConfirmed) return;
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(tourId, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
onRefresh();
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}}
className="p-1.5 rounded-xl bg-green-50 hover:bg-green-100 text-green-700 transition-colors disabled:opacity-50"
title="Chấp nhận"
>
<Check className="w-4 h-4" />
</button>
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
const isConfirmed = await confirm({
title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
});
if (!isConfirmed) return;
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(tourId, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
onRefresh();
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}}
className="p-1.5 rounded-xl bg-red-50 hover:bg-red-100 text-red-700 transition-colors disabled:opacity-50"
title="Từ chối"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Main Members Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* System Accounts */}
<div className="p-5 bg-white rounded-3xl border border-gray-100 shadow-sm space-y-4 flex flex-col">
<div className="flex justify-between items-center">
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
Thành viên hệ thống ({systemMembers.length})
</h3>
</div>
<div className="space-y-2.5 flex-1 max-h-[400px] overflow-y-auto pr-1">
{systemMembers.map((member: any) => {
const isCurrentUser = currentUserId && member.userId === currentUserId;
const isMemberOwner = member.role === 'OWNER';
const canRemove = canManage && !isCurrentUser && !isMemberOwner;
return (
<div key={member.id} className="flex items-center justify-between p-3 bg-gray-50/50 rounded-2xl border border-gray-100/50 hover:border-gray-200 transition-all">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-indigo-50 border border-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-sm">
{member.user.name.charAt(0)}
</div>
<div>
<div className="text-xs font-bold text-gray-800 flex items-center gap-1.5">
{member.user.name}
{isCurrentUser && <span className="text-[9px] bg-indigo-100 text-indigo-700 font-black px-1.5 py-0.5 rounded-md">Tôi</span>}
</div>
<div className="text-[10px] text-gray-500 flex items-center gap-1 mt-0.5">
<Mail className="w-3 h-3 text-gray-400" />
{member.user.email}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{getRoleBadge(member.role)}
{canRemove && (
<button
onClick={async () => {
const isConfirmed = await confirm({
title: 'Xóa thành viên',
message: `Bạn có chắc chắn muốn xóa thành viên "${member.user.name}" khỏi hành trình?`
});
if (isConfirmed) {
try {
await onRemoveMember(member.userId);
notify({ title: 'Thành công', message: 'Đã xóa thành viên', type: 'success' });
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể xóa thành viên', type: 'error' });
}
}
}}
className="p-1.5 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded-xl transition-all"
title="Xóa thành viên"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Manual Members */}
<div className="p-5 bg-white rounded-3xl border border-gray-100 shadow-sm space-y-4 flex flex-col">
<div className="flex justify-between items-center">
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500"></span>
Thành viên thủ công ({manualMembers.length})
</h3>
</div>
<div className="space-y-2.5 flex-1 max-h-[400px] overflow-y-auto pr-1">
{manualMembers.map((member: any) => {
const canRemove = canManage;
return (
<div key={member.id} className="flex items-center justify-between p-3 bg-gray-50/50 rounded-2xl border border-gray-100/50 hover:border-gray-200 transition-all">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-50 border border-amber-100 flex items-center justify-center text-amber-700 font-bold text-sm">
{member.displayName.charAt(0)}
</div>
<div>
<div className="text-xs font-bold text-gray-800">
{member.displayName}
</div>
<div className="text-[10px] text-gray-400 mt-0.5">
Tạo ngoài hệ thống
</div>
</div>
</div>
<div className="flex items-center gap-2">
{getRoleBadge(member.role)}
{canManage && (
<button
onClick={() => setAssigningManualMember(member)}
className="flex items-center gap-1 px-2.5 py-1 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 text-[10px] font-bold transition-all border border-indigo-100"
title="Hợp nhất với tài khoản hệ thống"
>
<GitMerge className="w-3 h-3" />
Gán tài khoản
</button>
)}
{canRemove && (
<button
onClick={async () => {
const isConfirmed = await confirm({
title: 'Xóa thành viên',
message: `Bạn có chắc chắn muốn xóa thành viên thủ công "${member.displayName}"?`
});
if (isConfirmed) {
try {
await onRemoveMember(member.id);
notify({ title: 'Thành công', message: 'Đã xóa thành viên', type: 'success' });
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể xóa thành viên', type: 'error' });
}
}
}}
className="p-1.5 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded-xl transition-all"
title="Xóa thành viên"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
);
})}
{manualMembers.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-center py-10 text-gray-400">
<User className="w-8 h-8 opacity-40 mb-2" />
<span className="text-xs">Chưa thành viên thủ công nào</span>
</div>
)}
</div>
</div>
</div>
{/* Manual Merge Modal Selector */}
{assigningManualMember && (
<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={() => setAssigningManualMember(null)} />
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h3 className="text-md font-bold text-gray-900 flex items-center gap-2">
<GitMerge className="w-5 h-5 text-indigo-600" /> Gán tài khoản hệ thống
</h3>
<p className="text-xs text-gray-500 mt-1">
Chọn một tài khoản hệ thống đ gán cho thành viên thủ công <strong>"{assigningManualMember.displayName}"</strong>.
</p>
</div>
<button
onClick={() => setAssigningManualMember(null)}
className="p-1.5 hover:bg-gray-100 rounded-full transition-colors"
>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Search Bar */}
<div className="p-4 border-b border-gray-100">
<div className="relative">
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Tìm tài khoản hệ thống theo tên hoặc email..."
value={systemSearchQuery}
onChange={(e) => setSystemSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-xl text-xs outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 transition-all"
/>
</div>
</div>
{/* System accounts list */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{filteredSystemMembersForMerge.map((systemMember: any) => (
<button
key={systemMember.id}
disabled={mergingId === assigningManualMember.id}
onClick={() => handleMerge(assigningManualMember.id, systemMember.userId, assigningManualMember.displayName, systemMember.user.name)}
className="w-full flex items-center justify-between p-3 hover:bg-indigo-50/50 active:bg-indigo-50 border border-gray-100 hover:border-indigo-100 rounded-2xl text-left transition-all"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-indigo-50 border border-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-xs shrink-0">
{systemMember.user.name.charAt(0)}
</div>
<div className="min-w-0">
<div className="text-xs font-bold text-gray-800 truncate">{systemMember.user.name}</div>
<div className="text-[10px] text-gray-500 truncate mt-0.5">{systemMember.user.email}</div>
</div>
</div>
<div className="shrink-0">
<ArrowRight className="w-4 h-4 text-gray-400" />
</div>
</button>
))}
{filteredSystemMembersForMerge.length === 0 && (
<div className="py-10 text-center text-gray-400 text-xs">
Không tìm thấy tài khoản hệ thống phù hợp.
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
+29 -3
View File
@@ -5,6 +5,7 @@ import { ExpenseManager } from '../components/ExpenseManager';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { AddLocationModal } from '@/components/AddLocationModal'; import { AddLocationModal } from '@/components/AddLocationModal';
import { AddMemberModal } from '../components/AddMemberModal'; import { AddMemberModal } from '../components/AddMemberModal';
import { MembersTab } from '../components/MembersTab';
import { useConfirm } from '@/hooks/useConfirm'; import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification'; import { useNotification } from '@/hooks/useNotification';
import { CommentModal } from '@/components/CommentModal'; import { CommentModal } from '@/components/CommentModal';
@@ -360,7 +361,8 @@ export const TourDetailPage = ({
const [userSpeed, setUserSpeed] = useState<number | null>(null); // Tốc độ di chuyển từ GPS const [userSpeed, setUserSpeed] = useState<number | null>(null); // Tốc độ di chuyển từ GPS
// Di chuyển khai báo state lên trên useEffect để tránh lỗi "before initialization" // Di chuyển khai báo state lên trên useEffect để tránh lỗi "before initialization"
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan'); const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings' | 'members'>('plan');
const [mergingId, setMergingId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline'); const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isHeadingMode, setIsHeadingMode] = useState(false); const [isHeadingMode, setIsHeadingMode] = useState(false);
const [mapRotation, setMapRotation] = useState(0); const [mapRotation, setMapRotation] = useState(0);
@@ -1398,6 +1400,7 @@ export const TourDetailPage = ({
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true }, { id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') }, { id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true }, { id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
{ id: 'members', label: 'Thành viên', icon: Users, visible: canManage },
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' }, { id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
].filter(t => t.visible); ].filter(t => t.visible);
@@ -1640,7 +1643,15 @@ export const TourDetailPage = ({
</span> </span>
</div> </div>
<button <button
disabled={mergingId === match.manual.id}
onClick={async () => { onClick={async () => {
const isConfirmed = await confirm({
title: 'Hợp nhất thành viên',
message: `Gán tài khoản "${match.system.user.name}" thay thế cho thành viên thủ công "${match.manual.displayName}"? Thao tác này không thể hoàn tác.`
});
if (!isConfirmed) return;
setMergingId(match.manual.id);
try { try {
const res = await fetch(`/api/v1/tours/${currentTour.id}/members/merge`, { const res = await fetch(`/api/v1/tours/${currentTour.id}/members/merge`, {
method: 'POST', method: 'POST',
@@ -1661,11 +1672,13 @@ export const TourDetailPage = ({
fetchTour(currentTour.id); fetchTour(currentTour.id);
} catch (e: any) { } catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể hợp nhất', type: 'error' }); notify({ title: 'Lỗi', message: e.message || 'Không thể hợp nhất', type: 'error' });
} finally {
setMergingId(null);
} }
}} }}
className="px-3.5 py-2 bg-amber-500 hover:bg-amber-600 active:scale-95 text-white font-black rounded-xl transition-all shrink-0 shadow-md text-[11px]" className={`px-3.5 py-2 bg-amber-500 hover:bg-amber-600 active:scale-95 text-white font-black rounded-xl transition-all shrink-0 shadow-md text-[11px] ${mergingId === match.manual.id ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
Gán & Hợp nhất {mergingId === match.manual.id ? 'Đang xử lý...' : 'Gán & Hợp nhất'}
</button> </button>
</div> </div>
))} ))}
@@ -2641,6 +2654,19 @@ export const TourDetailPage = ({
</div> </div>
</div> </div>
)} )}
{activeTab === 'members' && canManage && currentTour && (
<MembersTab
tourId={currentTour.id}
participants={currentTour.participants || []}
joinRequests={joinRequests}
userRole={userRole}
canManage={canManage}
isOwner={isOwner}
onRemoveMember={(memberIdOrUserId: string) => removeMember(currentTour.id, memberIdOrUserId)}
onRefresh={() => fetchTour(currentTour.id)}
onOpenAddMember={() => setIsAddMemberOpen(true)}
/>
)}
</div> </div>
</div> </div>