edtech / apps /admin /src /pages /ClientsManagementView.tsx
CognxSafeTrack
fix(admin): replace vague Meta hints with direct clickable links in Direct Setup modal
295c327
Raw History Blame Contribute Delete
73.3 kB
import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Building2, Plus, MessageSquare, ShieldCheck, Activity, Loader2, X, RefreshCw, CheckCircle2, XCircle, AlertTriangle, Info } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { api } from '../lib/api'; import { useAuth } from '../lib/auth'; import { launchEmbeddedSignup } from '../lib/meta-signup'; import MetaVerificationGuide from '../components/MetaVerificationGuide'; import { useToast } from '../hooks/useToast'; import { logError, logWarn } from '../lib/logger'; interface Organization { id: string; name: string; mode: 'EDTECH' | 'CRM_MARKETING' | 'PEDAGOGY' | 'CUSTOMER_SERVICE'; wabaId?: string; phoneNumbers?: { id: string; phoneNumber: string }[]; subscriptionPlan?: 'STARTER' | 'GROWTH' | 'SCALE' | 'ENTERPRISE'; subscriptionStatus?: string; isHardStopped?: boolean; personalityConfig?: { botName?: string; coreMission?: string; toneDescription?: string; }; } interface MetaStatus { configured: boolean; wabaStatus?: 'APPROVED' | 'PENDING' | 'REJECTED' | 'BANNED' | 'UNKNOWN'; businessId?: string; businessName?: string; businessVerified?: boolean; messagingLimitTier?: string; qualityRating?: 'GREEN' | 'YELLOW' | 'RED' | 'UNKNOWN'; syncedAt?: string; error?: string; loading?: boolean; } interface PersonalityModalProps { org: Organization; onClose: () => void; onSave: (config: any) => void; } export default function ClientsManagementView() { const { t } = useTranslation(); const toast = useToast(); const { token, user } = useAuth(); const isSuperAdmin = user?.role === 'SUPER_ADMIN'; const [clients, setClients] = useState<Organization[]>([]); const [loading, setLoading] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); const [creditOrg, setCreditOrg] = useState<Organization | null>(null); const [creditAmount, setCreditAmount] = useState(''); const [isSavingCredits, setIsSavingCredits] = useState(false); const [selectedOrgForPersonality, setSelectedOrgForPersonality] = useState<Organization | null>(null); const [newOrg, setNewOrg] = useState({ name: '', slug: '', adminEmail: '', adminName: '', password: '', mode: 'PEDAGOGY' as 'CRM_MARKETING' | 'PEDAGOGY' | 'CUSTOMER_SERVICE', useCase: 'EDUCATION' as 'EDUCATION' | 'CRM_WHATSAPP' }); const [isCreating, setIsCreating] = useState(false); const [createErrors, setCreateErrors] = useState<Record<string, string>>({}); const [showGuide, setShowGuide] = useState(false); const [billingOrg, setBillingOrg] = useState<Organization | null>(null); const [billingPlan, setBillingPlan] = useState<string>('STARTER'); const [isSavingPlan, setIsSavingPlan] = useState(false); const [metaStatuses, setMetaStatuses] = useState<Record<string, MetaStatus>>({}); const [setupOrg, setSetupOrg] = useState<Organization | null>(null); const [directSetup, setDirectSetup] = useState({ wabaId: '', metaBusinessId: '', accessToken: '', phoneNumberId: '' }); const [isDirectSetupSaving, setIsDirectSetupSaving] = useState(false); const fetchMetaStatus = async (orgId: string, force = false) => { setMetaStatuses(prev => ({ ...prev, [orgId]: { ...prev[orgId], configured: false, loading: true } })); try { const status = force ? await api.post(`/v1/organizations/${orgId}/meta-status/refresh`, {}, token!) : await api.get(`/v1/organizations/${orgId}/meta-status`, token!); setMetaStatuses(prev => ({ ...prev, [orgId]: { ...status, loading: false } })); } catch (err) { logWarn('[Clients] fetchMetaStatus failed', err); setMetaStatuses(prev => ({ ...prev, [orgId]: { configured: false, loading: false, error: t('common.error') } })); } }; const fetchClients = async () => { if (!token) return; try { const data = await api.get('/v1/organizations', token); setClients(data); // Fetch Meta status for all orgs in parallel (non-blocking) data.forEach((org: Organization) => fetchMetaStatus(org.id)); } catch (error) { logError("Failed to fetch organizations:", error); } finally { setLoading(false); } }; useEffect(() => { fetchClients(); }, [token]); const handleDirectSetupSubmit = async () => { if (!setupOrg || !directSetup.wabaId) return; setIsDirectSetupSaving(true); try { await api.post(`/v1/organizations/${setupOrg.id}/whatsapp-setup`, { wabaId: directSetup.wabaId, ...(directSetup.accessToken && { accessToken: directSetup.accessToken }), ...(directSetup.phoneNumberId && { phoneNumberId: directSetup.phoneNumberId }), }, token!); if (directSetup.metaBusinessId) { await api.put(`/v1/organizations/${setupOrg.id}`, { metaBusinessId: directSetup.metaBusinessId }, token!); } toast.success(t('clients.wa_setup_modal.success')); setSetupOrg(null); setDirectSetup({ wabaId: '', metaBusinessId: '', accessToken: '', phoneNumberId: '' }); await fetchClients(); setTimeout(() => fetchMetaStatus(setupOrg.id, true), 1000); } catch (err: any) { toast.error(`${t('clients.toast.error_prefix')}${err.message}`); } finally { setIsDirectSetupSaving(false); } }; const handleCreateOrg = async (e: React.FormEvent) => { e.preventDefault(); const errors: Record<string, string> = {}; if (!newOrg.name.trim()) errors.name = t('clients.validation.name_required'); if (!newOrg.slug.trim()) errors.slug = t('clients.validation.slug_required'); else if (!/^[a-z0-9-]+$/.test(newOrg.slug)) errors.slug = t('clients.validation.slug_format'); if (!newOrg.adminName.trim()) errors.adminName = t('clients.validation.admin_name_required'); if (!newOrg.adminEmail.trim()) errors.adminEmail = t('clients.validation.email_required'); else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newOrg.adminEmail)) errors.adminEmail = t('clients.validation.email_invalid'); if (!newOrg.password || newOrg.password.length < 8) errors.password = t('clients.validation.password_min'); if (Object.keys(errors).length > 0) { setCreateErrors(errors); return; } setCreateErrors({}); setIsCreating(true); try { await api.post('/v1/organizations', newOrg, token!); await fetchClients(); setIsModalOpen(false); setNewOrg({ name: '', slug: '', adminEmail: '', adminName: '', password: '', mode: 'PEDAGOGY', useCase: 'EDUCATION' }); } catch (error) { logError("Failed to create organization:", error); toast.error(t('clients.create_modal.error_create')); } finally { setIsCreating(false); } }; const handleEmbeddedSignup = async (orgId: string) => { try { const result = await launchEmbeddedSignup(); await api.post(`/v1/organizations/${orgId}/whatsapp-setup`, { wabaId: result.waba_id, accessToken: result.code, phoneNumberId: result.phone_number_id, phoneNumber: '' }, token!); toast.success(t('clients.toast.wa_connected')); fetchClients(); } catch (error: any) { toast.error(`${t('clients.toast.error_prefix')}${error.message}`); } }; 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" /> <p className="font-medium">{t('clients.loading')}</p> </div> ); } return ( <div className="p-8 max-w-6xl mx-auto"> <div className="flex items-center justify-between mb-8"> <div> <h1 className="text-3xl font-bold tracking-tight text-slate-900">{t('clients.page_title')}</h1> <p className="text-slate-500 mt-2">{t('clients.page_subtitle')}</p> </div> <button onClick={() => setIsModalOpen(true)} className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl font-medium hover:bg-slate-800 transition shadow-sm" > <Plus className="w-4 h-4" /> {t('clients.new_org_button')} </button> </div> <div className="grid gap-6"> {clients.length === 0 ? ( <div className="text-center py-20 bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200"> <Building2 className="w-12 h-12 text-slate-300 mx-auto mb-4" /> <h3 className="text-lg font-bold text-slate-900">{t('clients.no_clients_title')}</h3> <p className="text-slate-500">{t('clients.no_clients_desc')}</p> </div> ) : clients.map(client => ( <div key={client.id} className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm hover:shadow-md transition"> <div className="flex items-start justify-between"> <div className="flex gap-4"> <div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center"> <Building2 className="w-6 h-6 text-slate-600" /> </div> <div> <h3 className="text-xl font-bold text-slate-900">{client.name}</h3> <div className="flex items-center gap-3 mt-1"> <span className={`text-xs px-2.5 py-0.5 rounded-full font-semibold ${ client.phoneNumbers?.length ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700' }`}> {client.phoneNumbers?.length ? t('clients.status.operational') : t('clients.status.config_required')} </span> <span className="text-xs text-slate-400">ID: {client.id}</span> </div> </div> </div> <div className="flex gap-2"> {client.phoneNumbers?.length ? ( <div className="flex items-center gap-4 text-sm"> <div className="text-right"> <p className="font-medium text-slate-700">{client.phoneNumbers[0].phoneNumber}</p> <p className="text-xs text-slate-400">ID: {client.phoneNumbers[0].id}</p> </div> <div className="h-10 w-px bg-slate-100"></div> {metaStatuses[client.id] && !metaStatuses[client.id].loading && !metaStatuses[client.id].configured ? ( <button onClick={() => { setSetupOrg(client); setDirectSetup({ wabaId: '', metaBusinessId: '', accessToken: '', phoneNumberId: client.phoneNumbers?.[0]?.id || '' }); }} className="flex items-center gap-2 bg-amber-500 text-white px-4 py-2 rounded-xl font-semibold hover:bg-amber-600 transition text-xs" > <AlertTriangle className="w-3.5 h-3.5" /> {t('clients.actions.reconfigure_wa')} </button> ) : ( <div className="flex items-center gap-2 text-emerald-600"> <Activity className="w-4 h-4" /> <span className="font-medium text-xs">{t('clients.status.online')}</span> </div> )} </div> ) : ( <div className="flex gap-2"> <button onClick={() => handleEmbeddedSignup(client.id)} className="flex items-center gap-2 bg-indigo-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-indigo-700 transition shadow-lg shadow-indigo-100" > <MessageSquare className="w-4 h-4" /> {t('clients.actions.connect_wa')} </button> {isSuperAdmin && ( <button onClick={() => { setSetupOrg(client); setDirectSetup({ wabaId: client.wabaId || '', metaBusinessId: '', accessToken: '', phoneNumberId: '' }); }} className="flex items-center gap-2 bg-slate-700 text-white px-4 py-2.5 rounded-xl font-semibold hover:bg-slate-800 transition text-xs" title={t('clients.actions.direct_setup')} > <ShieldCheck className="w-3.5 h-3.5" /> {t('clients.actions.direct_setup')} </button> )} </div> )} <button onClick={() => setSelectedOrgForPersonality(client)} className="flex items-center gap-2 bg-slate-100 text-slate-700 px-4 py-2.5 rounded-xl font-semibold hover:bg-slate-200 transition" > <Activity className="w-4 h-4" /> {t('clients.actions.personality_studio')} </button> </div> </div> <div className="mt-8 pt-6 border-t border-slate-50 grid grid-cols-4 gap-6"> <MetaStatusCell ms={metaStatuses[client.id]} onRefresh={() => fetchMetaStatus(client.id, true)} onGuide={() => setShowGuide(true)} /> <BusinessVerificationCell ms={metaStatuses[client.id]} /> <DailyLimitCell ms={metaStatuses[client.id]} /> <div className="flex items-center justify-end gap-4"> {isSuperAdmin && ( <button onClick={() => { setCreditOrg(client); setCreditAmount(''); }} className="text-sm font-bold text-emerald-600 hover:text-emerald-700 underline underline-offset-4" > {t('clients.actions.ai_credits')} </button> )} {client.isHardStopped && ( <span className="text-[10px] font-bold bg-red-100 text-red-600 px-2 py-0.5 rounded-full"> {t('clients.status.suspended')} </span> )} <button onClick={() => { setBillingOrg(client); setBillingPlan(client.subscriptionPlan ?? 'STARTER'); }} className="text-sm font-bold text-indigo-600 hover:text-indigo-700 underline underline-offset-4" > {t('clients.actions.billing_details')} </button> </div> </div> </div> ))} </div> {/* Modal du Guide de VΓ©rification */} {showGuide && ( <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-xl flex items-center justify-center p-6 z-[100] animate-in fade-in duration-300"> <div className="w-full max-w-5xl max-h-[90vh] overflow-y-auto"> <div className="relative"> <button onClick={() => setShowGuide(false)} className="absolute -top-4 -right-4 bg-white p-3 rounded-full shadow-xl hover:scale-110 transition z-[110]" > <X className="w-6 h-6 text-slate-900" /> </button> <MetaVerificationGuide /> </div> </div> </div> )} {/* Modal de crΓ©ation d'organisation */} {isModalOpen && ( <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-end sm:items-center justify-center sm:p-4 z-50"> <div className="bg-white rounded-t-[2rem] sm:rounded-[2rem] shadow-2xl w-full sm:max-w-xl flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"> {/* Fixed header */} <div className="shrink-0 flex items-center justify-between px-8 pt-8 pb-5 border-b border-slate-100"> <h2 className="text-2xl font-black text-slate-900">{t('clients.create_modal.title')}</h2> <button onClick={() => { setIsModalOpen(false); setCreateErrors({}); }} className="p-2 hover:bg-slate-100 rounded-full transition"> <X className="w-5 h-5 text-slate-400" /> </button> </div> {/* Scrollable body */} <form onSubmit={handleCreateOrg} className="flex flex-col flex-1 min-h-0"> <div className="overflow-y-auto flex-1 px-8 py-6 space-y-6"> <div className="grid grid-cols-2 gap-4"> <div className="col-span-2"> <label className="block text-xs font-bold uppercase text-slate-400 mb-2">{t('clients.create_modal.company_name_label')} <span className="text-red-400">*</span></label> <input type="text" placeholder={t('clients.create_modal.company_name_placeholder')} value={newOrg.name} onChange={e => { setNewOrg({...newOrg, name: e.target.value}); setCreateErrors(p => ({...p, name: ''})); }} className={`w-full border rounded-xl px-4 py-3 outline-none focus:ring-2 transition ${createErrors.name ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} /> {createErrors.name && <p className="text-xs text-red-500 mt-1">{createErrors.name}</p>} </div> <div> <label className="block text-xs font-bold uppercase text-slate-400 mb-2">{t('clients.create_modal.slug_label')} <span className="text-red-400">*</span></label> <input type="text" placeholder={t('clients.create_modal.slug_placeholder')} value={newOrg.slug} onChange={e => { setNewOrg({...newOrg, slug: e.target.value.toLowerCase().replace(/\s+/g, '-')}); setCreateErrors(p => ({...p, slug: ''})); }} className={`w-full border rounded-xl px-4 py-3 outline-none focus:ring-2 transition font-mono text-sm ${createErrors.slug ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} /> {createErrors.slug && <p className="text-xs text-red-500 mt-1">{createErrors.slug}</p>} </div> <div> <label className="block text-xs font-bold uppercase text-slate-400 mb-2">{t('clients.create_modal.use_case_label')}</label> <select value={newOrg.mode} onChange={e => setNewOrg({...newOrg, mode: e.target.value as any})} className="w-full border border-slate-200 rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-slate-900 transition bg-white" > <option value="CRM_MARKETING">{t('clients.create_modal.mode_crm')}</option> <option value="PEDAGOGY">{t('clients.create_modal.mode_pedagogy')}</option> <option value="CUSTOMER_SERVICE">{t('clients.create_modal.mode_service')}</option> </select> </div> <div className="col-span-2"> <label className="block text-xs font-bold uppercase text-slate-400 mb-2">{t('clients.create_modal.workstream_label')}</label> <select value={newOrg.useCase} onChange={e => { const val = e.target.value as any; setNewOrg({ ...newOrg, useCase: val, mode: val === 'CRM_WHATSAPP' ? 'CRM_MARKETING' : 'PEDAGOGY' }); }} className="w-full border border-slate-200 rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-slate-900 transition bg-white" > <option value="EDUCATION">{t('clients.create_modal.use_case_education')}</option> <option value="CRM_WHATSAPP">{t('clients.create_modal.use_case_crm')}</option> </select> </div> </div> <div className="p-6 bg-slate-50 rounded-2xl border border-slate-100 space-y-4"> <h4 className="text-xs font-bold text-slate-500 uppercase tracking-widest">{t('clients.create_modal.admin_section')}</h4> <div className="grid grid-cols-2 gap-4"> <div> <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.create_modal.admin_name_label')} <span className="text-red-400">*</span></label> <input type="text" placeholder={t('clients.create_modal.admin_name_placeholder')} value={newOrg.adminName} onChange={e => { setNewOrg({...newOrg, adminName: e.target.value}); setCreateErrors(p => ({...p, adminName: ''})); }} className={`w-full border rounded-xl px-4 py-2 text-sm outline-none focus:ring-2 transition ${createErrors.adminName ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} /> {createErrors.adminName && <p className="text-xs text-red-500 mt-1">{createErrors.adminName}</p>} </div> <div> <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.create_modal.admin_email_label')} <span className="text-red-400">*</span></label> <input type="email" placeholder={t('clients.create_modal.admin_email_placeholder')} value={newOrg.adminEmail} onChange={e => { setNewOrg({...newOrg, adminEmail: e.target.value}); setCreateErrors(p => ({...p, adminEmail: ''})); }} className={`w-full border rounded-xl px-4 py-2 text-sm outline-none focus:ring-2 transition ${createErrors.adminEmail ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} /> {createErrors.adminEmail && <p className="text-xs text-red-500 mt-1">{createErrors.adminEmail}</p>} </div> <div className="col-span-2"> <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.create_modal.password_label')} <span className="text-red-400">*</span></label> <input type="password" placeholder={t('clients.create_modal.password_placeholder')} value={newOrg.password} onChange={e => { setNewOrg({...newOrg, password: e.target.value}); setCreateErrors(p => ({...p, password: ''})); }} className={`w-full border rounded-xl px-4 py-2 text-sm outline-none focus:ring-2 transition ${createErrors.password ? 'border-red-400 focus:ring-red-200' : 'border-slate-200 focus:ring-slate-900'}`} /> {createErrors.password && <p className="text-xs text-red-500 mt-1">{createErrors.password}</p>} </div> </div> </div> </div> {/* Fixed footer */} <div className="shrink-0 px-8 pb-8 pt-4 border-t border-slate-100"> <button type="submit" disabled={isCreating} className="w-full bg-slate-900 text-white py-4 rounded-2xl font-bold hover:bg-slate-800 transition disabled:opacity-50 flex items-center justify-center gap-2 shadow-xl shadow-slate-200" > {isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : t('clients.create_modal.submit')} </button> </div> </form> </div> </div> )} {/* Billing Modal */} {billingOrg && ( <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-6 z-[100]"> <div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg"> <div className="flex items-center justify-between p-6 border-b border-slate-100"> <h2 className="text-xl font-bold text-slate-900">{t('clients.billing_modal.title')}</h2> <button onClick={() => setBillingOrg(null)} className="p-2 hover:bg-slate-100 rounded-full transition"> <X className="w-5 h-5 text-slate-500" /> </button> </div> <div className="p-6 space-y-4"> <div className="flex items-center gap-3 bg-slate-50 rounded-2xl p-4"> <Building2 className="w-8 h-8 text-slate-400 shrink-0" /> <div> <p className="font-bold text-slate-900">{billingOrg.name}</p> <p className="text-xs text-slate-400 font-mono">{billingOrg.id}</p> </div> </div> <div className="grid grid-cols-2 gap-3 text-sm"> <div className="bg-slate-50 rounded-2xl p-4"> <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.mode_label')}</p> <p className="font-semibold text-slate-800">{billingOrg.mode}</p> </div> <div className="bg-slate-50 rounded-2xl p-4"> <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.waba_status_label')}</p> <WabaStatusBadge ms={metaStatuses[billingOrg.id]} /> </div> <div className="bg-slate-50 rounded-2xl p-4"> <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.daily_limit_label')}</p> <BillingTierDisplay ms={metaStatuses[billingOrg.id]} /> </div> <div className="bg-slate-50 rounded-2xl p-4"> <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.meta_business_label')}</p> <BusinessBadge ms={metaStatuses[billingOrg.id]} /> </div> </div> {billingOrg.wabaId && ( <div className="bg-slate-50 rounded-2xl p-4 text-sm"> <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{t('clients.billing_modal.waba_id_label')}</p> <p className="font-mono text-slate-700">{billingOrg.wabaId}</p> </div> )} </div> <div className="px-6 pb-4"> <div className="bg-slate-50 rounded-2xl p-4"> <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">{t('clients.billing_modal.plan_label')}</p> <select value={billingPlan} onChange={e => setBillingPlan(e.target.value)} className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm bg-white outline-none focus:ring-2 focus:ring-indigo-400 mb-3" > <option value="STARTER">{t('clients.billing_modal.plan_starter')}</option> <option value="GROWTH">{t('clients.billing_modal.plan_growth')}</option> <option value="SCALE">{t('clients.billing_modal.plan_scale')}</option> <option value="ENTERPRISE">{t('clients.billing_modal.plan_enterprise')}</option> </select> <button disabled={isSavingPlan || billingPlan === (billingOrg?.subscriptionPlan ?? 'STARTER')} onClick={async () => { if (!billingOrg) return; setIsSavingPlan(true); try { await api.put(`/v1/organizations/${billingOrg.id}`, { subscriptionPlan: billingPlan }, token!); setBillingOrg({ ...billingOrg, subscriptionPlan: billingPlan as any }); await fetchClients(); toast.success(t('clients.billing_modal.plan_updated')); } catch (err: any) { toast.error(`${t('clients.toast.error_prefix')}${err.message}`); } finally { setIsSavingPlan(false); } }} className="w-full py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white rounded-xl font-semibold text-sm transition flex items-center justify-center gap-2" > {isSavingPlan ? <Loader2 className="w-4 h-4 animate-spin" /> : t('clients.billing_modal.apply_plan')} </button> </div> </div> <div className="p-6 border-t border-slate-100"> <button onClick={() => setBillingOrg(null)} className="w-full py-3 bg-slate-900 text-white rounded-2xl font-bold hover:bg-slate-700 transition" > {t('clients.billing_modal.close')} </button> </div> </div> </div> )} {/* Super-admin: Credit Allocation Modal */} {creditOrg && isSuperAdmin && ( <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-end sm:items-center justify-center sm:p-4 z-[110]"> <div className="bg-white rounded-t-[2rem] sm:rounded-[2rem] shadow-2xl w-full sm:max-w-md flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"> <div className="shrink-0 flex items-center justify-between px-8 pt-8 pb-5 border-b border-slate-100"> <div> <h2 className="text-xl font-black text-slate-900">{t('clients.credits_modal.title')}</h2> <p className="text-sm text-slate-500 mt-0.5">{creditOrg.name}</p> </div> <button onClick={() => setCreditOrg(null)} className="p-2 hover:bg-slate-100 rounded-full transition"> <X className="w-5 h-5 text-slate-400" /> </button> </div> <div className="overflow-y-auto flex-1 px-8 py-6 space-y-5"> <div className="bg-slate-50 rounded-2xl p-4 flex items-center justify-between"> <span className="text-sm text-slate-500">{t('clients.credits_modal.current_balance')}</span> <span className="text-base font-bold text-slate-900"> {(creditOrg as any).walletBalance ?? 'β€”'} {t('clients.credits_modal.credits_unit')} </span> </div> <div> <label className="block text-xs font-bold uppercase text-slate-400 mb-2"> {t('clients.credits_modal.add_credits_label')} </label> <input type="number" min="1" value={creditAmount} onChange={e => setCreditAmount(e.target.value)} placeholder={t('clients.credits_modal.add_credits_placeholder')} className="w-full border border-slate-200 rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition font-mono text-lg" /> <p className="text-xs text-slate-400 mt-1.5">{t('clients.credits_modal.rate_hint')}</p> </div> </div> <div className="shrink-0 px-8 pb-8 pt-4 border-t border-slate-100"> <button type="button" disabled={isSavingCredits || !creditAmount || Number(creditAmount) <= 0} onClick={async () => { if (!creditAmount || Number(creditAmount) <= 0) return; setIsSavingCredits(true); try { const data = await api.post('/v1/billing/admin/allocate', { organizationId: creditOrg.id, amount: Number(creditAmount) }, token!); toast.success(`${creditAmount} ${t('clients.credits_modal.credits_unit')} β†’ ${data.newBalance}`); setCreditOrg(null); setCreditAmount(''); } catch { toast.error(t('clients.credits_modal.error')); } finally { setIsSavingCredits(false); } }} className="w-full bg-emerald-600 text-white py-4 rounded-2xl font-bold hover:bg-emerald-700 transition disabled:opacity-50 flex items-center justify-center gap-2" > {isSavingCredits ? <Loader2 className="w-4 h-4 animate-spin" /> : t('clients.credits_modal.submit')} </button> </div> </div> </div> )} {/* Personality Studio Modal */} {selectedOrgForPersonality && ( <PersonalityStudioModal org={selectedOrgForPersonality} onClose={() => setSelectedOrgForPersonality(null)} onSave={async (config) => { try { await api.patch(`/v1/organizations/${selectedOrgForPersonality.id}/personality`, config, token!); toast.success(t('clients.personality_modal.success')); fetchClients(); setSelectedOrgForPersonality(null); } catch (err: any) { toast.error(`${t('clients.toast.error_prefix')}${err.message}`); } }} /> )} {/* WhatsApp Setup Modal */} {setupOrg && ( <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4 z-50"> <div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg p-8 animate-in zoom-in-95 duration-200"> <div className="flex items-center justify-between mb-6"> <div> <h2 className="text-xl font-black text-slate-900">{t('clients.wa_setup_modal.title')}</h2> <p className="text-sm text-slate-500 mt-0.5">{setupOrg.name}</p> </div> <button onClick={() => setSetupOrg(null)} className="p-2 hover:bg-slate-100 rounded-full transition"> <X className="w-5 h-5 text-slate-400" /> </button> </div> {/* Option A β€” Direct (account dΓ©jΓ  sur Meta) */} <div className="border border-indigo-100 bg-indigo-50/40 rounded-2xl p-5 mb-4"> <p className="text-xs font-bold uppercase tracking-wider text-indigo-500 mb-3">{t('clients.wa_setup_modal.already_on_meta')}</p> <div className="space-y-3"> <div> <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.wa_setup_modal.waba_id_label')} <span className="text-red-400">*</span></label> <input type="text" placeholder={t('clients.wa_setup_modal.waba_id_placeholder')} value={directSetup.wabaId} onChange={e => setDirectSetup(s => ({ ...s, wabaId: e.target.value }))} className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm font-mono outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400" /> <p className="text-[10px] text-slate-400 mt-1"> <a href="https://business.facebook.com/settings/whatsapp-business-accounts" target="_blank" rel="noreferrer" className="text-indigo-500 underline">{t('clients.wa_setup_modal.waba_id_hint_link')}</a> </p> </div> <div> <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.wa_setup_modal.business_id_label')} <span className="text-slate-300">{t('clients.wa_setup_modal.business_id_optional')}</span></label> <input type="text" placeholder={t('clients.wa_setup_modal.business_id_placeholder')} value={directSetup.metaBusinessId} onChange={e => setDirectSetup(s => ({ ...s, metaBusinessId: e.target.value }))} className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm font-mono outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400" /> <p className="text-[10px] text-slate-400 mt-1"> <a href="https://business.facebook.com/settings/info" target="_blank" rel="noreferrer" className="text-indigo-500 underline">{t('clients.wa_setup_modal.business_id_hint_link')}</a> </p> </div> <div> <label className="block text-xs font-bold text-slate-500 mb-1">{t('clients.wa_setup_modal.token_label')} <span className="text-slate-300">{t('clients.wa_setup_modal.token_optional')}</span></label> <input type="password" placeholder={t('clients.wa_setup_modal.token_placeholder')} value={directSetup.accessToken} onChange={e => setDirectSetup(s => ({ ...s, accessToken: e.target.value }))} className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm font-mono outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400" /> <p className="text-[10px] text-slate-400 mt-1"> <a href="https://business.facebook.com/settings/system-users" target="_blank" rel="noreferrer" className="text-indigo-500 underline">{t('clients.wa_setup_modal.token_hint_link')}</a> </p> </div> {directSetup.phoneNumberId && ( <p className="text-xs text-slate-400">{t('clients.wa_setup_modal.phone_number_detected')} <span className="font-mono text-slate-600">{directSetup.phoneNumberId}</span></p> )} </div> <button onClick={handleDirectSetupSubmit} disabled={!directSetup.wabaId || isDirectSetupSaving} className="mt-4 w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white py-3 rounded-xl font-bold text-sm transition flex items-center justify-center gap-2" > {isDirectSetupSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle2 className="w-4 h-4" />} {t('clients.wa_setup_modal.save_config')} </button> </div> {/* Option B β€” Embedded Signup (nouveaux clients) */} <div className="border border-slate-100 rounded-2xl p-5"> <p className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">{t('clients.wa_setup_modal.new_account_heading')}</p> <p className="text-xs text-slate-500 mb-3">{t('clients.wa_setup_modal.new_account_desc')}</p> <button onClick={() => { setSetupOrg(null); handleEmbeddedSignup(setupOrg.id); }} className="w-full border border-slate-200 hover:bg-slate-50 text-slate-700 py-3 rounded-xl font-semibold text-sm transition flex items-center justify-center gap-2" > <svg className="w-4 h-4" viewBox="0 0 24 24" fill="#1877F2"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg> {t('clients.wa_setup_modal.connect_facebook')} </button> </div> </div> </div> )} {/* Meta Compliance Footer */} <div className="mt-12 p-6 bg-slate-50 rounded-2xl border border-slate-100"> <h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">{t('clients.tier.meta_compliance')}</h4> <div className="flex gap-8 opacity-60 grayscale hover:grayscale-0 transition duration-500"> <img src="https://upload.wikimedia.org/wikipedia/commons/b/be/Facebook_Messenger_logo_2020.svg" className="h-6" alt="Meta" /> <img src="https://upload.wikimedia.org/wikipedia/commons/6/6b/WhatsApp.svg" className="h-6" alt="WhatsApp" /> </div> </div> </div> ); } // ─── Meta status helpers ────────────────────────────────────────────────────── function dailyLimitLabel(ms: MetaStatus | undefined, t: (key: string) => string): string { if (!ms || ms.loading) return '…'; if (!ms.configured) return 'β€”'; const tierMap: Record<string, string> = { TIER_50: t('clients.tier.tier_50'), TIER_250: t('clients.tier.tier_250'), TIER_1K: t('clients.tier.tier_1k'), TIER_10K: t('clients.tier.tier_10k'), TIER_100K: t('clients.tier.tier_100k'), UNLIMITED: t('clients.tier.unlimited'), }; if (ms.messagingLimitTier) return tierMap[ms.messagingLimitTier] ?? ms.messagingLimitTier; return 'β€”'; } function WabaStatusBadge({ ms }: { ms?: MetaStatus }) { const { t } = useTranslation(); if (!ms || ms.loading) return <span className="text-slate-400 text-xs animate-pulse">{t('clients.waba.checking')}</span>; if (!ms.configured) return <span className="text-slate-400 font-medium text-xs">{t('clients.waba.not_connected')}</span>; const map: Record<string, { labelKey: string; cls: string }> = { APPROVED: { labelKey: 'clients.waba.approved', cls: 'text-emerald-600' }, PENDING: { labelKey: 'clients.waba.pending', cls: 'text-amber-600' }, REJECTED: { labelKey: 'clients.waba.rejected', cls: 'text-red-600' }, BANNED: { labelKey: 'clients.waba.banned', cls: 'text-red-600' }, UNKNOWN: { labelKey: 'clients.waba.unknown', cls: 'text-slate-500' }, }; const s = map[ms.wabaStatus ?? 'UNKNOWN'] ?? map.UNKNOWN; return <span className={`font-semibold text-sm ${s.cls}`}>{t(s.labelKey)}</span>; } function BusinessBadge({ ms }: { ms?: MetaStatus }) { const { t } = useTranslation(); if (!ms || ms.loading) return <span className="text-slate-400 text-xs animate-pulse">{t('clients.waba.checking')}</span>; if (!ms.configured || ms.businessId === undefined) return <span className="text-slate-400 font-medium text-xs">β€”</span>; return ms.businessVerified ? <span className="font-semibold text-sm text-emerald-600">{t('clients.waba.verified')}</span> : <span className="font-semibold text-sm text-amber-600">{t('clients.waba.not_verified')}</span>; } function MetaStatusCell({ ms, onRefresh, onGuide }: { ms?: MetaStatus; onRefresh: () => void; onGuide: () => void }) { const { t } = useTranslation(); const isApproved = ms?.wabaStatus === 'APPROVED'; const isLoading = !ms || ms.loading; return ( <div className="flex items-center gap-3"> <div className={`p-2 rounded-lg ${isLoading ? 'bg-slate-50' : isApproved ? 'bg-emerald-50' : 'bg-amber-50'}`}> {isLoading ? <ShieldCheck className="w-4 h-4 text-slate-300 animate-pulse" /> : isApproved ? <CheckCircle2 className="w-4 h-4 text-emerald-500" /> : <AlertTriangle className="w-4 h-4 text-amber-500" />} </div> <div className="min-w-0"> <div className="flex items-center gap-1.5"> <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">{t('clients.cells.waba_header')}</p> <button onClick={onRefresh} title={t('clients.waba.refresh_tooltip')} className="text-slate-300 hover:text-slate-500 transition"> <RefreshCw className="w-3 h-3" /> </button> </div> <div className="flex items-center gap-2 flex-wrap"> <WabaStatusBadge ms={ms} /> {ms && !isLoading && !isApproved && ms.configured && ( <button onClick={onGuide} className="text-[10px] text-indigo-600 hover:underline font-bold whitespace-nowrap"> {t('clients.waba.what_to_do')} </button> )} </div> {ms?.error && <p className="text-[10px] text-red-400 truncate">{ms.error}</p>} </div> </div> ); } function BusinessVerificationCell({ ms }: { ms?: MetaStatus }) { const { t } = useTranslation(); const isLoading = !ms || ms.loading; return ( <div className="flex items-center gap-3"> <div className={`p-2 rounded-lg ${isLoading ? 'bg-slate-50' : ms?.businessVerified ? 'bg-emerald-50' : ms?.businessId !== undefined ? 'bg-amber-50' : 'bg-slate-50'}`}> {isLoading ? <ShieldCheck className="w-4 h-4 text-slate-300 animate-pulse" /> : ms?.businessVerified ? <CheckCircle2 className="w-4 h-4 text-emerald-500" /> : ms?.businessId !== undefined ? <XCircle className="w-4 h-4 text-amber-500" /> : <ShieldCheck className="w-4 h-4 text-slate-300" />} </div> <div> <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">{t('clients.cells.business_header')}</p> <div className="flex items-center gap-2"> <BusinessBadge ms={ms} /> {ms && !isLoading && ms.configured && ms.businessId !== undefined && !ms.businessVerified && ( <a href="https://business.facebook.com/settings/security" target="_blank" rel="noreferrer" className="text-[10px] text-indigo-600 hover:underline font-bold whitespace-nowrap" > {t('clients.waba.verify_link')} </a> )} </div> </div> </div> ); } function QualityDot({ rating }: { rating?: string }) { const { t } = useTranslation(); const map: Record<string, string> = { GREEN: 'bg-emerald-400', YELLOW: 'bg-amber-400', RED: 'bg-red-400', UNKNOWN: 'bg-slate-300', }; const cls = map[rating ?? 'UNKNOWN'] ?? 'bg-slate-300'; return <span className={`inline-block w-2 h-2 rounded-full ${cls}`} title={`${t('clients.quality.dot_title')}${rating ?? 'β€”'}`} />; } const TIER_CONFIG: Record<string, { textCls: string; bgCls: string; iconCls: string; dotCls: string; order: number }> = { TIER_50: { textCls: 'text-red-600', bgCls: 'bg-red-50', iconCls: 'text-red-400', dotCls: 'bg-red-400', order: 0 }, TIER_250: { textCls: 'text-amber-600', bgCls: 'bg-amber-50', iconCls: 'text-amber-400', dotCls: 'bg-amber-400', order: 1 }, TIER_1K: { textCls: 'text-blue-600', bgCls: 'bg-blue-50', iconCls: 'text-blue-400', dotCls: 'bg-blue-400', order: 2 }, TIER_10K: { textCls: 'text-emerald-600', bgCls: 'bg-emerald-50', iconCls: 'text-emerald-400', dotCls: 'bg-emerald-400', order: 3 }, TIER_100K: { textCls: 'text-emerald-600', bgCls: 'bg-emerald-50', iconCls: 'text-emerald-400', dotCls: 'bg-emerald-400', order: 4 }, UNLIMITED: { textCls: 'text-emerald-600', bgCls: 'bg-emerald-50', iconCls: 'text-emerald-400', dotCls: 'bg-emerald-400', order: 5 }, }; function BillingTierDisplay({ ms }: { ms?: MetaStatus }) { const { t } = useTranslation(); const [showInfo, setShowInfo] = useState(false); const tierCfg = TIER_CONFIG[ms?.messagingLimitTier ?? '']; return ( <> <div className="flex items-center gap-1.5"> <span className={`font-semibold ${tierCfg?.textCls ?? 'text-slate-800'}`}>{dailyLimitLabel(ms, t)}</span> {ms?.qualityRating && <QualityDot rating={ms.qualityRating} />} {ms?.configured && ( <button onClick={() => setShowInfo(true)} title={t('clients.cells.daily_limit_info_tooltip')} className="text-slate-300 hover:text-indigo-500 transition"> <Info className="w-3.5 h-3.5" /> </button> )} </div> <TierInfoModal ms={ms} open={showInfo} onClose={() => setShowInfo(false)} /> </> ); } function TierInfoModal({ ms, open, onClose }: { ms?: MetaStatus; open: boolean; onClose: () => void }) { const { t } = useTranslation(); const currentTierOrder = TIER_CONFIG[ms?.messagingLimitTier ?? '']?.order ?? -1; const TIERS_IN_ORDER = [ { key: 'TIER_50', labelKey: 'clients.tier.tier_50', descKey: 'clients.tier_modal.tier_50_desc' }, { key: 'TIER_250', labelKey: 'clients.tier.tier_250', descKey: 'clients.tier_modal.tier_250_desc' }, { key: 'TIER_1K', labelKey: 'clients.tier.tier_1k', descKey: 'clients.tier_modal.tier_1k_desc' }, { key: 'TIER_10K', labelKey: 'clients.tier.tier_10k', descKey: 'clients.tier_modal.tier_10k_desc' }, { key: 'TIER_100K', labelKey: 'clients.tier.tier_100k', descKey: 'clients.tier_modal.tier_100k_desc' }, { key: 'UNLIMITED', labelKey: 'clients.tier.unlimited', descKey: 'clients.tier_modal.unlimited_desc' }, ]; const QUALITY_INFO = { GREEN: { dotCls: 'bg-emerald-400', ringCls: 'ring-emerald-300 bg-emerald-50', labelKey: 'clients.quality.good_label', descKey: 'clients.quality.good_desc' }, YELLOW: { dotCls: 'bg-amber-400', ringCls: 'ring-amber-300 bg-amber-50', labelKey: 'clients.quality.medium_label', descKey: 'clients.quality.medium_desc' }, RED: { dotCls: 'bg-red-400', ringCls: 'ring-red-300 bg-red-50', labelKey: 'clients.quality.risky_label', descKey: 'clients.quality.risky_desc' }, }; useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = ''; }; }, [open, onClose]); return ( <AnimatePresence> {open && ( <motion.div className="fixed inset-0 z-[200] flex items-end sm:items-center justify-center sm:p-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} onClick={onClose} > {/* Backdrop */} <div className="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" /> {/* Panel β€” bottom sheet on mobile, centered card on desktop */} <motion.div className="relative bg-white w-full sm:max-w-md sm:rounded-2xl rounded-t-3xl shadow-2xl flex flex-col" style={{ maxHeight: '85dvh' }} initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }} transition={{ type: 'spring', damping: 32, stiffness: 320, mass: 0.8 }} onClick={e => e.stopPropagation()} > {/* Drag handle β€” mobile only */} <div className="flex justify-center pt-3 pb-0 sm:hidden shrink-0"> <div className="w-9 h-1 bg-slate-200 rounded-full" /> </div> {/* Sticky header */} <div className="flex items-center justify-between px-6 pt-4 pb-3 sm:pt-6 shrink-0"> <h3 className="font-bold text-slate-900 text-base">{t('clients.tier_modal.title')}</h3> <button onClick={onClose} className="p-1.5 hover:bg-slate-100 rounded-lg transition -mr-1"> <X className="w-4 h-4 text-slate-500" /> </button> </div> {/* Scrollable body */} <div className="overflow-y-auto overscroll-contain px-6 pb-8 space-y-5"> {/* Tier ladder */} <div className="space-y-1.5"> <p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">{t('clients.tier_modal.levels_heading')}</p> {TIERS_IN_ORDER.map(({ key, labelKey, descKey }, i) => { const isCurrent = key === ms?.messagingLimitTier; const isPast = i < currentTierOrder; const cfg = TIER_CONFIG[key]; return ( <div key={key} className={`flex items-start gap-3 p-3 rounded-xl transition-all ${ isCurrent ? 'ring-2 ring-indigo-200 bg-indigo-50' : isPast ? 'opacity-30' : 'opacity-50' }`}> <span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${isCurrent ? (cfg?.dotCls ?? 'bg-indigo-400') : 'bg-slate-300'}`} /> <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap"> <span className={`text-sm font-semibold ${isCurrent ? cfg?.textCls : 'text-slate-500'}`}>{t(labelKey)}</span> {isCurrent && <span className="text-[10px] font-bold bg-indigo-100 text-indigo-600 px-2 py-0.5 rounded-full">{t('clients.tier_modal.current_badge')}</span>} </div> <p className="text-[11px] text-slate-400 mt-0.5 leading-snug">{t(descKey)}</p> </div> </div> ); })} </div> {/* Quality rating */} {ms?.qualityRating && ms.qualityRating !== 'UNKNOWN' && ( <div className="space-y-2"> <p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{t('clients.tier_modal.quality_heading')}</p> <div className="flex gap-2"> {(['GREEN', 'YELLOW', 'RED'] as const).map(r => { const isCurrent = ms.qualityRating === r; const q = QUALITY_INFO[r]; return ( <div key={r} className={`flex-1 p-3 rounded-xl text-center transition-all ${ isCurrent ? `ring-2 ring-offset-1 ${q.ringCls}` : 'bg-slate-50 opacity-30' }`}> <span className={`inline-block w-3 h-3 rounded-full ${q.dotCls} mb-1.5`} /> <p className="text-xs font-bold text-slate-700">{t(q.labelKey)}</p> <p className="text-[10px] text-slate-500 mt-0.5 leading-snug">{t(q.descKey)}</p> </div> ); })} </div> </div> )} <p className="text-[11px] text-slate-400 text-center leading-relaxed"> {t('clients.tier_modal.meta_auto_update')}<br /> {t('clients.tier_modal.levels_auto')} </p> </div> </motion.div> </motion.div> )} </AnimatePresence> ); } function DailyLimitCell({ ms }: { ms?: MetaStatus }) { const { t } = useTranslation(); const [showInfo, setShowInfo] = useState(false); const isLoading = !ms || ms.loading; const tierCfg = TIER_CONFIG[ms?.messagingLimitTier ?? '']; return ( <> <div className="flex items-center gap-3"> <div className={`p-2 rounded-lg ${tierCfg?.bgCls ?? 'bg-indigo-50'}`}> <MessageSquare className={`w-4 h-4 ${tierCfg?.iconCls ?? 'text-indigo-500'}`} /> </div> <div> <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">{t('clients.cells.daily_limit_header')}</p> <div className="flex items-center gap-1.5"> {isLoading ? <span className="text-slate-400 text-xs animate-pulse">…</span> : <p className={`text-sm font-semibold ${tierCfg?.textCls ?? 'text-slate-700'}`}>{dailyLimitLabel(ms, t)}</p> } {!isLoading && ms?.qualityRating && <QualityDot rating={ms.qualityRating} />} {!isLoading && ms?.configured && ( <button onClick={() => setShowInfo(true)} title={t('clients.cells.daily_limit_info_tooltip')} className="text-slate-300 hover:text-indigo-500 transition ml-0.5"> <Info className="w-3.5 h-3.5" /> </button> )} </div> </div> </div> <TierInfoModal ms={ms} open={showInfo} onClose={() => setShowInfo(false)} /> </> ); } // ─── Personality Studio Modal ───────────────────────────────────────────────── function PersonalityStudioModal({ org, onClose, onSave }: PersonalityModalProps) { const { t } = useTranslation(); const [config, setConfig] = useState(org.personalityConfig || { botName: '', coreMission: '', toneDescription: '' }); const [isSaving, setIsSaving] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSaving(true); await onSave(config); setIsSaving(false); }; return ( <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4 z-[60]"> <div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-2xl p-10 animate-in fade-in zoom-in-95 duration-300"> <div className="flex items-center justify-between mb-8"> <div className="flex items-center gap-4"> <div className="w-12 h-12 bg-indigo-50 rounded-2xl flex items-center justify-center"> <Activity className="w-6 h-6 text-indigo-600" /> </div> <div> <h2 className="text-2xl font-black text-slate-900">{t('clients.personality_modal.title')}</h2> <p className="text-slate-500 font-medium text-sm">{t('clients.personality_modal.subtitle', { orgName: org.name })}</p> </div> </div> <button onClick={onClose} className="p-3 hover:bg-slate-100 rounded-full transition"> <X className="w-6 h-6 text-slate-400" /> </button> </div> <form onSubmit={handleSubmit} className="space-y-8"> <div className="grid gap-6"> <div> <label className="block text-sm font-bold text-slate-700 mb-2.5">{t('clients.personality_modal.bot_name_label')}</label> <input type="text" placeholder={t('clients.personality_modal.bot_name_placeholder')} value={config.botName} onChange={e => setConfig({...config, botName: e.target.value})} className="w-full border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:ring-4 focus:ring-indigo-50 transition font-medium" /> </div> <div> <label className="block text-sm font-bold text-slate-700 mb-2.5">{t('clients.personality_modal.mission_label')}</label> <textarea placeholder={t('clients.personality_modal.mission_placeholder')} value={config.coreMission} onChange={e => setConfig({...config, coreMission: e.target.value})} className="w-full border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:ring-4 focus:ring-indigo-50 transition min-h-[120px] font-medium leading-relaxed" /> </div> <div> <label className="block text-sm font-bold text-slate-700 mb-2.5">{t('clients.personality_modal.tone_label')}</label> <textarea placeholder={t('clients.personality_modal.tone_placeholder')} value={config.toneDescription} onChange={e => setConfig({...config, toneDescription: e.target.value})} className="w-full border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:ring-4 focus:ring-indigo-50 transition min-h-[120px] font-medium leading-relaxed" /> </div> </div> <div className="flex gap-4 pt-4"> <button type="button" onClick={onClose} className="flex-1 px-6 py-4 rounded-2xl font-bold text-slate-600 hover:bg-slate-50 transition" > {t('clients.personality_modal.cancel')} </button> <button type="submit" disabled={isSaving} className="flex-[2] bg-slate-900 text-white py-4 rounded-2xl font-bold hover:bg-slate-800 transition disabled:opacity-50 flex items-center justify-center gap-3 shadow-xl shadow-slate-200" > {isSaving ? <Loader2 className="w-5 h-5 animate-spin" /> : <ShieldCheck className="w-5 h-5" />} {t('clients.personality_modal.submit')} </button> </div> </form> </div> </div> ); }
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Building2, Plus, MessageSquare, ShieldCheck, Activity, Loader2, X, RefreshCw, CheckCircle2, XCircle, AlertTriangle, Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../lib/api';
import { useAuth } from '../lib/auth';
import { launchEmbeddedSignup } from '../lib/meta-signup';
import MetaVerificationGuide from '../components/MetaVerificationGuide';
import { useToast } from '../hooks/useToast';
import { logError, logWarn } from '../lib/logger';
interface Organization {
id: string;
name: string;
mode: 'EDTECH' | 'CRM_MARKETING' | 'PEDAGOGY' | 'CUSTOMER_SERVICE';
wabaId?: string;
phoneNumbers?: { id: string; phoneNumber: string }[];
subscriptionPlan?: 'STARTER' | 'GROWTH' | 'SCALE' | 'ENTERPRISE';
subscriptionStatus?: string;
isHardStopped?: boolean;
personalityConfig?: {
botName?: string;
coreMission?: string;
toneDescription?: string;
};
}
interface MetaStatus {
configured: boolean;
wabaStatus?: 'APPROVED' | 'PENDING' | 'REJECTED' | 'BANNED' | 'UNKNOWN';
businessId?: string;
businessName?: string;
businessVerified?: boolean;
messagingLimitTier?: string;
qualityRating?: 'GREEN' | 'YELLOW' | 'RED' | 'UNKNOWN';
syncedAt?: string;
error?: string;
loading?: boolean;
}
interface PersonalityModalProps {
org: Organization;
onClose: () => void;
onSave: (config: any) => void;
}
export default function ClientsManagementView() {
const { t } = useTranslation();
const toast = useToast();
const { token, user } = useAuth();
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
const [clients, setClients] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [creditOrg, setCreditOrg] = useState<Organization | null>(null);
const [creditAmount, setCreditAmount] = useState('');
const [isSavingCredits, setIsSavingCredits] = useState(false);
const [selectedOrgForPersonality, setSelectedOrgForPersonality] = useState<Organization | null>(null);
const [newOrg, setNewOrg] = useState({
name: '',
slug: '',
adminEmail: '',
adminName: '',
password: '',
mode: 'PEDAGOGY' as 'CRM_MARKETING' | 'PEDAGOGY' | 'CUSTOMER_SERVICE',
useCase: 'EDUCATION' as 'EDUCATION' | 'CRM_WHATSAPP'
});
const [isCreating, setIsCreating] = useState(false);
const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
const [showGuide, setShowGuide] = useState(false);
const [billingOrg, setBillingOrg] = useState<Organization | null>(null);
const [billingPlan, setBillingPlan] = useState<string>('STARTER');
const [isSavingPlan, setIsSavingPlan] = useState(false);
const [metaStatuses, setMetaStatuses] = useState<Record<string, MetaStatus>>({});
const [setupOrg, setSetupOrg] = useState<Organization | null>(null);
const [directSetup, setDirectSetup] = useState({ wabaId: '', metaBusinessId: '', accessToken: '', phoneNumberId: '' });
const [isDirectSetupSaving, setIsDirectSetupSaving] = useState(false);
const fetchMetaStatus = async (orgId: string, force = false) => {
setMetaStatuses(prev => ({ ...prev, [orgId]: { ...prev[orgId], configured: false, loading: true } }));
try {
const status = force
? await api.post(`/v1/organizations/${orgId}/meta-status/refresh`, {}, token!)
: await api.get(`/v1/organizations/${orgId}/meta-status`, token!);
setMetaStatuses(prev => ({ ...prev, [orgId]: { ...status, loading: false } }));
} catch (err) {
logWarn('[Clients] fetchMetaStatus failed', err);
setMetaStatuses(prev => ({ ...prev, [orgId]: { configured: false, loading: false, error: t('common.error') } }));
}
};
const fetchClients = async () => {
if (!token) return;
try {
const data = await api.get('/v1/organizations', token);
setClients(data);
// Fetch Meta status for all orgs in parallel (non-blocking)
data.forEach((org: Organization) => fetchMetaStatus(org.id));
} catch (error) {
logError("Failed to fetch organizations:", error);