| import { useState, useEffect } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { useAuth } from '@/lib/auth'; |
| import { api } from '@/lib/api'; |
| import { useToast } from '@/hooks/useToast'; |
| import { Search, Plus, Pencil, Ban, Check, X, Trash2 } from 'lucide-react'; |
|
|
| const PLAN_COLORS: Record<string, string> = { |
| STARTER: 'bg-slate-700 text-slate-300', |
| GROWTH: 'bg-blue-900/60 text-blue-300', |
| SCALE: 'bg-violet-900/60 text-violet-300', |
| ENTERPRISE: 'bg-amber-900/60 text-amber-300', |
| }; |
|
|
| function StatusBadge({ isHardStopped, status }: { isHardStopped: boolean; status: string }) { |
| const { t } = useTranslation(); |
| if (isHardStopped) return <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-900/60 text-red-300">{t('super_admin.status_suspended')}</span>; |
| if (status === 'TRIAL') return <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-amber-900/60 text-amber-300">{t('super_admin.status_trial')}</span>; |
| return <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-900/60 text-emerald-300">{t('super_admin.status_active')}</span>; |
| } |
|
|
| export default function OrganizationsManager() { |
| const { t } = useTranslation(); |
| const { token } = useAuth(); |
| const toast = useToast(); |
| const [orgs, setOrgs] = useState<any[]>([]); |
| const [total, setTotal] = useState(0); |
| const [page, setPage] = useState(1); |
| const [search, setSearch] = useState(''); |
| const [loading, setLoading] = useState(true); |
| const [editOrg, setEditOrg] = useState<any>(null); |
| const [showCreate, setShowCreate] = useState(false); |
| const [newOrgName, setNewOrgName] = useState(''); |
| const [saving, setSaving] = useState(false); |
|
|
| const LIMIT = 20; |
|
|
| async function load() { |
| if (!token) return; |
| setLoading(true); |
| try { |
| const params = new URLSearchParams({ page: String(page), limit: String(LIMIT) }); |
| if (search) params.set('search', search); |
| const data = await api.get(`/v1/super-admin/organizations?${params}`, token); |
| setOrgs(data.data ?? []); |
| setTotal(data.total ?? 0); |
| } catch { toast.error(t('super_admin.err_load_orgs')); } |
| finally { setLoading(false); } |
| } |
|
|
| useEffect(() => { load(); }, [page, token, search]); |
|
|
| async function handleSearch(e: React.FormEvent) { |
| e.preventDefault(); |
| setPage(1); |
| } |
|
|
| async function handleSuspend(org: any) { |
| try { |
| await api.post(`/v1/super-admin/organizations/${org.id}/suspend`, { suspend: !org.isHardStopped }, token); |
| toast.success(org.isHardStopped ? t('super_admin.org_reactivated') : t('super_admin.org_suspended')); |
| load(); |
| } catch { toast.error(t('super_admin.err_suspend')); } |
| } |
|
|
| async function handleDelete(org: any) { |
| if (!confirm(t('super_admin.org_delete_confirm', { name: org.name }))) return; |
| try { |
| await api.delete(`/v1/super-admin/organizations/${org.id}`, token); |
| toast.success(t('super_admin.org_deleted', { name: org.name })); |
| load(); |
| } catch { toast.error(t('super_admin.err_delete')); } |
| } |
|
|
| async function handleSaveEdit() { |
| if (!editOrg) return; |
| setSaving(true); |
| try { |
| await api.patch(`/v1/super-admin/organizations/${editOrg.id}`, { |
| subscriptionPlan: editOrg.subscriptionPlan, |
| aiCreditsLimit: editOrg.aiCreditsLimit, |
| isCrmActive: editOrg.isCrmActive, |
| isEdTechActive: editOrg.isEdTechActive, |
| }, token); |
| toast.success(t('super_admin.org_updated')); |
| setEditOrg(null); |
| load(); |
| } catch { toast.error(t('super_admin.err_update')); } |
| finally { setSaving(false); } |
| } |
|
|
| async function handleCreate() { |
| if (!newOrgName.trim()) return; |
| setSaving(true); |
| try { |
| await api.post('/v1/super-admin/organizations', { name: newOrgName }, token); |
| toast.success(t('super_admin.org_created')); |
| setShowCreate(false); |
| setNewOrgName(''); |
| load(); |
| } catch { toast.error(t('super_admin.err_create')); } |
| finally { setSaving(false); } |
| } |
|
|
| return ( |
| <div className="space-y-4"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <h1 className="text-xl font-bold text-white">{t('super_admin.orgs_title')}</h1> |
| <p className="text-sm text-slate-400 mt-0.5">{t('super_admin.orgs_total', { count: total })}</p> |
| </div> |
| <button |
| onClick={() => setShowCreate(true)} |
| className="flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors" |
| > |
| <Plus className="w-4 h-4" /> |
| {t('super_admin.org_new')} |
| </button> |
| </div> |
| |
| <form onSubmit={handleSearch} className="flex gap-2"> |
| <div className="relative flex-1"> |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> |
| <input |
| value={search} |
| onChange={e => setSearch(e.target.value)} |
| placeholder={t('super_admin.org_search_placeholder')} |
| className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500" |
| /> |
| </div> |
| <button type="submit" className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition-colors">{t('super_admin.org_search_btn')}</button> |
| </form> |
| |
| <div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden"> |
| {loading ? ( |
| <div className="p-8 text-center text-slate-500 animate-pulse">{t('super_admin.org_loading')}</div> |
| ) : orgs.length === 0 ? ( |
| <div className="p-8 text-center text-slate-500">{t('super_admin.org_empty')}</div> |
| ) : ( |
| <div className="overflow-x-auto"> |
| <table className="w-full text-sm"> |
| <thead> |
| <tr className="border-b border-slate-800"> |
| <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.col_name')}</th> |
| <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.col_plan')}</th> |
| <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.col_status')}</th> |
| <th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.col_users')}</th> |
| <th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.col_credits')}</th> |
| <th className="px-4 py-3" /> |
| </tr> |
| </thead> |
| <tbody className="divide-y divide-slate-800"> |
| {orgs.map(org => ( |
| <tr key={org.id} className="hover:bg-slate-800/50 transition-colors"> |
| <td className="px-4 py-3"> |
| <div className="font-medium text-white">{org.name}</div> |
| <div className="text-xs text-slate-500 font-mono">{(org.id ?? '').slice(0, 8)}…</div> |
| </td> |
| <td className="px-4 py-3"> |
| <span className={`text-xs px-2 py-0.5 rounded-full ${PLAN_COLORS[org.subscriptionPlan] ?? 'bg-slate-700 text-slate-300'}`}> |
| {org.subscriptionPlan} |
| </span> |
| </td> |
| <td className="px-4 py-3"> |
| <StatusBadge isHardStopped={org.isHardStopped} status={org.subscriptionStatus ?? 'ACTIVE'} /> |
| </td> |
| <td className="px-4 py-3 text-right text-slate-300">{org.userCount}</td> |
| <td className="px-4 py-3 text-right text-slate-300">{org.walletBalance?.toLocaleString()}</td> |
| <td className="px-4 py-3"> |
| <div className="flex items-center justify-end gap-1"> |
| <button |
| onClick={() => setEditOrg({ ...org })} |
| className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors" |
| title={t('super_admin.btn_edit')} |
| > |
| <Pencil className="w-3.5 h-3.5" /> |
| </button> |
| <button |
| onClick={() => handleSuspend(org)} |
| className={`p-1.5 rounded transition-colors ${org.isHardStopped ? 'text-emerald-400 hover:text-emerald-300 hover:bg-slate-700' : 'text-amber-400 hover:text-amber-300 hover:bg-slate-700'}`} |
| title={org.isHardStopped ? t('super_admin.btn_reactivate') : t('super_admin.btn_suspend')} |
| > |
| {org.isHardStopped ? <Check className="w-3.5 h-3.5" /> : <Ban className="w-3.5 h-3.5" />} |
| </button> |
| <button |
| onClick={() => handleDelete(org)} |
| className="p-1.5 text-red-500 hover:text-red-400 hover:bg-slate-700 rounded transition-colors" |
| title={t('super_admin.btn_delete_forever')} |
| > |
| <Trash2 className="w-3.5 h-3.5" /> |
| </button> |
| </div> |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| )} |
| </div> |
| |
| {total > LIMIT && ( |
| <div className="flex items-center justify-between text-sm text-slate-400"> |
| <span>{t('super_admin.pagination_info', { from: ((page - 1) * LIMIT) + 1, to: Math.min(page * LIMIT, total), 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-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">{t('super_admin.prev')}</button> |
| <button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">{t('super_admin.next')}</button> |
| </div> |
| </div> |
| )} |
| |
| {editOrg && ( |
| <div className="fixed inset-0 bg-black/60 z-50 flex justify-end"> |
| <div className="w-96 bg-slate-900 border-l border-slate-800 h-full overflow-y-auto p-6 space-y-5"> |
| <div className="flex items-center justify-between"> |
| <h2 className="text-base font-bold text-white">{editOrg.name}</h2> |
| <button onClick={() => setEditOrg(null)} className="p-1.5 text-slate-400 hover:text-white"><X className="w-4 h-4" /></button> |
| </div> |
| <div className="space-y-4"> |
| <div> |
| <label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.label_plan')}</label> |
| <select |
| value={editOrg.subscriptionPlan} |
| onChange={e => setEditOrg({ ...editOrg, subscriptionPlan: e.target.value })} |
| className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500" |
| > |
| {['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE'].map(p => <option key={p} value={p}>{p}</option>)} |
| </select> |
| </div> |
| <div> |
| <label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{t('super_admin.label_ai_credits')}</label> |
| <input |
| type="number" |
| value={editOrg.aiCreditsLimit} |
| onChange={e => setEditOrg({ ...editOrg, aiCreditsLimit: parseInt(e.target.value) })} |
| className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500" |
| /> |
| </div> |
| <div className="flex items-center justify-between"> |
| <span className="text-sm text-slate-300">{t('super_admin.label_crm_active')}</span> |
| <button |
| onClick={() => setEditOrg({ ...editOrg, isCrmActive: !editOrg.isCrmActive })} |
| className={`w-10 h-5 rounded-full transition-colors relative ${editOrg.isCrmActive ? 'bg-violet-600' : 'bg-slate-700'}`} |
| > |
| <span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-all ${editOrg.isCrmActive ? 'left-5' : 'left-0.5'}`} /> |
| </button> |
| </div> |
| <div className="flex items-center justify-between"> |
| <span className="text-sm text-slate-300">{t('super_admin.label_edtech_active')}</span> |
| <button |
| onClick={() => setEditOrg({ ...editOrg, isEdTechActive: !editOrg.isEdTechActive })} |
| className={`w-10 h-5 rounded-full transition-colors relative ${editOrg.isEdTechActive ? 'bg-violet-600' : 'bg-slate-700'}`} |
| > |
| <span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-all ${editOrg.isEdTechActive ? 'left-5' : 'left-0.5'}`} /> |
| </button> |
| </div> |
| </div> |
| <button |
| onClick={handleSaveEdit} |
| disabled={saving} |
| className="w-full bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white font-medium py-2 rounded-lg text-sm transition-colors" |
| > |
| {saving ? t('super_admin.saving') : t('super_admin.save')} |
| </button> |
| </div> |
| </div> |
| )} |
| |
| {showCreate && ( |
| <div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center"> |
| <div className="bg-slate-900 border border-slate-800 rounded-xl p-6 w-96 space-y-4"> |
| <h2 className="text-base font-bold text-white">{t('super_admin.modal_new_org')}</h2> |
| <input |
| autoFocus |
| value={newOrgName} |
| onChange={e => setNewOrgName(e.target.value)} |
| placeholder={t('super_admin.org_name_placeholder')} |
| className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500" |
| /> |
| <div className="flex gap-2"> |
| <button onClick={() => setShowCreate(false)} className="flex-1 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">{t('super_admin.cancel')}</button> |
| <button onClick={handleCreate} disabled={saving || !newOrgName.trim()} className="flex-1 py-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"> |
| {saving ? t('super_admin.creating') : t('super_admin.create')} |
| </button> |
| </div> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|