edtech / apps /admin /src /pages /UserListPage.tsx
CognxSafeTrack
feat(i18n): complete admin app internationalization across all pages
d80fec4
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>
);
}