| import { useEffect, useState } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { X, Building2, Loader2, Trash2, UserCheck, AlertTriangle } from 'lucide-react'; |
| import { useAuth } from '../lib/auth'; |
| import { useTenant } from '../lib/tenant'; |
| import { api } from '../lib/api'; |
| import { useToast } from '../hooks/useToast'; |
| import { logWarn } from '../lib/logger'; |
|
|
| export default function UserListPage() { |
| const { t } = useTranslation(); |
| const toast = useToast(); |
| const { token } = useAuth(); |
| const { selectedOrgId } = useTenant(); |
| const [users, setUsers] = useState<any[]>([]); |
| const [total, setTotal] = useState(0); |
| const [page, setPage] = useState(1); |
| const LIMIT = 20; |
| const [loading, setLoading] = useState(true); |
| const [selectedUser, setSelectedUser] = useState<any>(null); |
| const [messages, setMessages] = useState<any[]>([]); |
| const [loadingMsg, setLoadingMsg] = useState(false); |
| const [handoffStatus, setHandoffStatus] = useState<Record<string, boolean>>({}); |
| const [deletingId, setDeletingId] = useState<string | null>(null); |
| const [releasingId, setReleasingId] = useState<string | null>(null); |
|
|
| const loadUsers = () => { |
| if (!selectedOrgId) { setLoading(false); return; } |
| setLoading(true); |
| api.get(`/v1/admin/users?page=${page}&limit=${LIMIT}`, token, selectedOrgId) |
| .then(d => { setUsers(d.users || d); setTotal(d.total || 0); setLoading(false); }) |
| .catch((err) => { setLoading(false); toast.error(err?.message ?? t('users.load_error')); }); |
| }; |
|
|
| useEffect(() => { loadUsers(); }, [token, selectedOrgId, page]); |
|
|
| const viewMessages = async (userId: string) => { |
| setLoadingMsg(true); |
| setSelectedUser({ id: userId }); |
| try { |
| const [data, handoff] = await Promise.all([ |
| api.get(`/v1/admin/users/${userId}/messages`, token, selectedOrgId), |
| api.get(`/v1/admin/users/${userId}/handoff`, token, selectedOrgId).catch(() => ({ handoffActive: false })), |
| ]); |
| setSelectedUser(data.user); |
| setMessages(data.messages || []); |
| setHandoffStatus(prev => ({ ...prev, [userId]: handoff.handoffActive })); |
| } catch (err) { |
| logWarn('[UserList] fetchMessages failed', err); |
| toast.error(t('common.error')); |
| } finally { |
| setLoadingMsg(false); |
| } |
| }; |
|
|
| const handleDelete = async (userId: string) => { |
| if (!confirm(t('users.confirm_delete'))) return; |
| setDeletingId(userId); |
| try { |
| await api.delete(`/v1/admin/users/${userId}`, token, selectedOrgId); |
| setUsers(prev => prev.filter(u => u.id !== userId)); |
| setTotal(prev => prev - 1); |
| toast.success(t('users.delete_success')); |
| } catch (err: any) { |
| toast.error(err?.message ?? t('users.delete_error')); |
| } finally { |
| setDeletingId(null); |
| } |
| }; |
|
|
| const handleReleaseHandoff = async (userId: string) => { |
| setReleasingId(userId); |
| try { |
| const res = await api.delete(`/v1/admin/users/${userId}/handoff`, token, selectedOrgId); |
| setHandoffStatus(prev => ({ ...prev, [userId]: false })); |
| toast.success(res.wasActive ? t('users.handoff_released') : t('users.handoff_none')); |
| } catch (err: any) { |
| toast.error(err?.message ?? t('users.handoff_error')); |
| } finally { |
| setReleasingId(null); |
| } |
| }; |
|
|
| if (!selectedOrgId) { |
| return ( |
| <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400"> |
| <Building2 className="w-12 h-12 mb-4 opacity-20" /> |
| <h3 className="text-lg font-bold text-slate-900">{t('settings.no_org_selected')}</h3> |
| </div> |
| ); |
| } |
|
|
| if (loading) { |
| return ( |
| <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400"> |
| <Loader2 className="w-8 h-8 animate-spin mb-4 text-slate-900" /> |
| <p>{t('common.loading')}</p> |
| </div> |
| ); |
| } |
|
|
| const tableHeaders = [ |
| t('common.phone'), t('common.name'), t('users.language_column'), t('users.sector_column'), |
| t('nav.organizations'), t('users.columns.status'), t('common.date'), t('common.actions') |
| ]; |
|
|
| return ( |
| <div className="p-8"> |
| <h1 className="text-3xl font-bold mb-6 text-slate-800"> |
| {t('users.title')} <span className="text-lg font-normal text-slate-400">({total})</span> |
| </h1> |
| <div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"> |
| <table className="w-full text-sm"> |
| <thead className="bg-slate-50 text-xs text-slate-500 uppercase"> |
| <tr>{tableHeaders.map(h => <th key={h} className="px-5 py-3 text-left">{h}</th>)}</tr> |
| </thead> |
| <tbody> |
| {users.map((u: any) => ( |
| <tr key={u.id} className="border-t border-slate-50 hover:bg-slate-50/50"> |
| <td className="px-5 py-3 font-medium">{u.phone}</td> |
| <td className="px-5 py-3 text-slate-600">{u.name || '—'}</td> |
| <td className="px-5 py-3"><span className="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full">{u.language}</span></td> |
| <td className="px-5 py-3 text-slate-500 text-xs">{u.activity || '—'}</td> |
| <td className="px-5 py-3 text-center">{u._count?.enrollments || 0}</td> |
| <td className="px-5 py-3 text-center">{u._count?.responses || 0}</td> |
| <td className="px-5 py-3 text-slate-400 text-xs">{new Date(u.createdAt).toLocaleDateString()}</td> |
| <td className="px-5 py-3"> |
| <div className="flex items-center justify-end gap-1.5"> |
| <button |
| onClick={() => viewMessages(u.id)} |
| className="text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1.5 rounded-lg font-medium transition-colors" |
| > |
| {t('users.conversation_btn')} |
| </button> |
| <button |
| onClick={() => handleDelete(u.id)} |
| disabled={deletingId === u.id} |
| className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-40" |
| title={t('users.delete_title')} |
| > |
| {deletingId === u.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />} |
| </button> |
| </div> |
| </td> |
| </tr> |
| ))} |
| {!users.length && ( |
| <tr><td colSpan={8} className="px-5 py-8 text-center text-slate-400">{t('users.no_users')}</td></tr> |
| )} |
| </tbody> |
| </table> |
| </div> |
| |
| {/* Pagination */} |
| {total > LIMIT && ( |
| <div className="flex items-center justify-between text-sm text-slate-400 mt-4"> |
| <span>{((page - 1) * LIMIT) + 1}–{Math.min(page * LIMIT, total)} {t('common.of')} {total}</span> |
| <div className="flex gap-2"> |
| <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-white border border-slate-200 rounded-lg disabled:opacity-40 hover:bg-slate-50 transition-colors">{t('users.prev')}</button> |
| <button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-white border border-slate-200 rounded-lg disabled:opacity-40 hover:bg-slate-50 transition-colors">{t('common.next')}</button> |
| </div> |
| </div> |
| )} |
| |
| {/* Conversation Detail Modal */} |
| {selectedUser && ( |
| <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> |
| <div className="bg-slate-50 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden"> |
| <div className="bg-white px-6 py-4 flex items-center justify-between border-b border-slate-200"> |
| <div> |
| <h3 className="font-bold text-slate-800">{selectedUser.name || 'Chat'}</h3> |
| <p className="text-xs text-slate-500">{selectedUser.phone}</p> |
| </div> |
| <div className="flex items-center gap-2"> |
| {handoffStatus[selectedUser.id] && ( |
| <div className="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-xl px-3 py-1.5"> |
| <AlertTriangle className="w-3.5 h-3.5 text-amber-500" /> |
| <span className="text-xs font-bold text-amber-700">{t('users.handoff_active')}</span> |
| <button |
| onClick={() => handleReleaseHandoff(selectedUser.id)} |
| disabled={releasingId === selectedUser.id} |
| className="ml-1 flex items-center gap-1 text-xs bg-amber-500 hover:bg-amber-600 text-white px-2 py-0.5 rounded-lg font-bold transition-colors disabled:opacity-50" |
| > |
| {releasingId === selectedUser.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <UserCheck className="w-3 h-3" />} |
| {t('users.release_ai')} |
| </button> |
| </div> |
| )} |
| <button onClick={() => { setSelectedUser(null); setMessages([]); }} className="p-2 hover:bg-slate-100 rounded-full"> |
| <X className="w-5 h-5 text-slate-500" /> |
| </button> |
| </div> |
| </div> |
| <div className="flex-1 overflow-y-auto p-6 space-y-4 bg-[#e5ddd5]"> |
| {loadingMsg ? ( |
| <div className="text-center text-slate-500 py-10">{t('common.loading')}</div> |
| ) : messages.length === 0 ? ( |
| <div className="text-center text-slate-500 py-10 bg-white/50 rounded-xl">{t('crm.inbox.no_messages')}</div> |
| ) : ( |
| messages.map((m: any) => { |
| const isBot = m.direction === 'OUTBOUND'; |
| return ( |
| <div key={m.id} className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}> |
| <div className={`max-w-[80%] rounded-2xl px-4 py-2.5 shadow-sm text-sm ${isBot ? 'bg-white text-slate-800 rounded-tl-none' : 'bg-[#dcf8c6] text-slate-900 rounded-tr-none'}`}> |
| {m.mediaUrl && ( |
| <div className="mb-2"> |
| {m.mediaUrl.endsWith('.mp3') || m.mediaUrl.endsWith('.ogg') || m.mediaUrl.endsWith('.webm') |
| ? <audio src={m.mediaUrl} controls className="h-10 max-w-full" /> |
| : <a href={m.mediaUrl} target="_blank" rel="noreferrer" className="text-blue-600 underline">Media</a> |
| } |
| </div> |
| )} |
| {m.content && <p className="whitespace-pre-wrap">{m.content}</p>} |
| <p className={`text-[10px] mt-1 text-right ${isBot ? 'text-slate-400' : 'text-slate-500'}`}> |
| {new Date(m.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} |
| </p> |
| </div> |
| </div> |
| ); |
| }) |
| )} |
| </div> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|