File size: 13,504 Bytes
de6a95b
ab43d7b
42a2598
de6a95b
fe40cec
6dd9bad
 
a966957
de6a95b
 
ab43d7b
6dd9bad
2ab1980
fe40cec
ab43d7b
 
a888244
 
de6a95b
ab43d7b
 
de6a95b
42a2598
 
 
de6a95b
42a2598
ab43d7b
fe40cec
a888244
6dd9bad
d80fec4
42a2598
 
a888244
de6a95b
 
ab43d7b
de6a95b
 
42a2598
 
 
 
de6a95b
 
42a2598
a966957
 
6dd9bad
de6a95b
 
 
 
 
42a2598
d80fec4
42a2598
 
 
 
 
d80fec4
42a2598
d80fec4
42a2598
 
 
 
 
 
 
 
 
 
d80fec4
42a2598
d80fec4
42a2598
 
 
 
 
fe40cec
 
 
 
ab43d7b
fe40cec
 
 
 
 
 
 
 
ab43d7b
fe40cec
 
 
de6a95b
ab43d7b
d80fec4
ab43d7b
 
 
de6a95b
 
ab43d7b
 
 
de6a95b
 
 
ab43d7b
de6a95b
 
 
 
 
 
 
 
 
 
ab43d7b
42a2598
 
 
 
 
 
d80fec4
42a2598
 
 
 
 
d80fec4
42a2598
 
 
 
de6a95b
 
 
ab43d7b
 
 
de6a95b
 
 
 
a888244
 
 
d80fec4
a888244
d80fec4
 
a888244
 
 
 
42a2598
de6a95b
 
 
 
 
ab43d7b
de6a95b
 
42a2598
 
 
 
d80fec4
42a2598
 
 
 
 
 
d80fec4
42a2598
 
 
 
 
 
 
de6a95b
 
 
ab43d7b
de6a95b
ab43d7b
de6a95b
 
 
 
 
 
 
 
ab43d7b
 
 
de6a95b
 
 
 
 
ab43d7b
de6a95b
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
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>
    );
}