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:
Vendored
+1
@@ -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.' };
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 lý thành viên
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Quản lý thành viên hệ thống, thành viên thủ công và 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 có 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 và 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 có 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user