CognxSafeTrack Claude Sonnet 4.6 commited on
Commit ·
ab43d7b
1
Parent(s): 4e2a593
feat: wire i18n to dashboard, settings, users — complete locale files
Browse filesDashboardPage, SettingsPage, UserListPage now use useTranslation().
fr/es/pt locale files expanded with dashboard, analytics, users,
contacts, settings, crm, knowledge sections.
Sidebar was already translated; main content pages now follow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- apps/admin/src/locales/es.json +193 -8
- apps/admin/src/locales/fr.json +193 -8
- apps/admin/src/locales/pt.json +195 -10
- apps/admin/src/pages/DashboardPage.tsx +33 -22
- apps/admin/src/pages/SettingsPage.tsx +50 -59
- apps/admin/src/pages/UserListPage.tsx +40 -35
apps/admin/src/locales/es.json
CHANGED
|
@@ -10,20 +10,205 @@
|
|
| 10 |
"cancel": "Cancelar",
|
| 11 |
"loading": "Cargando...",
|
| 12 |
"error": "Ocurrió un error",
|
| 13 |
-
"success": "Éxito"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
"onboarding": {
|
| 16 |
"title": "Bienvenido a Xamlé.Studio",
|
| 17 |
"subtitle": "Configuremos tu escuela en unos segundos.",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"connect_fb": "Conectar con Facebook",
|
| 19 |
"fb_connected": "¡Cuenta de Facebook conectada!",
|
| 20 |
-
"setup_waba": "Configurando tu cuenta de WhatsApp Business..."
|
|
|
|
|
|
|
| 21 |
},
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
}
|
|
|
|
| 10 |
"cancel": "Cancelar",
|
| 11 |
"loading": "Cargando...",
|
| 12 |
"error": "Ocurrió un error",
|
| 13 |
+
"success": "Éxito",
|
| 14 |
+
"create": "Crear",
|
| 15 |
+
"delete": "Eliminar",
|
| 16 |
+
"edit": "Editar",
|
| 17 |
+
"search": "Buscar...",
|
| 18 |
+
"actions": "Acciones",
|
| 19 |
+
"export": "Exportar",
|
| 20 |
+
"sync": "Sincronizar",
|
| 21 |
+
"send": "Enviar",
|
| 22 |
+
"close": "Cerrar",
|
| 23 |
+
"confirm": "Confirmar",
|
| 24 |
+
"back": "Atrás",
|
| 25 |
+
"next": "Siguiente",
|
| 26 |
+
"yes": "Sí",
|
| 27 |
+
"no": "No",
|
| 28 |
+
"status": "Estado",
|
| 29 |
+
"date": "Fecha",
|
| 30 |
+
"name": "Nombre",
|
| 31 |
+
"email": "Correo",
|
| 32 |
+
"phone": "Teléfono",
|
| 33 |
+
"day": "Día",
|
| 34 |
+
"none": "Ninguno"
|
| 35 |
+
},
|
| 36 |
+
"nav": {
|
| 37 |
+
"home": "Inicio",
|
| 38 |
+
"inbox": "Bandeja de entrada",
|
| 39 |
+
"campaigns": "Campañas",
|
| 40 |
+
"templates": "Plantillas",
|
| 41 |
+
"organizations": "Organizaciones",
|
| 42 |
+
"users": "Usuarios",
|
| 43 |
+
"training": "Training Lab",
|
| 44 |
+
"moderation": "Moderación",
|
| 45 |
+
"b2b": "Clientes B2B"
|
| 46 |
+
},
|
| 47 |
+
"dashboard": {
|
| 48 |
+
"title": "Tablero",
|
| 49 |
+
"subtitle": "Estado de tu plataforma en tiempo real",
|
| 50 |
+
"select_org": "Bienvenido a EdTech Admin",
|
| 51 |
+
"select_org_hint": "Para empezar, selecciona una organización en el menú de la izquierda.",
|
| 52 |
+
"isolation_note": "El aislamiento de datos garantiza que solo ves las estadísticas de la organización activa.",
|
| 53 |
+
"loading": "Analizando datos...",
|
| 54 |
+
"no_enrollments": "Sin inscripciones",
|
| 55 |
+
"recent_enrollments": "Inscripciones recientes",
|
| 56 |
+
"export_csv": "Exportar CSV",
|
| 57 |
+
"stats": {
|
| 58 |
+
"users": "Usuarios",
|
| 59 |
+
"active": "Activos",
|
| 60 |
+
"completed": "Completados",
|
| 61 |
+
"tracks": "Cursos",
|
| 62 |
+
"revenue": "Ingresos",
|
| 63 |
+
"total_messages": "Mensajes Totales",
|
| 64 |
+
"active_users": "Usuarios Activos",
|
| 65 |
+
"completion_rate": "Tasa de Finalización",
|
| 66 |
+
"ai_cost": "Costo IA estimado"
|
| 67 |
+
},
|
| 68 |
+
"table": {
|
| 69 |
+
"phone": "Teléfono",
|
| 70 |
+
"track": "Curso",
|
| 71 |
+
"status": "Estado",
|
| 72 |
+
"day": "Día",
|
| 73 |
+
"date": "Fecha"
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
"analytics": {
|
| 77 |
+
"title": "Análisis y Rendimiento",
|
| 78 |
+
"export": "Exportar informe",
|
| 79 |
+
"explain": "Explicar",
|
| 80 |
+
"messages": {
|
| 81 |
+
"title": "Volumen de Mensajes",
|
| 82 |
+
"inbound": "Entrantes",
|
| 83 |
+
"outbound": "Salientes"
|
| 84 |
+
},
|
| 85 |
+
"completion": {
|
| 86 |
+
"title": "Tasa de Éxito",
|
| 87 |
+
"completed": "Completados",
|
| 88 |
+
"in_progress": "En progreso"
|
| 89 |
+
},
|
| 90 |
+
"performance": {
|
| 91 |
+
"title": "Rendimiento",
|
| 92 |
+
"avg_score": "Puntuación media de ejercicios"
|
| 93 |
+
},
|
| 94 |
+
"engagement": {
|
| 95 |
+
"title": "Compromiso",
|
| 96 |
+
"avg_days": "Días de formación en promedio"
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
"users": {
|
| 100 |
+
"title": "Gestión de Usuarios",
|
| 101 |
+
"subtitle": "Todos los estudiantes inscritos",
|
| 102 |
+
"no_users": "No se encontraron usuarios",
|
| 103 |
+
"invite": "Invitar",
|
| 104 |
+
"columns": {
|
| 105 |
+
"name": "Nombre",
|
| 106 |
+
"phone": "Teléfono",
|
| 107 |
+
"track": "Curso activo",
|
| 108 |
+
"day": "Día",
|
| 109 |
+
"status": "Estado",
|
| 110 |
+
"joined": "Inscripción"
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
"contacts": {
|
| 114 |
+
"title": "Contactos",
|
| 115 |
+
"subtitle": "Gestión de tu base de contactos CRM",
|
| 116 |
+
"add": "Agregar contacto",
|
| 117 |
+
"import": "Importar",
|
| 118 |
+
"no_contacts": "Sin contactos",
|
| 119 |
+
"search_placeholder": "Buscar un contacto..."
|
| 120 |
+
},
|
| 121 |
+
"settings": {
|
| 122 |
+
"title": "Configuración",
|
| 123 |
+
"profile": "Perfil de la organización",
|
| 124 |
+
"branding": "Marca y Colores",
|
| 125 |
+
"ai_config": "Configuración IA",
|
| 126 |
+
"whatsapp_config": "Configuración WhatsApp",
|
| 127 |
+
"billing": "Facturación",
|
| 128 |
+
"org_name": "Nombre de la organización",
|
| 129 |
+
"primary_color": "Color principal",
|
| 130 |
+
"logo_url": "URL del logo",
|
| 131 |
+
"save_success": "Configuración guardada con éxito.",
|
| 132 |
+
"save_error": "No se pudo guardar la configuración.",
|
| 133 |
+
"no_org_selected": "Por favor selecciona una organización."
|
| 134 |
},
|
| 135 |
"onboarding": {
|
| 136 |
"title": "Bienvenido a Xamlé.Studio",
|
| 137 |
"subtitle": "Configuremos tu escuela en unos segundos.",
|
| 138 |
+
"step_welcome": "Bienvenida",
|
| 139 |
+
"step_legal": "Contrato",
|
| 140 |
+
"step_whatsapp": "WhatsApp",
|
| 141 |
+
"step_ai": "IA",
|
| 142 |
"connect_fb": "Conectar con Facebook",
|
| 143 |
"fb_connected": "¡Cuenta de Facebook conectada!",
|
| 144 |
+
"setup_waba": "Configurando tu cuenta de WhatsApp Business...",
|
| 145 |
+
"cta_launch": "Lanzar mi plataforma",
|
| 146 |
+
"legal_text": "Al aceptar, aceptas nuestros términos de socio y las políticas comerciales de Meta."
|
| 147 |
},
|
| 148 |
+
"crm": {
|
| 149 |
+
"stats": {
|
| 150 |
+
"total_contacts": "Contactos Totales",
|
| 151 |
+
"messages_sent": "Mensajes Enviados",
|
| 152 |
+
"open_rate": "Tasa de apertura",
|
| 153 |
+
"conversion": "Conversión"
|
| 154 |
+
},
|
| 155 |
+
"inbox": {
|
| 156 |
+
"title": "Conversaciones",
|
| 157 |
+
"no_messages": "No se encontraron conversaciones.",
|
| 158 |
+
"reply_placeholder": "Tu mensaje...",
|
| 159 |
+
"send": "Enviar"
|
| 160 |
+
},
|
| 161 |
+
"campaigns": {
|
| 162 |
+
"title": "Historial de Campañas",
|
| 163 |
+
"subtitle": "Sigue todas tus transmisiones y su rendimiento.",
|
| 164 |
+
"new_campaign": "Nueva Campaña",
|
| 165 |
+
"select_template": "Plantilla WhatsApp (Opcional)",
|
| 166 |
+
"choose_approved": "Selecciona una plantilla aprobada...",
|
| 167 |
+
"no_approved_templates": "No hay plantillas aprobadas. Sincroniza primero.",
|
| 168 |
+
"use_ai_text": "Usar texto generado por IA",
|
| 169 |
+
"status_sent": "Enviado",
|
| 170 |
+
"status_delivered": "Entregado",
|
| 171 |
+
"status_read": "Leído",
|
| 172 |
+
"status_failed": "Fallido"
|
| 173 |
+
}
|
| 174 |
+
},
|
| 175 |
+
"whatsapp": {
|
| 176 |
+
"templates": {
|
| 177 |
+
"title": "Plantillas de Mensajes WhatsApp",
|
| 178 |
+
"subtitle": "Gestiona y sincroniza tus plantillas aprobadas por Meta.",
|
| 179 |
+
"sync_button": "Sincronizar con Meta",
|
| 180 |
+
"create_button": "Crear plantilla",
|
| 181 |
+
"no_templates": "No hay plantillas. Sincroniza o crea una.",
|
| 182 |
+
"table": {
|
| 183 |
+
"name": "Nombre de plantilla",
|
| 184 |
+
"category": "Categoría",
|
| 185 |
+
"language": "Idioma",
|
| 186 |
+
"status": "Estado"
|
| 187 |
+
},
|
| 188 |
+
"create_modal": {
|
| 189 |
+
"title": "Crear nueva plantilla",
|
| 190 |
+
"name_label": "Nombre (minúsculas, sin espacios)",
|
| 191 |
+
"category_label": "Categoría",
|
| 192 |
+
"language_label": "Idioma",
|
| 193 |
+
"body_label": "Texto del cuerpo",
|
| 194 |
+
"submit": "Enviar para aprobación",
|
| 195 |
+
"success": "¡Plantilla enviada con éxito!",
|
| 196 |
+
"error": "Error al enviar la plantilla."
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
},
|
| 200 |
+
"knowledge": {
|
| 201 |
+
"title": "Base de Conocimiento",
|
| 202 |
+
"subtitle": "Gestiona los documentos de tu IA",
|
| 203 |
+
"upload": "Agregar documento",
|
| 204 |
+
"no_documents": "No hay documentos indexados."
|
| 205 |
+
},
|
| 206 |
+
"training": {
|
| 207 |
+
"title": "Training Lab",
|
| 208 |
+
"subtitle": "Prueba y mejora tu IA pedagógica"
|
| 209 |
+
},
|
| 210 |
+
"ai_setup": {
|
| 211 |
+
"title": "Configuración del Agente IA",
|
| 212 |
+
"save": "Guardar configuración"
|
| 213 |
}
|
| 214 |
}
|
apps/admin/src/locales/fr.json
CHANGED
|
@@ -10,20 +10,205 @@
|
|
| 10 |
"cancel": "Annuler",
|
| 11 |
"loading": "Chargement...",
|
| 12 |
"error": "Une erreur est survenue",
|
| 13 |
-
"success": "Succès"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
"onboarding": {
|
| 16 |
"title": "Bienvenue sur Xamlé.Studio",
|
| 17 |
"subtitle": "Configurons votre école en quelques secondes.",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"connect_fb": "Se connecter avec Facebook",
|
| 19 |
"fb_connected": "Compte Facebook connecté !",
|
| 20 |
-
"setup_waba": "Configuration de votre compte WhatsApp Business..."
|
|
|
|
|
|
|
| 21 |
},
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
}
|
|
|
|
| 10 |
"cancel": "Annuler",
|
| 11 |
"loading": "Chargement...",
|
| 12 |
"error": "Une erreur est survenue",
|
| 13 |
+
"success": "Succès",
|
| 14 |
+
"create": "Créer",
|
| 15 |
+
"delete": "Supprimer",
|
| 16 |
+
"edit": "Modifier",
|
| 17 |
+
"search": "Rechercher...",
|
| 18 |
+
"actions": "Actions",
|
| 19 |
+
"export": "Exporter",
|
| 20 |
+
"sync": "Synchroniser",
|
| 21 |
+
"send": "Envoyer",
|
| 22 |
+
"close": "Fermer",
|
| 23 |
+
"confirm": "Confirmer",
|
| 24 |
+
"back": "Retour",
|
| 25 |
+
"next": "Suivant",
|
| 26 |
+
"yes": "Oui",
|
| 27 |
+
"no": "Non",
|
| 28 |
+
"status": "Statut",
|
| 29 |
+
"date": "Date",
|
| 30 |
+
"name": "Nom",
|
| 31 |
+
"email": "Email",
|
| 32 |
+
"phone": "Téléphone",
|
| 33 |
+
"day": "Jour",
|
| 34 |
+
"none": "Aucun"
|
| 35 |
+
},
|
| 36 |
+
"nav": {
|
| 37 |
+
"home": "Accueil",
|
| 38 |
+
"inbox": "Messagerie",
|
| 39 |
+
"campaigns": "Campagnes",
|
| 40 |
+
"templates": "Modèles",
|
| 41 |
+
"organizations": "Organisations",
|
| 42 |
+
"users": "Utilisateurs",
|
| 43 |
+
"training": "Training Lab",
|
| 44 |
+
"moderation": "Modération",
|
| 45 |
+
"b2b": "Clients B2B"
|
| 46 |
+
},
|
| 47 |
+
"dashboard": {
|
| 48 |
+
"title": "Tableau de Bord",
|
| 49 |
+
"subtitle": "Statut de votre plateforme en temps réel",
|
| 50 |
+
"select_org": "Bienvenue sur EdTech Admin",
|
| 51 |
+
"select_org_hint": "Pour commencer, veuillez sélectionner une organisation dans le menu déroulant à gauche.",
|
| 52 |
+
"isolation_note": "L'isolation des données garantit que vous ne voyez que les statistiques de l'organisation active.",
|
| 53 |
+
"loading": "Analyse des données en cours...",
|
| 54 |
+
"no_enrollments": "Aucune inscription",
|
| 55 |
+
"recent_enrollments": "Inscriptions récentes",
|
| 56 |
+
"export_csv": "Exporter CSV",
|
| 57 |
+
"stats": {
|
| 58 |
+
"users": "Utilisateurs",
|
| 59 |
+
"active": "Actifs",
|
| 60 |
+
"completed": "Complétés",
|
| 61 |
+
"tracks": "Parcours",
|
| 62 |
+
"revenue": "Revenus",
|
| 63 |
+
"total_messages": "Messages Totaux",
|
| 64 |
+
"active_users": "Utilisateurs Actifs",
|
| 65 |
+
"completion_rate": "Taux de Complétion",
|
| 66 |
+
"ai_cost": "Estimation Coût IA"
|
| 67 |
+
},
|
| 68 |
+
"table": {
|
| 69 |
+
"phone": "Téléphone",
|
| 70 |
+
"track": "Parcours",
|
| 71 |
+
"status": "Statut",
|
| 72 |
+
"day": "Jour",
|
| 73 |
+
"date": "Date"
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
"analytics": {
|
| 77 |
+
"title": "Analyses & Performance",
|
| 78 |
+
"export": "Exporter le rapport",
|
| 79 |
+
"explain": "Expliquer",
|
| 80 |
+
"messages": {
|
| 81 |
+
"title": "Volume des Messages",
|
| 82 |
+
"inbound": "Entrants",
|
| 83 |
+
"outbound": "Sortants"
|
| 84 |
+
},
|
| 85 |
+
"completion": {
|
| 86 |
+
"title": "Taux de Réussite",
|
| 87 |
+
"completed": "Complétés",
|
| 88 |
+
"in_progress": "En cours"
|
| 89 |
+
},
|
| 90 |
+
"performance": {
|
| 91 |
+
"title": "Performance",
|
| 92 |
+
"avg_score": "Score moyen des exercices"
|
| 93 |
+
},
|
| 94 |
+
"engagement": {
|
| 95 |
+
"title": "Engagement",
|
| 96 |
+
"avg_days": "Jours de formation en moyenne"
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
"users": {
|
| 100 |
+
"title": "Gestion des Utilisateurs",
|
| 101 |
+
"subtitle": "Tous les apprenants inscrits",
|
| 102 |
+
"no_users": "Aucun utilisateur trouvé",
|
| 103 |
+
"invite": "Inviter",
|
| 104 |
+
"columns": {
|
| 105 |
+
"name": "Nom",
|
| 106 |
+
"phone": "Téléphone",
|
| 107 |
+
"track": "Parcours actif",
|
| 108 |
+
"day": "Jour",
|
| 109 |
+
"status": "Statut",
|
| 110 |
+
"joined": "Inscription"
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
"contacts": {
|
| 114 |
+
"title": "Contacts",
|
| 115 |
+
"subtitle": "Gestion de votre base de contacts CRM",
|
| 116 |
+
"add": "Ajouter un contact",
|
| 117 |
+
"import": "Importer",
|
| 118 |
+
"no_contacts": "Aucun contact",
|
| 119 |
+
"search_placeholder": "Rechercher un contact..."
|
| 120 |
+
},
|
| 121 |
+
"settings": {
|
| 122 |
+
"title": "Paramètres",
|
| 123 |
+
"profile": "Profil de l'organisation",
|
| 124 |
+
"branding": "Marque & Couleurs",
|
| 125 |
+
"ai_config": "Configuration IA",
|
| 126 |
+
"whatsapp_config": "Configuration WhatsApp",
|
| 127 |
+
"billing": "Facturation",
|
| 128 |
+
"org_name": "Nom de l'organisation",
|
| 129 |
+
"primary_color": "Couleur principale",
|
| 130 |
+
"logo_url": "URL du logo",
|
| 131 |
+
"save_success": "Paramètres enregistrés avec succès.",
|
| 132 |
+
"save_error": "Impossible d'enregistrer les paramètres.",
|
| 133 |
+
"no_org_selected": "Veuillez sélectionner une organisation."
|
| 134 |
},
|
| 135 |
"onboarding": {
|
| 136 |
"title": "Bienvenue sur Xamlé.Studio",
|
| 137 |
"subtitle": "Configurons votre école en quelques secondes.",
|
| 138 |
+
"step_welcome": "Bienvenue",
|
| 139 |
+
"step_legal": "Contrat",
|
| 140 |
+
"step_whatsapp": "WhatsApp",
|
| 141 |
+
"step_ai": "IA",
|
| 142 |
"connect_fb": "Se connecter avec Facebook",
|
| 143 |
"fb_connected": "Compte Facebook connecté !",
|
| 144 |
+
"setup_waba": "Configuration de votre compte WhatsApp Business...",
|
| 145 |
+
"cta_launch": "Lancer ma plateforme",
|
| 146 |
+
"legal_text": "En acceptant, vous acceptez nos conditions de partenaire et les politiques commerciales de Meta."
|
| 147 |
},
|
| 148 |
+
"crm": {
|
| 149 |
+
"stats": {
|
| 150 |
+
"total_contacts": "Contacts Totaux",
|
| 151 |
+
"messages_sent": "Messages Envoyés",
|
| 152 |
+
"open_rate": "Taux d'ouverture",
|
| 153 |
+
"conversion": "Conversion"
|
| 154 |
+
},
|
| 155 |
+
"inbox": {
|
| 156 |
+
"title": "Conversations",
|
| 157 |
+
"no_messages": "Aucune conversation trouvée.",
|
| 158 |
+
"reply_placeholder": "Votre message...",
|
| 159 |
+
"send": "Envoyer"
|
| 160 |
+
},
|
| 161 |
+
"campaigns": {
|
| 162 |
+
"title": "Historique des Campagnes",
|
| 163 |
+
"subtitle": "Suivez toutes vos diffusions et leurs performances.",
|
| 164 |
+
"new_campaign": "Nouvelle Campagne",
|
| 165 |
+
"select_template": "Modèle WhatsApp (Optionnel)",
|
| 166 |
+
"choose_approved": "Sélectionner un modèle approuvé...",
|
| 167 |
+
"no_approved_templates": "Aucun modèle approuvé. Synchronisez d'abord.",
|
| 168 |
+
"use_ai_text": "Utiliser le texte généré par IA",
|
| 169 |
+
"status_sent": "Envoyé",
|
| 170 |
+
"status_delivered": "Livré",
|
| 171 |
+
"status_read": "Lu",
|
| 172 |
+
"status_failed": "Échec"
|
| 173 |
+
}
|
| 174 |
+
},
|
| 175 |
+
"whatsapp": {
|
| 176 |
+
"templates": {
|
| 177 |
+
"title": "Modèles de Messages WhatsApp",
|
| 178 |
+
"subtitle": "Gérez et synchronisez vos modèles approuvés par Meta.",
|
| 179 |
+
"sync_button": "Synchroniser avec Meta",
|
| 180 |
+
"create_button": "Créer un modèle",
|
| 181 |
+
"no_templates": "Aucun modèle. Synchronisez ou créez-en un.",
|
| 182 |
+
"table": {
|
| 183 |
+
"name": "Nom du modèle",
|
| 184 |
+
"category": "Catégorie",
|
| 185 |
+
"language": "Langue",
|
| 186 |
+
"status": "Statut"
|
| 187 |
+
},
|
| 188 |
+
"create_modal": {
|
| 189 |
+
"title": "Créer un nouveau modèle",
|
| 190 |
+
"name_label": "Nom du modèle (minuscules, sans espaces)",
|
| 191 |
+
"category_label": "Catégorie",
|
| 192 |
+
"language_label": "Langue",
|
| 193 |
+
"body_label": "Texte du corps",
|
| 194 |
+
"submit": "Soumettre pour approbation",
|
| 195 |
+
"success": "Modèle soumis avec succès !",
|
| 196 |
+
"error": "Échec de la soumission."
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
},
|
| 200 |
+
"knowledge": {
|
| 201 |
+
"title": "Base de Connaissances",
|
| 202 |
+
"subtitle": "Gérez les documents de votre IA",
|
| 203 |
+
"upload": "Ajouter un document",
|
| 204 |
+
"no_documents": "Aucun document indexé."
|
| 205 |
+
},
|
| 206 |
+
"training": {
|
| 207 |
+
"title": "Training Lab",
|
| 208 |
+
"subtitle": "Testez et affinez votre IA pédagogique"
|
| 209 |
+
},
|
| 210 |
+
"ai_setup": {
|
| 211 |
+
"title": "Configuration de l'Agent IA",
|
| 212 |
+
"save": "Enregistrer la configuration"
|
| 213 |
}
|
| 214 |
}
|
apps/admin/src/locales/pt.json
CHANGED
|
@@ -6,24 +6,209 @@
|
|
| 6 |
"analytics": "Análises",
|
| 7 |
"settings": "Configurações",
|
| 8 |
"logout": "Sair",
|
| 9 |
-
"save": "
|
| 10 |
"cancel": "Cancelar",
|
| 11 |
"loading": "Carregando...",
|
| 12 |
"error": "Ocorreu um erro",
|
| 13 |
-
"success": "Sucesso"
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
"
|
| 17 |
-
"
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
},
|
| 22 |
"nav": {
|
| 23 |
"home": "Início",
|
| 24 |
"inbox": "Caixa de entrada",
|
| 25 |
"campaigns": "Campanhas",
|
| 26 |
"templates": "Modelos",
|
| 27 |
-
"organizations": "Organizações"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
}
|
|
|
|
| 6 |
"analytics": "Análises",
|
| 7 |
"settings": "Configurações",
|
| 8 |
"logout": "Sair",
|
| 9 |
+
"save": "Guardar",
|
| 10 |
"cancel": "Cancelar",
|
| 11 |
"loading": "Carregando...",
|
| 12 |
"error": "Ocorreu um erro",
|
| 13 |
+
"success": "Sucesso",
|
| 14 |
+
"create": "Criar",
|
| 15 |
+
"delete": "Excluir",
|
| 16 |
+
"edit": "Editar",
|
| 17 |
+
"search": "Pesquisar...",
|
| 18 |
+
"actions": "Ações",
|
| 19 |
+
"export": "Exportar",
|
| 20 |
+
"sync": "Sincronizar",
|
| 21 |
+
"send": "Enviar",
|
| 22 |
+
"close": "Fechar",
|
| 23 |
+
"confirm": "Confirmar",
|
| 24 |
+
"back": "Voltar",
|
| 25 |
+
"next": "Próximo",
|
| 26 |
+
"yes": "Sim",
|
| 27 |
+
"no": "Não",
|
| 28 |
+
"status": "Estado",
|
| 29 |
+
"date": "Data",
|
| 30 |
+
"name": "Nome",
|
| 31 |
+
"email": "Email",
|
| 32 |
+
"phone": "Telefone",
|
| 33 |
+
"day": "Dia",
|
| 34 |
+
"none": "Nenhum"
|
| 35 |
},
|
| 36 |
"nav": {
|
| 37 |
"home": "Início",
|
| 38 |
"inbox": "Caixa de entrada",
|
| 39 |
"campaigns": "Campanhas",
|
| 40 |
"templates": "Modelos",
|
| 41 |
+
"organizations": "Organizações",
|
| 42 |
+
"users": "Utilizadores",
|
| 43 |
+
"training": "Training Lab",
|
| 44 |
+
"moderation": "Moderação",
|
| 45 |
+
"b2b": "Clientes B2B"
|
| 46 |
+
},
|
| 47 |
+
"dashboard": {
|
| 48 |
+
"title": "Painel",
|
| 49 |
+
"subtitle": "Estado da sua plataforma em tempo real",
|
| 50 |
+
"select_org": "Bem-vindo ao EdTech Admin",
|
| 51 |
+
"select_org_hint": "Para começar, selecione uma organização no menu à esquerda.",
|
| 52 |
+
"isolation_note": "O isolamento de dados garante que você veja apenas as estatísticas da organização ativa.",
|
| 53 |
+
"loading": "Analisando dados...",
|
| 54 |
+
"no_enrollments": "Sem inscrições",
|
| 55 |
+
"recent_enrollments": "Inscrições recentes",
|
| 56 |
+
"export_csv": "Exportar CSV",
|
| 57 |
+
"stats": {
|
| 58 |
+
"users": "Utilizadores",
|
| 59 |
+
"active": "Ativos",
|
| 60 |
+
"completed": "Concluídos",
|
| 61 |
+
"tracks": "Cursos",
|
| 62 |
+
"revenue": "Receita",
|
| 63 |
+
"total_messages": "Mensagens Totais",
|
| 64 |
+
"active_users": "Utilizadores Ativos",
|
| 65 |
+
"completion_rate": "Taxa de Conclusão",
|
| 66 |
+
"ai_cost": "Custo IA estimado"
|
| 67 |
+
},
|
| 68 |
+
"table": {
|
| 69 |
+
"phone": "Telefone",
|
| 70 |
+
"track": "Curso",
|
| 71 |
+
"status": "Estado",
|
| 72 |
+
"day": "Dia",
|
| 73 |
+
"date": "Data"
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
"analytics": {
|
| 77 |
+
"title": "Análises e Desempenho",
|
| 78 |
+
"export": "Exportar relatório",
|
| 79 |
+
"explain": "Explicar",
|
| 80 |
+
"messages": {
|
| 81 |
+
"title": "Volume de Mensagens",
|
| 82 |
+
"inbound": "Recebidas",
|
| 83 |
+
"outbound": "Enviadas"
|
| 84 |
+
},
|
| 85 |
+
"completion": {
|
| 86 |
+
"title": "Taxa de Sucesso",
|
| 87 |
+
"completed": "Concluídos",
|
| 88 |
+
"in_progress": "Em progresso"
|
| 89 |
+
},
|
| 90 |
+
"performance": {
|
| 91 |
+
"title": "Desempenho",
|
| 92 |
+
"avg_score": "Pontuação média dos exercícios"
|
| 93 |
+
},
|
| 94 |
+
"engagement": {
|
| 95 |
+
"title": "Envolvimento",
|
| 96 |
+
"avg_days": "Dias de formação em média"
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
"users": {
|
| 100 |
+
"title": "Gestão de Utilizadores",
|
| 101 |
+
"subtitle": "Todos os alunos inscritos",
|
| 102 |
+
"no_users": "Nenhum utilizador encontrado",
|
| 103 |
+
"invite": "Convidar",
|
| 104 |
+
"columns": {
|
| 105 |
+
"name": "Nome",
|
| 106 |
+
"phone": "Telefone",
|
| 107 |
+
"track": "Curso ativo",
|
| 108 |
+
"day": "Dia",
|
| 109 |
+
"status": "Estado",
|
| 110 |
+
"joined": "Inscrição"
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
"contacts": {
|
| 114 |
+
"title": "Contactos",
|
| 115 |
+
"subtitle": "Gestão da sua base de contactos CRM",
|
| 116 |
+
"add": "Adicionar contacto",
|
| 117 |
+
"import": "Importar",
|
| 118 |
+
"no_contacts": "Sem contactos",
|
| 119 |
+
"search_placeholder": "Procurar um contacto..."
|
| 120 |
+
},
|
| 121 |
+
"settings": {
|
| 122 |
+
"title": "Configurações",
|
| 123 |
+
"profile": "Perfil da organização",
|
| 124 |
+
"branding": "Marca e Cores",
|
| 125 |
+
"ai_config": "Configuração IA",
|
| 126 |
+
"whatsapp_config": "Configuração WhatsApp",
|
| 127 |
+
"billing": "Faturação",
|
| 128 |
+
"org_name": "Nome da organização",
|
| 129 |
+
"primary_color": "Cor principal",
|
| 130 |
+
"logo_url": "URL do logótipo",
|
| 131 |
+
"save_success": "Configurações guardadas com sucesso.",
|
| 132 |
+
"save_error": "Não foi possível guardar as configurações.",
|
| 133 |
+
"no_org_selected": "Por favor selecione uma organização."
|
| 134 |
+
},
|
| 135 |
+
"onboarding": {
|
| 136 |
+
"title": "Bem-vindo ao Xamlé.Studio",
|
| 137 |
+
"subtitle": "Vamos configurar a sua escola em segundos.",
|
| 138 |
+
"step_welcome": "Boas-vindas",
|
| 139 |
+
"step_legal": "Contrato",
|
| 140 |
+
"step_whatsapp": "WhatsApp",
|
| 141 |
+
"step_ai": "IA",
|
| 142 |
+
"connect_fb": "Ligar ao Facebook",
|
| 143 |
+
"fb_connected": "Conta Facebook ligada!",
|
| 144 |
+
"setup_waba": "A configurar a sua conta WhatsApp Business...",
|
| 145 |
+
"cta_launch": "Lançar a minha plataforma",
|
| 146 |
+
"legal_text": "Ao aceitar, concorda com os nossos termos de parceiro e as políticas comerciais da Meta."
|
| 147 |
+
},
|
| 148 |
+
"crm": {
|
| 149 |
+
"stats": {
|
| 150 |
+
"total_contacts": "Total de Contactos",
|
| 151 |
+
"messages_sent": "Mensagens Enviadas",
|
| 152 |
+
"open_rate": "Taxa de abertura",
|
| 153 |
+
"conversion": "Conversão"
|
| 154 |
+
},
|
| 155 |
+
"inbox": {
|
| 156 |
+
"title": "Conversas",
|
| 157 |
+
"no_messages": "Nenhuma conversa encontrada.",
|
| 158 |
+
"reply_placeholder": "A sua mensagem...",
|
| 159 |
+
"send": "Enviar"
|
| 160 |
+
},
|
| 161 |
+
"campaigns": {
|
| 162 |
+
"title": "Histórico de Campanhas",
|
| 163 |
+
"subtitle": "Acompanhe todas as suas transmissões e o seu desempenho.",
|
| 164 |
+
"new_campaign": "Nova Campanha",
|
| 165 |
+
"select_template": "Modelo WhatsApp (Opcional)",
|
| 166 |
+
"choose_approved": "Selecione um modelo aprovado...",
|
| 167 |
+
"no_approved_templates": "Sem modelos aprovados. Sincronize primeiro.",
|
| 168 |
+
"use_ai_text": "Usar texto gerado por IA",
|
| 169 |
+
"status_sent": "Enviado",
|
| 170 |
+
"status_delivered": "Entregue",
|
| 171 |
+
"status_read": "Lido",
|
| 172 |
+
"status_failed": "Falhou"
|
| 173 |
+
}
|
| 174 |
+
},
|
| 175 |
+
"whatsapp": {
|
| 176 |
+
"templates": {
|
| 177 |
+
"title": "Modelos de Mensagens WhatsApp",
|
| 178 |
+
"subtitle": "Gerencie e sincronize os seus modelos aprovados pela Meta.",
|
| 179 |
+
"sync_button": "Sincronizar com Meta",
|
| 180 |
+
"create_button": "Criar modelo",
|
| 181 |
+
"no_templates": "Sem modelos. Sincronize ou crie um.",
|
| 182 |
+
"table": {
|
| 183 |
+
"name": "Nome do modelo",
|
| 184 |
+
"category": "Categoria",
|
| 185 |
+
"language": "Idioma",
|
| 186 |
+
"status": "Estado"
|
| 187 |
+
},
|
| 188 |
+
"create_modal": {
|
| 189 |
+
"title": "Criar novo modelo",
|
| 190 |
+
"name_label": "Nome (minúsculas, sem espaços)",
|
| 191 |
+
"category_label": "Categoria",
|
| 192 |
+
"language_label": "Idioma",
|
| 193 |
+
"body_label": "Texto do corpo",
|
| 194 |
+
"submit": "Submeter para aprovação",
|
| 195 |
+
"success": "Modelo submetido com sucesso!",
|
| 196 |
+
"error": "Falha ao submeter o modelo."
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
},
|
| 200 |
+
"knowledge": {
|
| 201 |
+
"title": "Base de Conhecimento",
|
| 202 |
+
"subtitle": "Gerencie os documentos da sua IA",
|
| 203 |
+
"upload": "Adicionar documento",
|
| 204 |
+
"no_documents": "Nenhum documento indexado."
|
| 205 |
+
},
|
| 206 |
+
"training": {
|
| 207 |
+
"title": "Training Lab",
|
| 208 |
+
"subtitle": "Teste e melhore a sua IA pedagógica"
|
| 209 |
+
},
|
| 210 |
+
"ai_setup": {
|
| 211 |
+
"title": "Configuração do Agente IA",
|
| 212 |
+
"save": "Guardar configuração"
|
| 213 |
}
|
| 214 |
}
|
apps/admin/src/pages/DashboardPage.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
import { useEffect, useState } from 'react';
|
|
|
|
| 2 |
import { Users, PlayCircle, CheckCircle, Lightbulb, DollarSign, Download, Building2, Loader2 } from 'lucide-react';
|
| 3 |
import { useAuth } from '@/lib/auth';
|
| 4 |
import { useTenant } from '@/lib/tenant';
|
| 5 |
import { api } from '@/lib/api';
|
| 6 |
|
| 7 |
export default function DashboardPage() {
|
|
|
|
| 8 |
const { token } = useAuth();
|
| 9 |
const { selectedOrgId } = useTenant();
|
| 10 |
const [stats, setStats] = useState<any>(null);
|
|
@@ -37,12 +39,12 @@ export default function DashboardPage() {
|
|
| 37 |
}, [token, selectedOrgId]);
|
| 38 |
|
| 39 |
const exportCSV = () => {
|
| 40 |
-
if (!enrollments.length) return alert('
|
| 41 |
const rows = enrollments.map((e: any) => [e.id, e.user?.phone, e.track?.title, e.status, e.currentDay, e.startedAt]);
|
| 42 |
const csv = [['ID', 'Phone', 'Track', 'Status', 'Day', 'Started'].join(','), ...rows.map(r => r.join(','))].join('\n');
|
| 43 |
-
const a = document.createElement('a');
|
| 44 |
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
|
| 45 |
-
a.download = `enrollments_${new Date().toISOString().slice(0, 10)}.csv`;
|
| 46 |
a.click();
|
| 47 |
};
|
| 48 |
|
|
@@ -50,12 +52,10 @@ export default function DashboardPage() {
|
|
| 50 |
return (
|
| 51 |
<div className="flex flex-col items-center justify-center min-h-[80vh] text-slate-400">
|
| 52 |
<Building2 className="w-16 h-16 mb-6 opacity-20" />
|
| 53 |
-
<h2 className="text-2xl font-bold text-slate-900">
|
| 54 |
-
<p className="max-w-md text-center mt-3 text-lg">
|
| 55 |
-
Pour commencer, veuillez sélectionner une **organisation** dans le menu déroulant à gauche.
|
| 56 |
-
</p>
|
| 57 |
<div className="mt-8 p-4 bg-blue-50 text-blue-700 rounded-2xl text-sm font-medium border border-blue-100">
|
| 58 |
-
💡
|
| 59 |
</div>
|
| 60 |
</div>
|
| 61 |
);
|
|
@@ -65,40 +65,49 @@ export default function DashboardPage() {
|
|
| 65 |
return (
|
| 66 |
<div className="flex flex-col items-center justify-center min-h-[80vh] text-slate-400">
|
| 67 |
<Loader2 className="w-10 h-10 animate-spin mb-4 text-slate-900" />
|
| 68 |
-
<p className="text-lg font-medium">
|
| 69 |
</div>
|
| 70 |
);
|
| 71 |
}
|
| 72 |
|
| 73 |
const statCards = [
|
| 74 |
-
{ icon: <Users className="w-6 h-6 text-slate-400" />, label: '
|
| 75 |
-
{ icon: <PlayCircle className="w-6 h-6 text-blue-400" />, label: '
|
| 76 |
-
{ icon: <CheckCircle className="w-6 h-6 text-green-400" />, label: '
|
| 77 |
-
{ icon: <Lightbulb className="w-6 h-6 text-purple-400" />, label: '
|
| 78 |
-
{ icon: <DollarSign className="w-6 h-6 text-emerald-400" />, label: '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
];
|
| 80 |
|
| 81 |
return (
|
| 82 |
<div className="p-8">
|
| 83 |
-
<h1 className="text-3xl font-bold mb-8 text-slate-800">
|
| 84 |
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
| 85 |
{statCards.map((s, i) => (
|
| 86 |
<div key={i} className="bg-white p-5 rounded-xl shadow-sm border border-slate-100 flex flex-col items-center gap-2">
|
| 87 |
-
{s.icon}
|
|
|
|
| 88 |
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
| 89 |
</div>
|
| 90 |
))}
|
| 91 |
</div>
|
| 92 |
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
| 93 |
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
|
| 94 |
-
<h2 className="text-lg font-semibold text-slate-800">
|
| 95 |
<button onClick={exportCSV} className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
|
| 96 |
-
<Download className="w-4 h-4" /><span>
|
| 97 |
</button>
|
| 98 |
</div>
|
| 99 |
<table className="w-full text-sm">
|
| 100 |
<thead className="bg-slate-50 text-xs text-slate-500 uppercase">
|
| 101 |
-
<tr>{
|
| 102 |
</thead>
|
| 103 |
<tbody>
|
| 104 |
{enrollments.map((e: any) => (
|
|
@@ -110,11 +119,13 @@ export default function DashboardPage() {
|
|
| 110 |
{e.status}
|
| 111 |
</span>
|
| 112 |
</td>
|
| 113 |
-
<td className="px-6 py-4">
|
| 114 |
-
<td className="px-6 py-4 text-slate-500">{new Date(e.startedAt).toLocaleDateString(
|
| 115 |
</tr>
|
| 116 |
))}
|
| 117 |
-
{!enrollments.length &&
|
|
|
|
|
|
|
| 118 |
</tbody>
|
| 119 |
</table>
|
| 120 |
</div>
|
|
|
|
| 1 |
import { useEffect, useState } from 'react';
|
| 2 |
+
import { useTranslation } from 'react-i18next';
|
| 3 |
import { Users, PlayCircle, CheckCircle, Lightbulb, DollarSign, Download, Building2, Loader2 } from 'lucide-react';
|
| 4 |
import { useAuth } from '@/lib/auth';
|
| 5 |
import { useTenant } from '@/lib/tenant';
|
| 6 |
import { api } from '@/lib/api';
|
| 7 |
|
| 8 |
export default function DashboardPage() {
|
| 9 |
+
const { t } = useTranslation();
|
| 10 |
const { token } = useAuth();
|
| 11 |
const { selectedOrgId } = useTenant();
|
| 12 |
const [stats, setStats] = useState<any>(null);
|
|
|
|
| 39 |
}, [token, selectedOrgId]);
|
| 40 |
|
| 41 |
const exportCSV = () => {
|
| 42 |
+
if (!enrollments.length) return alert(t('dashboard.no_enrollments'));
|
| 43 |
const rows = enrollments.map((e: any) => [e.id, e.user?.phone, e.track?.title, e.status, e.currentDay, e.startedAt]);
|
| 44 |
const csv = [['ID', 'Phone', 'Track', 'Status', 'Day', 'Started'].join(','), ...rows.map(r => r.join(','))].join('\n');
|
| 45 |
+
const a = document.createElement('a');
|
| 46 |
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
|
| 47 |
+
a.download = `enrollments_${new Date().toISOString().slice(0, 10)}.csv`;
|
| 48 |
a.click();
|
| 49 |
};
|
| 50 |
|
|
|
|
| 52 |
return (
|
| 53 |
<div className="flex flex-col items-center justify-center min-h-[80vh] text-slate-400">
|
| 54 |
<Building2 className="w-16 h-16 mb-6 opacity-20" />
|
| 55 |
+
<h2 className="text-2xl font-bold text-slate-900">{t('dashboard.select_org')}</h2>
|
| 56 |
+
<p className="max-w-md text-center mt-3 text-lg">{t('dashboard.select_org_hint')}</p>
|
|
|
|
|
|
|
| 57 |
<div className="mt-8 p-4 bg-blue-50 text-blue-700 rounded-2xl text-sm font-medium border border-blue-100">
|
| 58 |
+
💡 {t('dashboard.isolation_note')}
|
| 59 |
</div>
|
| 60 |
</div>
|
| 61 |
);
|
|
|
|
| 65 |
return (
|
| 66 |
<div className="flex flex-col items-center justify-center min-h-[80vh] text-slate-400">
|
| 67 |
<Loader2 className="w-10 h-10 animate-spin mb-4 text-slate-900" />
|
| 68 |
+
<p className="text-lg font-medium">{t('dashboard.loading')}</p>
|
| 69 |
</div>
|
| 70 |
);
|
| 71 |
}
|
| 72 |
|
| 73 |
const statCards = [
|
| 74 |
+
{ icon: <Users className="w-6 h-6 text-slate-400" />, label: t('dashboard.stats.users'), value: stats?.totalUsers || 0, color: 'text-slate-900' },
|
| 75 |
+
{ icon: <PlayCircle className="w-6 h-6 text-blue-400" />, label: t('dashboard.stats.active'), value: stats?.activeEnrollments || 0, color: 'text-blue-600' },
|
| 76 |
+
{ icon: <CheckCircle className="w-6 h-6 text-green-400" />, label: t('dashboard.stats.completed'), value: stats?.completedEnrollments || 0, color: 'text-green-600' },
|
| 77 |
+
{ icon: <Lightbulb className="w-6 h-6 text-purple-400" />, label: t('dashboard.stats.tracks'), value: stats?.totalTracks || 0, color: 'text-purple-600' },
|
| 78 |
+
{ icon: <DollarSign className="w-6 h-6 text-emerald-400" />, label: t('dashboard.stats.revenue'), value: `${(stats?.totalRevenue || 0).toLocaleString()} XOF`, color: 'text-emerald-600' },
|
| 79 |
+
];
|
| 80 |
+
|
| 81 |
+
const tableHeaders = [
|
| 82 |
+
t('dashboard.table.phone'),
|
| 83 |
+
t('dashboard.table.track'),
|
| 84 |
+
t('dashboard.table.status'),
|
| 85 |
+
t('dashboard.table.day'),
|
| 86 |
+
t('dashboard.table.date'),
|
| 87 |
];
|
| 88 |
|
| 89 |
return (
|
| 90 |
<div className="p-8">
|
| 91 |
+
<h1 className="text-3xl font-bold mb-8 text-slate-800">{t('dashboard.title')}</h1>
|
| 92 |
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
| 93 |
{statCards.map((s, i) => (
|
| 94 |
<div key={i} className="bg-white p-5 rounded-xl shadow-sm border border-slate-100 flex flex-col items-center gap-2">
|
| 95 |
+
{s.icon}
|
| 96 |
+
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{s.label}</p>
|
| 97 |
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
| 98 |
</div>
|
| 99 |
))}
|
| 100 |
</div>
|
| 101 |
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
| 102 |
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
|
| 103 |
+
<h2 className="text-lg font-semibold text-slate-800">{t('dashboard.recent_enrollments')}</h2>
|
| 104 |
<button onClick={exportCSV} className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
|
| 105 |
+
<Download className="w-4 h-4" /><span>{t('dashboard.export_csv')}</span>
|
| 106 |
</button>
|
| 107 |
</div>
|
| 108 |
<table className="w-full text-sm">
|
| 109 |
<thead className="bg-slate-50 text-xs text-slate-500 uppercase">
|
| 110 |
+
<tr>{tableHeaders.map(h => <th key={h} className="px-6 py-3 text-left">{h}</th>)}</tr>
|
| 111 |
</thead>
|
| 112 |
<tbody>
|
| 113 |
{enrollments.map((e: any) => (
|
|
|
|
| 119 |
{e.status}
|
| 120 |
</span>
|
| 121 |
</td>
|
| 122 |
+
<td className="px-6 py-4">{t('common.day')} {e.currentDay}</td>
|
| 123 |
+
<td className="px-6 py-4 text-slate-500">{new Date(e.startedAt).toLocaleDateString()}</td>
|
| 124 |
</tr>
|
| 125 |
))}
|
| 126 |
+
{!enrollments.length && (
|
| 127 |
+
<tr><td colSpan={5} className="px-6 py-8 text-center text-slate-400">{t('dashboard.no_enrollments')}</td></tr>
|
| 128 |
+
)}
|
| 129 |
</tbody>
|
| 130 |
</table>
|
| 131 |
</div>
|
apps/admin/src/pages/SettingsPage.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
|
|
|
| 3 |
import { api } from '../lib/api';
|
| 4 |
import { useTenant } from '../lib/tenant';
|
| 5 |
import { useAuth } from '../lib/auth';
|
| 6 |
|
| 7 |
export default function SettingsPage() {
|
|
|
|
| 8 |
const { token } = useAuth();
|
| 9 |
const { selectedOrgId } = useTenant();
|
| 10 |
const [org, setOrg] = useState<any>(null);
|
|
@@ -12,7 +14,7 @@ export default function SettingsPage() {
|
|
| 12 |
const [saving, setSaving] = useState(false);
|
| 13 |
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
| 14 |
|
| 15 |
-
const isValidId =
|
| 16 |
|
| 17 |
useEffect(() => {
|
| 18 |
if (isValidId && token) {
|
|
@@ -41,9 +43,9 @@ export default function SettingsPage() {
|
|
| 41 |
setMessage(null);
|
| 42 |
try {
|
| 43 |
await api.put(`/v1/organizations/${selectedOrgId}`, org, token);
|
| 44 |
-
setMessage({ type: 'success', text: '
|
| 45 |
} catch (err) {
|
| 46 |
-
setMessage({ type: 'error', text: '
|
| 47 |
} finally {
|
| 48 |
setSaving(false);
|
| 49 |
}
|
|
@@ -52,24 +54,24 @@ export default function SettingsPage() {
|
|
| 52 |
if (!isValidId) {
|
| 53 |
return (
|
| 54 |
<div className="p-12 text-center text-slate-400">
|
| 55 |
-
<p className="text-lg font-medium">
|
| 56 |
</div>
|
| 57 |
);
|
| 58 |
}
|
| 59 |
|
| 60 |
-
if (loading) return <div className="p-12 text-slate-500 animate-pulse">
|
| 61 |
-
if (!org) return <div className="p-12 text-red-500">
|
| 62 |
|
| 63 |
return (
|
| 64 |
<div className="p-8 max-w-4xl mx-auto">
|
| 65 |
<div className="flex justify-between items-center mb-8">
|
| 66 |
-
<h1 className="text-3xl font-bold text-slate-800">
|
| 67 |
-
<button
|
| 68 |
onClick={handleSave}
|
| 69 |
disabled={saving}
|
| 70 |
className="px-6 py-2 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 disabled:opacity-50 transition-all shadow-lg shadow-emerald-200"
|
| 71 |
>
|
| 72 |
-
{saving ? '
|
| 73 |
</button>
|
| 74 |
</div>
|
| 75 |
|
|
@@ -81,32 +83,31 @@ export default function SettingsPage() {
|
|
| 81 |
)}
|
| 82 |
|
| 83 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 84 |
-
{/* General Settings */}
|
| 85 |
<div className="space-y-6">
|
| 86 |
<section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
|
| 87 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 88 |
-
<span>🏢</span>
|
| 89 |
</h2>
|
| 90 |
<div className="space-y-4">
|
| 91 |
<div>
|
| 92 |
-
<label className="block text-sm font-medium text-slate-600 mb-1">
|
| 93 |
-
<input
|
| 94 |
-
type="text"
|
| 95 |
-
value={org.name || ''}
|
| 96 |
onChange={e => setOrg({...org, name: e.target.value})}
|
| 97 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
|
| 98 |
/>
|
| 99 |
</div>
|
| 100 |
<div>
|
| 101 |
-
<label className="block text-sm font-medium text-slate-600 mb-1">
|
| 102 |
-
<select
|
| 103 |
value={org.mode || 'EDTECH'}
|
| 104 |
onChange={e => setOrg({...org, mode: e.target.value})}
|
| 105 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none bg-white"
|
| 106 |
>
|
| 107 |
-
<option value="EDTECH">EdTech
|
| 108 |
-
<option value="WEBHOOK">Webhook
|
| 109 |
-
<option value="AI_AGENT">Agent IA
|
| 110 |
</select>
|
| 111 |
</div>
|
| 112 |
</div>
|
|
@@ -115,25 +116,25 @@ export default function SettingsPage() {
|
|
| 115 |
{org.mode === 'WEBHOOK' && (
|
| 116 |
<section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-in fade-in slide-in-from-top-4">
|
| 117 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 118 |
-
<span>🔗</span>
|
| 119 |
</h2>
|
| 120 |
<div className="space-y-4">
|
| 121 |
<div>
|
| 122 |
-
<label className="block text-sm font-medium text-slate-600 mb-1">URL
|
| 123 |
-
<input
|
| 124 |
-
type="url"
|
| 125 |
placeholder="https://votre-api.com/webhook"
|
| 126 |
-
value={org.webhookUrl || ''}
|
| 127 |
onChange={e => setOrg({...org, webhookUrl: e.target.value})}
|
| 128 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
|
| 129 |
/>
|
| 130 |
</div>
|
| 131 |
<div>
|
| 132 |
-
<label className="block text-sm font-medium text-slate-600 mb-1">Secret
|
| 133 |
-
<input
|
| 134 |
-
type="password"
|
| 135 |
placeholder="••••••••"
|
| 136 |
-
value={org.webhookSecret || ''}
|
| 137 |
onChange={e => setOrg({...org, webhookSecret: e.target.value})}
|
| 138 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
|
| 139 |
/>
|
|
@@ -145,25 +146,25 @@ export default function SettingsPage() {
|
|
| 145 |
{org.mode === 'AI_AGENT' && (
|
| 146 |
<section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-in fade-in slide-in-from-top-4">
|
| 147 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 148 |
-
<span>🤖</span>
|
| 149 |
</h2>
|
| 150 |
<div className="space-y-4">
|
| 151 |
<div>
|
| 152 |
-
<label className="block text-sm font-medium text-slate-600 mb-1">
|
| 153 |
-
<input
|
| 154 |
-
type="url"
|
| 155 |
placeholder="https://storage.com/catalogue.pdf"
|
| 156 |
-
value={org.knowledgeBaseUrl || ''}
|
| 157 |
onChange={e => setOrg({...org, knowledgeBaseUrl: e.target.value})}
|
| 158 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
|
| 159 |
/>
|
| 160 |
</div>
|
| 161 |
<div>
|
| 162 |
-
<label className="block text-sm font-medium text-slate-600 mb-1">Prompt
|
| 163 |
-
<textarea
|
| 164 |
rows={4}
|
| 165 |
placeholder="Tu es un conseiller expert en..."
|
| 166 |
-
value={org.customPrompt || ''}
|
| 167 |
onChange={e => setOrg({...org, customPrompt: e.target.value})}
|
| 168 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none resize-none"
|
| 169 |
/>
|
|
@@ -173,32 +174,22 @@ export default function SettingsPage() {
|
|
| 173 |
)}
|
| 174 |
</div>
|
| 175 |
|
| 176 |
-
{/* EdTech / Flow Config */}
|
| 177 |
<div className="space-y-6">
|
| 178 |
<section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
|
| 179 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 180 |
-
<span>⚙️</span>
|
| 181 |
</h2>
|
| 182 |
-
<
|
| 183 |
-
Définissez ici vos secteurs et vos parcours par défaut.
|
| 184 |
-
</p>
|
| 185 |
-
<textarea
|
| 186 |
rows={12}
|
| 187 |
-
value={typeof org.flowConfig === 'string' ? org.flowConfig : JSON.stringify(org.flowConfig, null, 2)}
|
| 188 |
-
onChange={e => {
|
| 189 |
-
try {
|
| 190 |
-
// We keep it as string while editing
|
| 191 |
-
setOrg({...org, flowConfig: e.target.value});
|
| 192 |
-
} catch(e) {}
|
| 193 |
-
}}
|
| 194 |
onBlur={() => {
|
| 195 |
try {
|
| 196 |
-
// Parse back to object on blur to validate
|
| 197 |
if (typeof org.flowConfig === 'string') {
|
| 198 |
setOrg({...org, flowConfig: JSON.parse(org.flowConfig)});
|
| 199 |
}
|
| 200 |
-
} catch
|
| 201 |
-
setMessage({ type: 'error', text: 'Format JSON invalide
|
| 202 |
}
|
| 203 |
}}
|
| 204 |
className="w-full px-4 py-2 font-mono text-xs border border-slate-200 rounded-xl focus:ring-2 focus:ring-slate-500 outline-none bg-slate-50"
|
|
@@ -206,23 +197,23 @@ export default function SettingsPage() {
|
|
| 206 |
</section>
|
| 207 |
|
| 208 |
<section className="bg-slate-800 p-6 rounded-2xl text-white">
|
| 209 |
-
<h2 className="text-lg font-semibold mb-2">
|
| 210 |
<div className="space-y-2 opacity-80 text-sm">
|
| 211 |
-
<p>WABA ID: <span className="font-mono text-emerald-400">{org.wabaId || '
|
| 212 |
-
<p>
|
| 213 |
</div>
|
| 214 |
</section>
|
| 215 |
|
| 216 |
<section className="bg-white p-6 rounded-2xl border border-indigo-100 shadow-sm shadow-indigo-50">
|
| 217 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 218 |
-
<span>💳</span>
|
| 219 |
</h2>
|
| 220 |
<div className="p-4 bg-indigo-50 rounded-xl mb-4 border border-indigo-100">
|
| 221 |
-
<div className="text-xs font-bold text-indigo-600 uppercase mb-1">
|
| 222 |
<div className="text-lg font-bold text-indigo-900">{org.subscriptionStatus || 'INACTIF'}</div>
|
| 223 |
</div>
|
| 224 |
<p className="text-xs text-slate-500">
|
| 225 |
-
|
| 226 |
</p>
|
| 227 |
</section>
|
| 228 |
</div>
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { useTranslation } from 'react-i18next';
|
| 4 |
import { api } from '../lib/api';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
| 6 |
import { useAuth } from '../lib/auth';
|
| 7 |
|
| 8 |
export default function SettingsPage() {
|
| 9 |
+
const { t } = useTranslation();
|
| 10 |
const { token } = useAuth();
|
| 11 |
const { selectedOrgId } = useTenant();
|
| 12 |
const [org, setOrg] = useState<any>(null);
|
|
|
|
| 14 |
const [saving, setSaving] = useState(false);
|
| 15 |
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
| 16 |
|
| 17 |
+
const isValidId = !!selectedOrgId;
|
| 18 |
|
| 19 |
useEffect(() => {
|
| 20 |
if (isValidId && token) {
|
|
|
|
| 43 |
setMessage(null);
|
| 44 |
try {
|
| 45 |
await api.put(`/v1/organizations/${selectedOrgId}`, org, token);
|
| 46 |
+
setMessage({ type: 'success', text: t('settings.save_success') });
|
| 47 |
} catch (err) {
|
| 48 |
+
setMessage({ type: 'error', text: t('settings.save_error') });
|
| 49 |
} finally {
|
| 50 |
setSaving(false);
|
| 51 |
}
|
|
|
|
| 54 |
if (!isValidId) {
|
| 55 |
return (
|
| 56 |
<div className="p-12 text-center text-slate-400">
|
| 57 |
+
<p className="text-lg font-medium">{t('settings.no_org_selected')}</p>
|
| 58 |
</div>
|
| 59 |
);
|
| 60 |
}
|
| 61 |
|
| 62 |
+
if (loading) return <div className="p-12 text-slate-500 animate-pulse">{t('common.loading')}</div>;
|
| 63 |
+
if (!org) return <div className="p-12 text-red-500">{t('common.error')}</div>;
|
| 64 |
|
| 65 |
return (
|
| 66 |
<div className="p-8 max-w-4xl mx-auto">
|
| 67 |
<div className="flex justify-between items-center mb-8">
|
| 68 |
+
<h1 className="text-3xl font-bold text-slate-800">{t('settings.title')}</h1>
|
| 69 |
+
<button
|
| 70 |
onClick={handleSave}
|
| 71 |
disabled={saving}
|
| 72 |
className="px-6 py-2 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 disabled:opacity-50 transition-all shadow-lg shadow-emerald-200"
|
| 73 |
>
|
| 74 |
+
{saving ? t('common.loading') : t('common.save')}
|
| 75 |
</button>
|
| 76 |
</div>
|
| 77 |
|
|
|
|
| 83 |
)}
|
| 84 |
|
| 85 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
|
|
| 86 |
<div className="space-y-6">
|
| 87 |
<section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
|
| 88 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 89 |
+
<span>🏢</span> {t('settings.profile')}
|
| 90 |
</h2>
|
| 91 |
<div className="space-y-4">
|
| 92 |
<div>
|
| 93 |
+
<label className="block text-sm font-medium text-slate-600 mb-1">{t('settings.org_name')}</label>
|
| 94 |
+
<input
|
| 95 |
+
type="text"
|
| 96 |
+
value={org.name || ''}
|
| 97 |
onChange={e => setOrg({...org, name: e.target.value})}
|
| 98 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
|
| 99 |
/>
|
| 100 |
</div>
|
| 101 |
<div>
|
| 102 |
+
<label className="block text-sm font-medium text-slate-600 mb-1">{t('settings.ai_config')}</label>
|
| 103 |
+
<select
|
| 104 |
value={org.mode || 'EDTECH'}
|
| 105 |
onChange={e => setOrg({...org, mode: e.target.value})}
|
| 106 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none bg-white"
|
| 107 |
>
|
| 108 |
+
<option value="EDTECH">EdTech</option>
|
| 109 |
+
<option value="WEBHOOK">Webhook</option>
|
| 110 |
+
<option value="AI_AGENT">Agent IA</option>
|
| 111 |
</select>
|
| 112 |
</div>
|
| 113 |
</div>
|
|
|
|
| 116 |
{org.mode === 'WEBHOOK' && (
|
| 117 |
<section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-in fade-in slide-in-from-top-4">
|
| 118 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 119 |
+
<span>🔗</span> {t('settings.whatsapp_config')}
|
| 120 |
</h2>
|
| 121 |
<div className="space-y-4">
|
| 122 |
<div>
|
| 123 |
+
<label className="block text-sm font-medium text-slate-600 mb-1">URL</label>
|
| 124 |
+
<input
|
| 125 |
+
type="url"
|
| 126 |
placeholder="https://votre-api.com/webhook"
|
| 127 |
+
value={org.webhookUrl || ''}
|
| 128 |
onChange={e => setOrg({...org, webhookUrl: e.target.value})}
|
| 129 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
|
| 130 |
/>
|
| 131 |
</div>
|
| 132 |
<div>
|
| 133 |
+
<label className="block text-sm font-medium text-slate-600 mb-1">Secret</label>
|
| 134 |
+
<input
|
| 135 |
+
type="password"
|
| 136 |
placeholder="••••••••"
|
| 137 |
+
value={org.webhookSecret || ''}
|
| 138 |
onChange={e => setOrg({...org, webhookSecret: e.target.value})}
|
| 139 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
|
| 140 |
/>
|
|
|
|
| 146 |
{org.mode === 'AI_AGENT' && (
|
| 147 |
<section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-in fade-in slide-in-from-top-4">
|
| 148 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 149 |
+
<span>🤖</span> {t('ai_setup.title')}
|
| 150 |
</h2>
|
| 151 |
<div className="space-y-4">
|
| 152 |
<div>
|
| 153 |
+
<label className="block text-sm font-medium text-slate-600 mb-1">{t('knowledge.title')} (URL PDF)</label>
|
| 154 |
+
<input
|
| 155 |
+
type="url"
|
| 156 |
placeholder="https://storage.com/catalogue.pdf"
|
| 157 |
+
value={org.knowledgeBaseUrl || ''}
|
| 158 |
onChange={e => setOrg({...org, knowledgeBaseUrl: e.target.value})}
|
| 159 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
|
| 160 |
/>
|
| 161 |
</div>
|
| 162 |
<div>
|
| 163 |
+
<label className="block text-sm font-medium text-slate-600 mb-1">Prompt</label>
|
| 164 |
+
<textarea
|
| 165 |
rows={4}
|
| 166 |
placeholder="Tu es un conseiller expert en..."
|
| 167 |
+
value={org.customPrompt || ''}
|
| 168 |
onChange={e => setOrg({...org, customPrompt: e.target.value})}
|
| 169 |
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none resize-none"
|
| 170 |
/>
|
|
|
|
| 174 |
)}
|
| 175 |
</div>
|
| 176 |
|
|
|
|
| 177 |
<div className="space-y-6">
|
| 178 |
<section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
|
| 179 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 180 |
+
<span>⚙️</span> Flow Config (JSON)
|
| 181 |
</h2>
|
| 182 |
+
<textarea
|
|
|
|
|
|
|
|
|
|
| 183 |
rows={12}
|
| 184 |
+
value={typeof org.flowConfig === 'string' ? org.flowConfig : JSON.stringify(org.flowConfig, null, 2)}
|
| 185 |
+
onChange={e => setOrg({...org, flowConfig: e.target.value})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
onBlur={() => {
|
| 187 |
try {
|
|
|
|
| 188 |
if (typeof org.flowConfig === 'string') {
|
| 189 |
setOrg({...org, flowConfig: JSON.parse(org.flowConfig)});
|
| 190 |
}
|
| 191 |
+
} catch {
|
| 192 |
+
setMessage({ type: 'error', text: 'Format JSON invalide.' });
|
| 193 |
}
|
| 194 |
}}
|
| 195 |
className="w-full px-4 py-2 font-mono text-xs border border-slate-200 rounded-xl focus:ring-2 focus:ring-slate-500 outline-none bg-slate-50"
|
|
|
|
| 197 |
</section>
|
| 198 |
|
| 199 |
<section className="bg-slate-800 p-6 rounded-2xl text-white">
|
| 200 |
+
<h2 className="text-lg font-semibold mb-2">{t('settings.whatsapp_config')}</h2>
|
| 201 |
<div className="space-y-2 opacity-80 text-sm">
|
| 202 |
+
<p>WABA ID: <span className="font-mono text-emerald-400">{org.wabaId || '—'}</span></p>
|
| 203 |
+
<p>Token: <span className="font-mono text-emerald-400">{org.systemUserToken ? '✅' : '❌'}</span></p>
|
| 204 |
</div>
|
| 205 |
</section>
|
| 206 |
|
| 207 |
<section className="bg-white p-6 rounded-2xl border border-indigo-100 shadow-sm shadow-indigo-50">
|
| 208 |
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 209 |
+
<span>💳</span> {t('settings.billing')}
|
| 210 |
</h2>
|
| 211 |
<div className="p-4 bg-indigo-50 rounded-xl mb-4 border border-indigo-100">
|
| 212 |
+
<div className="text-xs font-bold text-indigo-600 uppercase mb-1">{t('common.status')}</div>
|
| 213 |
<div className="text-lg font-bold text-indigo-900">{org.subscriptionStatus || 'INACTIF'}</div>
|
| 214 |
</div>
|
| 215 |
<p className="text-xs text-slate-500">
|
| 216 |
+
Orange Money / Wave — {t('common.loading').toLowerCase()}
|
| 217 |
</p>
|
| 218 |
</section>
|
| 219 |
</div>
|
apps/admin/src/pages/UserListPage.tsx
CHANGED
|
@@ -1,46 +1,39 @@
|
|
| 1 |
import { useEffect, useState } from 'react';
|
|
|
|
| 2 |
import { X, Building2, Loader2 } from 'lucide-react';
|
| 3 |
import { useAuth } from '../lib/auth';
|
| 4 |
import { useTenant } from '../lib/tenant';
|
| 5 |
import { API_URL, ah } from '../lib/api';
|
| 6 |
|
| 7 |
export default function UserListPage() {
|
|
|
|
| 8 |
const { token } = useAuth();
|
| 9 |
const { selectedOrgId } = useTenant();
|
| 10 |
-
const [users, setUsers] = useState<any[]>([]);
|
| 11 |
-
const [total, setTotal] = useState(0);
|
| 12 |
const [loading, setLoading] = useState(true);
|
| 13 |
-
const [selectedUser, setSelectedUser] = useState<any>(null);
|
| 14 |
-
const [messages, setMessages] = useState<any[]>([]);
|
| 15 |
const [loadingMsg, setLoadingMsg] = useState(false);
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
useEffect(() => {
|
| 20 |
-
if (!selectedOrgId) {
|
| 21 |
-
setLoading(false);
|
| 22 |
-
return;
|
| 23 |
-
}
|
| 24 |
setLoading(true);
|
| 25 |
fetch(`${API_URL}/v1/admin/users`, { headers: ah(token!, selectedOrgId) })
|
| 26 |
.then(r => r.json())
|
| 27 |
-
.then(d => {
|
| 28 |
-
setUsers(d.users || d);
|
| 29 |
-
setTotal(d.total || 0);
|
| 30 |
-
setLoading(false);
|
| 31 |
-
});
|
| 32 |
}, [token, selectedOrgId]);
|
| 33 |
|
| 34 |
const viewMessages = async (userId: string) => {
|
| 35 |
-
setLoadingMsg(true);
|
| 36 |
setSelectedUser({ id: userId });
|
| 37 |
try {
|
| 38 |
const res = await fetch(`${API_URL}/v1/admin/users/${userId}/messages`, { headers: ah(token!, selectedOrgId) });
|
| 39 |
const data = await res.json();
|
| 40 |
setSelectedUser(data.user);
|
| 41 |
setMessages(data.messages || []);
|
| 42 |
-
} catch
|
| 43 |
-
alert(
|
| 44 |
} finally {
|
| 45 |
setLoadingMsg(false);
|
| 46 |
}
|
|
@@ -50,8 +43,7 @@ export default function UserListPage() {
|
|
| 50 |
return (
|
| 51 |
<div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
|
| 52 |
<Building2 className="w-12 h-12 mb-4 opacity-20" />
|
| 53 |
-
<h3 className="text-lg font-bold text-slate-900">
|
| 54 |
-
<p className="max-w-xs text-center mt-2">Sélectionnez une organisation pour gérer ses utilisateurs.</p>
|
| 55 |
</div>
|
| 56 |
);
|
| 57 |
}
|
|
@@ -60,18 +52,25 @@ export default function UserListPage() {
|
|
| 60 |
return (
|
| 61 |
<div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
|
| 62 |
<Loader2 className="w-8 h-8 animate-spin mb-4 text-slate-900" />
|
| 63 |
-
<p>
|
| 64 |
</div>
|
| 65 |
);
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
return (
|
| 69 |
<div className="p-8">
|
| 70 |
-
<h1 className="text-3xl font-bold mb-6 text-slate-800">
|
|
|
|
|
|
|
| 71 |
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
| 72 |
<table className="w-full text-sm">
|
| 73 |
<thead className="bg-slate-50 text-xs text-slate-500 uppercase">
|
| 74 |
-
<tr>{
|
| 75 |
</thead>
|
| 76 |
<tbody>
|
| 77 |
{users.map((u: any) => (
|
|
@@ -82,13 +81,17 @@ export default function UserListPage() {
|
|
| 82 |
<td className="px-5 py-3 text-slate-500 text-xs">{u.activity || '—'}</td>
|
| 83 |
<td className="px-5 py-3 text-center">{u._count?.enrollments || 0}</td>
|
| 84 |
<td className="px-5 py-3 text-center">{u._count?.responses || 0}</td>
|
| 85 |
-
<td className="px-5 py-3 text-slate-400 text-xs">{new Date(u.createdAt).toLocaleDateString(
|
| 86 |
<td className="px-5 py-3 text-right">
|
| 87 |
-
<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">
|
|
|
|
|
|
|
| 88 |
</td>
|
| 89 |
</tr>
|
| 90 |
))}
|
| 91 |
-
{!users.length &&
|
|
|
|
|
|
|
| 92 |
</tbody>
|
| 93 |
</table>
|
| 94 |
</div>
|
|
@@ -98,16 +101,18 @@ export default function UserListPage() {
|
|
| 98 |
<div className="bg-slate-50 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
| 99 |
<div className="bg-white px-6 py-4 flex items-center justify-between border-b border-slate-200">
|
| 100 |
<div>
|
| 101 |
-
<h3 className="font-bold text-slate-800">{selectedUser.name || 'Chat
|
| 102 |
<p className="text-xs text-slate-500">{selectedUser.phone}</p>
|
| 103 |
</div>
|
| 104 |
-
<button onClick={() => { setSelectedUser(null); setMessages([]); }} className="p-2 hover:bg-slate-100 rounded-full">
|
|
|
|
|
|
|
| 105 |
</div>
|
| 106 |
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-[#e5ddd5]">
|
| 107 |
{loadingMsg ? (
|
| 108 |
-
<div className="text-center text-slate-500 py-10">
|
| 109 |
) : messages.length === 0 ? (
|
| 110 |
-
<div className="text-center text-slate-500 py-10 bg-white/50 rounded-xl">
|
| 111 |
) : (
|
| 112 |
messages.map((m: any) => {
|
| 113 |
const isBot = m.direction === 'OUTBOUND';
|
|
@@ -116,15 +121,15 @@ export default function UserListPage() {
|
|
| 116 |
<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'}`}>
|
| 117 |
{m.mediaUrl && (
|
| 118 |
<div className="mb-2">
|
| 119 |
-
{m.mediaUrl.endsWith('.mp3') || m.mediaUrl.endsWith('.ogg') || m.mediaUrl.endsWith('.webm')
|
| 120 |
-
<audio src={m.mediaUrl} controls className="h-10 max-w-full" />
|
| 121 |
-
<a href={m.mediaUrl} target="_blank" rel="noreferrer" className="text-blue-600 underline">
|
| 122 |
}
|
| 123 |
</div>
|
| 124 |
)}
|
| 125 |
{m.content && <p className="whitespace-pre-wrap">{m.content}</p>}
|
| 126 |
<p className={`text-[10px] mt-1 text-right ${isBot ? 'text-slate-400' : 'text-slate-500'}`}>
|
| 127 |
-
{new Date(m.createdAt).toLocaleTimeString(
|
| 128 |
</p>
|
| 129 |
</div>
|
| 130 |
</div>
|
|
|
|
| 1 |
import { useEffect, useState } from 'react';
|
| 2 |
+
import { useTranslation } from 'react-i18next';
|
| 3 |
import { X, Building2, Loader2 } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
| 6 |
import { API_URL, ah } from '../lib/api';
|
| 7 |
|
| 8 |
export default function UserListPage() {
|
| 9 |
+
const { t } = useTranslation();
|
| 10 |
const { token } = useAuth();
|
| 11 |
const { selectedOrgId } = useTenant();
|
| 12 |
+
const [users, setUsers] = useState<any[]>([]);
|
| 13 |
+
const [total, setTotal] = useState(0);
|
| 14 |
const [loading, setLoading] = useState(true);
|
| 15 |
+
const [selectedUser, setSelectedUser] = useState<any>(null);
|
| 16 |
+
const [messages, setMessages] = useState<any[]>([]);
|
| 17 |
const [loadingMsg, setLoadingMsg] = useState(false);
|
| 18 |
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
if (!selectedOrgId) { setLoading(false); return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
setLoading(true);
|
| 22 |
fetch(`${API_URL}/v1/admin/users`, { headers: ah(token!, selectedOrgId) })
|
| 23 |
.then(r => r.json())
|
| 24 |
+
.then(d => { setUsers(d.users || d); setTotal(d.total || 0); setLoading(false); });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}, [token, selectedOrgId]);
|
| 26 |
|
| 27 |
const viewMessages = async (userId: string) => {
|
| 28 |
+
setLoadingMsg(true);
|
| 29 |
setSelectedUser({ id: userId });
|
| 30 |
try {
|
| 31 |
const res = await fetch(`${API_URL}/v1/admin/users/${userId}/messages`, { headers: ah(token!, selectedOrgId) });
|
| 32 |
const data = await res.json();
|
| 33 |
setSelectedUser(data.user);
|
| 34 |
setMessages(data.messages || []);
|
| 35 |
+
} catch {
|
| 36 |
+
alert(t('common.error'));
|
| 37 |
} finally {
|
| 38 |
setLoadingMsg(false);
|
| 39 |
}
|
|
|
|
| 43 |
return (
|
| 44 |
<div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
|
| 45 |
<Building2 className="w-12 h-12 mb-4 opacity-20" />
|
| 46 |
+
<h3 className="text-lg font-bold text-slate-900">{t('settings.no_org_selected')}</h3>
|
|
|
|
| 47 |
</div>
|
| 48 |
);
|
| 49 |
}
|
|
|
|
| 52 |
return (
|
| 53 |
<div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
|
| 54 |
<Loader2 className="w-8 h-8 animate-spin mb-4 text-slate-900" />
|
| 55 |
+
<p>{t('common.loading')}</p>
|
| 56 |
</div>
|
| 57 |
);
|
| 58 |
}
|
| 59 |
|
| 60 |
+
const tableHeaders = [
|
| 61 |
+
t('common.phone'), t('common.name'), 'Langue', 'Secteur',
|
| 62 |
+
t('nav.organizations'), t('users.columns.status'), t('common.date'), t('common.actions')
|
| 63 |
+
];
|
| 64 |
+
|
| 65 |
return (
|
| 66 |
<div className="p-8">
|
| 67 |
+
<h1 className="text-3xl font-bold mb-6 text-slate-800">
|
| 68 |
+
{t('users.title')} <span className="text-lg font-normal text-slate-400">({total})</span>
|
| 69 |
+
</h1>
|
| 70 |
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
| 71 |
<table className="w-full text-sm">
|
| 72 |
<thead className="bg-slate-50 text-xs text-slate-500 uppercase">
|
| 73 |
+
<tr>{tableHeaders.map(h => <th key={h} className="px-5 py-3 text-left">{h}</th>)}</tr>
|
| 74 |
</thead>
|
| 75 |
<tbody>
|
| 76 |
{users.map((u: any) => (
|
|
|
|
| 81 |
<td className="px-5 py-3 text-slate-500 text-xs">{u.activity || '—'}</td>
|
| 82 |
<td className="px-5 py-3 text-center">{u._count?.enrollments || 0}</td>
|
| 83 |
<td className="px-5 py-3 text-center">{u._count?.responses || 0}</td>
|
| 84 |
+
<td className="px-5 py-3 text-slate-400 text-xs">{new Date(u.createdAt).toLocaleDateString()}</td>
|
| 85 |
<td className="px-5 py-3 text-right">
|
| 86 |
+
<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">
|
| 87 |
+
Conversation
|
| 88 |
+
</button>
|
| 89 |
</td>
|
| 90 |
</tr>
|
| 91 |
))}
|
| 92 |
+
{!users.length && (
|
| 93 |
+
<tr><td colSpan={8} className="px-5 py-8 text-center text-slate-400">{t('users.no_users')}</td></tr>
|
| 94 |
+
)}
|
| 95 |
</tbody>
|
| 96 |
</table>
|
| 97 |
</div>
|
|
|
|
| 101 |
<div className="bg-slate-50 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
| 102 |
<div className="bg-white px-6 py-4 flex items-center justify-between border-b border-slate-200">
|
| 103 |
<div>
|
| 104 |
+
<h3 className="font-bold text-slate-800">{selectedUser.name || 'Chat'}</h3>
|
| 105 |
<p className="text-xs text-slate-500">{selectedUser.phone}</p>
|
| 106 |
</div>
|
| 107 |
+
<button onClick={() => { setSelectedUser(null); setMessages([]); }} className="p-2 hover:bg-slate-100 rounded-full">
|
| 108 |
+
<X className="w-5 h-5 text-slate-500" />
|
| 109 |
+
</button>
|
| 110 |
</div>
|
| 111 |
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-[#e5ddd5]">
|
| 112 |
{loadingMsg ? (
|
| 113 |
+
<div className="text-center text-slate-500 py-10">{t('common.loading')}</div>
|
| 114 |
) : messages.length === 0 ? (
|
| 115 |
+
<div className="text-center text-slate-500 py-10 bg-white/50 rounded-xl">{t('crm.inbox.no_messages')}</div>
|
| 116 |
) : (
|
| 117 |
messages.map((m: any) => {
|
| 118 |
const isBot = m.direction === 'OUTBOUND';
|
|
|
|
| 121 |
<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'}`}>
|
| 122 |
{m.mediaUrl && (
|
| 123 |
<div className="mb-2">
|
| 124 |
+
{m.mediaUrl.endsWith('.mp3') || m.mediaUrl.endsWith('.ogg') || m.mediaUrl.endsWith('.webm')
|
| 125 |
+
? <audio src={m.mediaUrl} controls className="h-10 max-w-full" />
|
| 126 |
+
: <a href={m.mediaUrl} target="_blank" rel="noreferrer" className="text-blue-600 underline">Media</a>
|
| 127 |
}
|
| 128 |
</div>
|
| 129 |
)}
|
| 130 |
{m.content && <p className="whitespace-pre-wrap">{m.content}</p>}
|
| 131 |
<p className={`text-[10px] mt-1 text-right ${isBot ? 'text-slate-400' : 'text-slate-500'}`}>
|
| 132 |
+
{new Date(m.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 133 |
</p>
|
| 134 |
</div>
|
| 135 |
</div>
|