Files
travelplanning/AddMemberModal.tsx
T

302 lines
14 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
interface AddMemberModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>;
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
onRemoveMember?: (userId: string) => Promise<void>;
onMemberAdded?: () => void;
userRole?: string;
}
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [role, setRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
const [submitting, setSubmitting] = useState(false);
const [fetchError, setFetchError] = useState('');
const [submitError, setSubmitError] = useState('');
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null);
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [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 fetchUsers = async () => {
setLoading(true);
setFetchError('');
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) throw new Error('Không thể tải danh sách người dùng');
const data = await res.json();
setUsers(Array.isArray(data) ? data : []);
} catch (err: any) {
setFetchError(err.message || 'Không thể tải danh sách người dùng');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
fetchUsers();
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
setQuery('');
setSelectedUser(null);
setRole('MEMBER');
setFetchError('');
setSubmitError('');
}
}, [isOpen]);
const handleRemove = async (userId: string, memberName: string) => {
if (!onRemoveMember) return;
setConfirmTarget({ userId, name: memberName });
setIsConfirmOpen(true);
};
const confirmRemove = async () => {
if (!confirmTarget || !onRemoveMember) return;
try {
await onRemoveMember(confirmTarget.userId);
} catch (err: any) {
setSubmitError(err.message || 'Không thể xóa thành viên');
} finally {
setIsConfirmOpen(false);
setConfirmTarget(null);
}
};
const handleAdd = async () => {
if (!selectedUser) return;
setSubmitting(true);
setSubmitError('');
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
const body = canCreateDirectly
? { userId: selectedUser, role }
: { userId: selectedUser };
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify(body),
});
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);
}
};
if (!isOpen) return null;
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 flex flex-col max-h-[90vh]">
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'}
</h2>
<p className="text-xs text-gray-500">
{canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<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.length})</p>
<div className="flex flex-wrap gap-3">
{participants.map((p) => {
const rawToken = localStorage.getItem('token');
let currentUserId: string | null = null;
try {
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
currentUserId = payload.sub;
} catch {
currentUserId = null;
}
const isCurrentUser = currentUserId && p.userId === currentUserId;
const isOwner = p.role === 'OWNER';
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
return (
<div key={p.userId} 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) || '?'}
</div>
{canRemove && (
<button
onClick={() => handleRemove(p.userId, p.user?.name || p.userId)}
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">{p.user?.name || p.userId}</span>
</div>
);
})}
{participants.length === 0 && (
<span className="text-xs text-gray-400">Chưa thành viên nào</span>
)}
</div>
</div>
{joinRequests.length > 0 && (
<div>
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
<Clock className="w-3 h-3 text-amber-500" /> Đang chờ phê duyệt ({joinRequests.length})
</p>
<div className="flex flex-wrap gap-3">
{(joinRequests as any[]).map((req) => (
<div key={req.id} className="flex flex-col items-center gap-1">
<div className="w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden">
{req.user?.name?.charAt(0) || '?'}
</div>
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{req.user?.name || req.userId}</span>
<span className="text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200">PENDING</span>
</div>
))}
</div>
</div>
)}
{canCreateDirectly && (
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label>
<select
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={role}
onChange={(e) => setRole(e.target.value as any)}
>
<option value="OWNER">OWNER</option>
<option value="MANAGER">MANAGER</option>
<option value="MEMBER">MEMBER</option>
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
</select>
</div>
)}
<div className="space-y-2">
{fetchError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError}
</div>
)}
{submitError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{submitError}
</div>
)}
{loading ? (
<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">
{visibleUsers.map((u) => {
const isSelected = selectedUser === u.id;
return (
<button
key={u.id}
onClick={() => setSelectedUser(u.id)}
disabled={requestUserIds.has(u.id)}
className={`w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${
isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'
} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`}
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || '?'}
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `• ${u.address}` : ''}</div>
</div>
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
)}
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
</div>
</button>
);
})}
{!loading && visibleUsers.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
)}
</div>
)}
</div>
</div>
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<button
disabled={!selectedUser || submitting}
onClick={handleAdd}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all"
>
{submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
</button>
</div>
</div>
{isConfirmOpen && (
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={() => setIsConfirmOpen(false)} />
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
<h3 className="text-base font-bold text-gray-900">Xác nhận xóa thành viên</h3>
<p className="mt-2 text-sm text-gray-600">
Bạn chắc muốn xóa <span className="font-semibold text-gray-800">{confirmTarget?.name}</span> khỏi tour này?
</p>
<div className="mt-4 flex justify-end gap-2">
<button onClick={() => setIsConfirmOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<button onClick={confirmRemove} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
Xóa
</button>
</div>
</div>
</div>
)}
</div>
);
};