Files
travelplanning/frontend/src/components/MembersTab.tsx
T

511 lines
24 KiB
TypeScript

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>
);
};