edtech / apps /admin /src /pages /super-admin /OrganizationsManager.tsx
CognxSafeTrack
feat(i18n): complete super-admin i18n — all 11 pages fully translated
b92ea37
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>
);
}