CognxSafeTrack Claude Sonnet 4.6 commited on
Commit
6b2ad5a
·
1 Parent(s): ab43d7b

feat: wire i18n to all remaining admin pages

Browse files

AIAgentSetup, AnalyticsPage, CampaignHistoryPage, ContactsPage,
KnowledgeBasePage, LiveFeed, TrackDaysPage, TrackFormPage,
TrackListPage, TrainingLab now use useTranslation() throughout.

STATUS_CONFIG labels migrated to labelKey pattern to stay compatible
with the hook-only call site. Removed all fr-FR locale hardcoding
in toLocaleDateString/toLocaleString calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

apps/admin/src/pages/AIAgentSetup.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import React, { useState, useEffect } from 'react';
 
2
  import { useAuth } from '@/lib/auth';
3
  import { useTenant } from '@/lib/tenant';
4
 
@@ -11,6 +12,7 @@ interface KbStats {
11
  }
12
 
13
  export default function AIAgentSetup() {
 
14
  const { token } = useAuth();
15
  const { selectedOrgId } = useTenant();
16
 
@@ -108,18 +110,18 @@ export default function AIAgentSetup() {
108
 
109
  return (
110
  <div className="p-8 max-w-4xl mx-auto">
111
- <h1 className="text-3xl font-bold text-slate-800 mb-2">Configuration de l'Agent IA</h1>
112
- <p className="text-slate-500 mb-8">Transformez vos documents en une intelligence conversationnelle sur WhatsApp.</p>
113
 
114
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
115
  <div className="md:col-span-2 space-y-6">
116
  {/* Knowledge Base Card */}
117
  <div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
118
  <h2 className="text-xl font-bold text-slate-800 mb-4 flex items-center gap-2">
119
- <span>📚</span> Base de Connaissances
120
  </h2>
121
  <p className="text-sm text-slate-600 mb-6">
122
- Téléchargez vos catalogues, manuels de formation ou FAQ. L'IA utilisera ces documents pour répondre précisément à vos clients.
123
  </p>
124
 
125
  <div className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors cursor-pointer group ${
@@ -144,10 +146,10 @@ export default function AIAgentSetup() {
144
  </span>
145
  </div>
146
  <p className="font-medium text-slate-800">
147
- {uploadStatus === 'IDLE' && 'Cliquez pour uploader un document'}
148
- {uploadStatus === 'UPLOADING' && 'Upload et indexation en cours...'}
149
- {uploadStatus === 'SUCCESS' && 'Document indexé avec succès !'}
150
- {uploadStatus === 'ERROR' && (uploadError || 'Erreur lors de l\'upload')}
151
  </p>
152
  {kbStats?.knowledgeBaseUrl && uploadStatus !== 'UPLOADING' && (
153
  <p className="text-xs text-emerald-600 mt-1 truncate max-w-xs mx-auto">
@@ -162,21 +164,21 @@ export default function AIAgentSetup() {
162
  {/* Personality Card */}
163
  <div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
164
  <h2 className="text-xl font-bold text-slate-800 mb-4 flex items-center gap-2">
165
- <span>🧠</span> Personnalité de l'Agent
166
  </h2>
167
  <div className="space-y-4">
168
  <div>
169
- <label className="block text-sm font-medium text-slate-600 mb-1">Rôle principal</label>
170
  <input
171
  type="text"
172
  value={role}
173
  onChange={e => setRole(e.target.value)}
174
- placeholder="Ex: Conseiller technique pour Agritech"
175
  className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
176
  />
177
  </div>
178
  <div>
179
- <label className="block text-sm font-medium text-slate-600 mb-1">Ton et Style</label>
180
  <div className="flex gap-2 flex-wrap">
181
  {TONES.map(t => (
182
  <button
@@ -200,10 +202,10 @@ export default function AIAgentSetup() {
200
  disabled={!role.trim() || saveStatus === 'SAVING'}
201
  className="mt-2 px-6 py-2.5 bg-slate-900 text-white rounded-xl text-sm font-bold hover:bg-slate-700 transition disabled:opacity-40"
202
  >
203
- {saveStatus === 'SAVING' ? 'Sauvegarde...' :
204
- saveStatus === 'SAVED' ? 'Sauvegardé' :
205
- saveStatus === 'ERROR' ? 'Erreur — Réessayer' :
206
- 'Sauvegarder la personnalité'}
207
  </button>
208
  </div>
209
  </div>
@@ -211,7 +213,7 @@ export default function AIAgentSetup() {
211
 
212
  <div className="space-y-6">
213
  <div className="bg-emerald-900 text-white p-6 rounded-3xl shadow-xl shadow-emerald-100">
214
- <h3 className="font-bold text-lg mb-2">Preview WhatsApp</h3>
215
  <div className="bg-emerald-800/50 rounded-2xl p-4 min-h-[200px] flex flex-col justify-end">
216
  <div className="bg-white text-slate-800 p-3 rounded-2xl rounded-bl-none text-xs self-start mb-2">
217
  Bonjour ! Comment puis-je vous aider aujourd'hui ?
@@ -228,23 +230,23 @@ export default function AIAgentSetup() {
228
  </div>
229
 
230
  <div className="bg-slate-50 p-6 rounded-3xl border border-slate-100">
231
- <h4 className="font-bold text-slate-800 mb-3">Statistiques Agent</h4>
232
  {kbStats === null ? (
233
- <p className="text-xs text-slate-400">Chargement...</p>
234
  ) : !kbStats.hasKnowledgeBase ? (
235
- <p className="text-xs text-slate-400">Aucune base de connaissances indexée.</p>
236
  ) : (
237
  <div className="space-y-3">
238
  <div className="flex justify-between text-sm">
239
- <span className="text-slate-500">Statut</span>
240
- <span className="text-emerald-600 font-bold">Actif</span>
241
  </div>
242
  <div className="flex justify-between text-sm">
243
- <span className="text-slate-500">Chunks indexés</span>
244
  <span className="text-slate-800 font-bold">{kbStats.chunkCount.toLocaleString()}</span>
245
  </div>
246
  <div className="flex justify-between text-sm">
247
- <span className="text-slate-500">Mots estimés</span>
248
  <span className="text-slate-800 font-bold">~{wordCount.toLocaleString()}</span>
249
  </div>
250
  </div>
 
1
  import React, { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { useAuth } from '@/lib/auth';
4
  import { useTenant } from '@/lib/tenant';
5
 
 
12
  }
13
 
14
  export default function AIAgentSetup() {
15
+ const { t } = useTranslation();
16
  const { token } = useAuth();
17
  const { selectedOrgId } = useTenant();
18
 
 
110
 
111
  return (
112
  <div className="p-8 max-w-4xl mx-auto">
113
+ <h1 className="text-3xl font-bold text-slate-800 mb-2">{t('ai_setup.title')}</h1>
114
+ <p className="text-slate-500 mb-8">{t('ai_setup.subtitle')}</p>
115
 
116
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
117
  <div className="md:col-span-2 space-y-6">
118
  {/* Knowledge Base Card */}
119
  <div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
120
  <h2 className="text-xl font-bold text-slate-800 mb-4 flex items-center gap-2">
121
+ <span>📚</span> {t('ai_setup.kb_title')}
122
  </h2>
123
  <p className="text-sm text-slate-600 mb-6">
124
+ {t('ai_setup.kb_desc')}
125
  </p>
126
 
127
  <div className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors cursor-pointer group ${
 
146
  </span>
147
  </div>
148
  <p className="font-medium text-slate-800">
149
+ {uploadStatus === 'IDLE' && t('ai_setup.upload_idle')}
150
+ {uploadStatus === 'UPLOADING' && t('ai_setup.upload_loading')}
151
+ {uploadStatus === 'SUCCESS' && t('ai_setup.upload_success')}
152
+ {uploadStatus === 'ERROR' && (uploadError || t('ai_setup.upload_error'))}
153
  </p>
154
  {kbStats?.knowledgeBaseUrl && uploadStatus !== 'UPLOADING' && (
155
  <p className="text-xs text-emerald-600 mt-1 truncate max-w-xs mx-auto">
 
164
  {/* Personality Card */}
165
  <div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
166
  <h2 className="text-xl font-bold text-slate-800 mb-4 flex items-center gap-2">
167
+ <span>🧠</span> {t('ai_setup.personality_title')}
168
  </h2>
169
  <div className="space-y-4">
170
  <div>
171
+ <label className="block text-sm font-medium text-slate-600 mb-1">{t('ai_setup.role_label')}</label>
172
  <input
173
  type="text"
174
  value={role}
175
  onChange={e => setRole(e.target.value)}
176
+ placeholder={t('ai_setup.role_placeholder')}
177
  className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none"
178
  />
179
  </div>
180
  <div>
181
+ <label className="block text-sm font-medium text-slate-600 mb-1">{t('ai_setup.tone_label')}</label>
182
  <div className="flex gap-2 flex-wrap">
183
  {TONES.map(t => (
184
  <button
 
202
  disabled={!role.trim() || saveStatus === 'SAVING'}
203
  className="mt-2 px-6 py-2.5 bg-slate-900 text-white rounded-xl text-sm font-bold hover:bg-slate-700 transition disabled:opacity-40"
204
  >
205
+ {saveStatus === 'SAVING' ? t('ai_setup.saving') :
206
+ saveStatus === 'SAVED' ? `${t('ai_setup.saved')}` :
207
+ saveStatus === 'ERROR' ? t('common.error') :
208
+ t('ai_setup.save')}
209
  </button>
210
  </div>
211
  </div>
 
213
 
214
  <div className="space-y-6">
215
  <div className="bg-emerald-900 text-white p-6 rounded-3xl shadow-xl shadow-emerald-100">
216
+ <h3 className="font-bold text-lg mb-2">{t('ai_setup.preview_title')}</h3>
217
  <div className="bg-emerald-800/50 rounded-2xl p-4 min-h-[200px] flex flex-col justify-end">
218
  <div className="bg-white text-slate-800 p-3 rounded-2xl rounded-bl-none text-xs self-start mb-2">
219
  Bonjour ! Comment puis-je vous aider aujourd'hui ?
 
230
  </div>
231
 
232
  <div className="bg-slate-50 p-6 rounded-3xl border border-slate-100">
233
+ <h4 className="font-bold text-slate-800 mb-3">{t('ai_setup.stats_title')}</h4>
234
  {kbStats === null ? (
235
+ <p className="text-xs text-slate-400">{t('common.loading')}</p>
236
  ) : !kbStats.hasKnowledgeBase ? (
237
+ <p className="text-xs text-slate-400">{t('ai_setup.no_kb')}</p>
238
  ) : (
239
  <div className="space-y-3">
240
  <div className="flex justify-between text-sm">
241
+ <span className="text-slate-500">{t('ai_setup.stats_status')}</span>
242
+ <span className="text-emerald-600 font-bold">{t('ai_setup.stats_active')}</span>
243
  </div>
244
  <div className="flex justify-between text-sm">
245
+ <span className="text-slate-500">{t('ai_setup.stats_chunks')}</span>
246
  <span className="text-slate-800 font-bold">{kbStats.chunkCount.toLocaleString()}</span>
247
  </div>
248
  <div className="flex justify-between text-sm">
249
+ <span className="text-slate-500">{t('ai_setup.stats_words')}</span>
250
  <span className="text-slate-800 font-bold">~{wordCount.toLocaleString()}</span>
251
  </div>
252
  </div>
apps/admin/src/pages/AnalyticsPage.tsx CHANGED
@@ -1,5 +1,6 @@
1
 
2
  import { useEffect, useState } from 'react';
 
3
  import {
4
  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
5
  PieChart, Pie, Cell
@@ -16,6 +17,7 @@ const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444'];
16
 
17
  export default function AnalyticsPage() {
18
  const { token } = useAuth();
 
19
  const { selectedOrgId } = useTenant();
20
  const [usage, setUsage] = useState<any>(null);
21
  const [pedagogy, setPedagogy] = useState<any>(null);
@@ -48,29 +50,29 @@ export default function AnalyticsPage() {
48
  return (
49
  <div className="p-12 text-center text-gray-400">
50
  <Building2 className="w-12 h-12 mx-auto mb-4 opacity-20" />
51
- Sélectionnez une organisation pour voir les statistiques.
52
  </div>
53
  );
54
  }
55
 
56
- if (loading) return <div className="p-12 animate-pulse text-gray-400">Chargement des données...</div>;
57
 
58
  const messageData = [
59
- { name: 'Entrants', value: usage?.messages?.inbound || 0 },
60
- { name: 'Sortants', value: usage?.messages?.outbound || 0 },
61
  ];
62
 
63
  const completionData = [
64
- { name: 'Complétés', value: pedagogy?.completion?.completed || 0 },
65
- { name: 'En cours', value: pedagogy?.completion?.active || 0 },
66
  ];
67
 
68
  return (
69
  <div className="p-8 max-w-7xl mx-auto space-y-8">
70
  <div className="flex items-center justify-between">
71
  <div>
72
- <h1 className="text-3xl font-bold text-slate-900">Tableau de Bord</h1>
73
- <p className="text-slate-500">Statut de votre plateforme en temps réel</p>
74
  </div>
75
  <div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 flex items-center gap-3 shadow-sm">
76
  <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
@@ -81,7 +83,7 @@ export default function AnalyticsPage() {
81
  {/* KPI Cards */}
82
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
83
  <StatCard
84
- title="Messages Totaux"
85
  value={usage?.messages?.total || 0}
86
  icon={<MessageSquare className="w-5 h-5" />}
87
  trend="+12%"
@@ -89,7 +91,7 @@ export default function AnalyticsPage() {
89
  bg="bg-indigo-50"
90
  />
91
  <StatCard
92
- title="Utilisateurs Actifs"
93
  value={usage?.users?.activeLast24h || 0}
94
  icon={<Users className="w-5 h-5" />}
95
  trend="Stable"
@@ -97,7 +99,7 @@ export default function AnalyticsPage() {
97
  bg="bg-emerald-50"
98
  />
99
  <StatCard
100
- title="Taux de Complétion"
101
  value={`${Math.round(pedagogy?.completion?.rate || 0)}%`}
102
  icon={<TrendingUp className="w-5 h-5" />}
103
  trend="+5%"
@@ -105,7 +107,7 @@ export default function AnalyticsPage() {
105
  bg="bg-amber-50"
106
  />
107
  <StatCard
108
- title="Estimation Coût IA"
109
  value={`$${usage?.costs?.estimatedUsd?.toFixed(2) || '0.00'}`}
110
  icon={<BrainCircuit className="w-5 h-5" />}
111
  trend="Optimisé"
@@ -118,7 +120,7 @@ export default function AnalyticsPage() {
118
  {/* Messages Chart */}
119
  <div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
120
  <h3 className="text-lg font-bold text-slate-800 mb-6 flex items-center gap-2">
121
- Volume des Messages
122
  </h3>
123
  <div className="h-64 min-h-[256px] w-full">
124
  <ResponsiveContainer width="100%" height="100%">
@@ -137,7 +139,7 @@ export default function AnalyticsPage() {
137
 
138
  {/* Completion Pie */}
139
  <div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
140
- <h3 className="text-lg font-bold text-slate-800 mb-6">Taux de Réussite</h3>
141
  <div className="h-64 min-h-[256px] w-full flex items-center">
142
  <ResponsiveContainer width="100%" height="100%">
143
  <PieChart>
@@ -175,21 +177,21 @@ export default function AnalyticsPage() {
175
  <div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-12">
176
  <div>
177
  <div className="flex items-center gap-3 text-indigo-300 mb-4 font-bold uppercase tracking-widest text-[10px]">
178
- <Award className="w-4 h-4" /> Performance
179
  </div>
180
  <div className="text-4xl font-bold mb-1">{pedagogy?.performance?.averageScore?.toFixed(1) || 0}</div>
181
- <div className="text-sm text-slate-400">Score moyen des exercices</div>
182
  </div>
183
  <div>
184
  <div className="flex items-center gap-3 text-indigo-300 mb-4 font-bold uppercase tracking-widest text-[10px]">
185
- <Clock className="w-4 h-4" /> Engagement
186
  </div>
187
  <div className="text-4xl font-bold mb-1">{pedagogy?.performance?.averageProgressDays?.toFixed(1) || 0}</div>
188
- <div className="text-sm text-slate-400">Jours de formation en moyenne</div>
189
  </div>
190
  <div className="flex items-end justify-end">
191
  <button className="bg-white/10 hover:bg-white/20 transition px-6 py-3 rounded-xl text-sm font-bold flex items-center gap-2">
192
- Exporter le rapport <ChevronRight className="w-4 h-4" />
193
  </button>
194
  </div>
195
  </div>
 
1
 
2
  import { useEffect, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
  import {
5
  BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
6
  PieChart, Pie, Cell
 
17
 
18
  export default function AnalyticsPage() {
19
  const { token } = useAuth();
20
+ const { t } = useTranslation();
21
  const { selectedOrgId } = useTenant();
22
  const [usage, setUsage] = useState<any>(null);
23
  const [pedagogy, setPedagogy] = useState<any>(null);
 
50
  return (
51
  <div className="p-12 text-center text-gray-400">
52
  <Building2 className="w-12 h-12 mx-auto mb-4 opacity-20" />
53
+ {t('dashboard.select_org_hint')}
54
  </div>
55
  );
56
  }
57
 
58
+ if (loading) return <div className="p-12 animate-pulse text-gray-400">{t('common.loading')}</div>;
59
 
60
  const messageData = [
61
+ { name: t('analytics.messages.inbound'), value: usage?.messages?.inbound || 0 },
62
+ { name: t('analytics.messages.outbound'), value: usage?.messages?.outbound || 0 },
63
  ];
64
 
65
  const completionData = [
66
+ { name: t('analytics.completion.completed'), value: pedagogy?.completion?.completed || 0 },
67
+ { name: t('analytics.completion.in_progress'), value: pedagogy?.completion?.active || 0 },
68
  ];
69
 
70
  return (
71
  <div className="p-8 max-w-7xl mx-auto space-y-8">
72
  <div className="flex items-center justify-between">
73
  <div>
74
+ <h1 className="text-3xl font-bold text-slate-900">{t('dashboard.title')}</h1>
75
+ <p className="text-slate-500">{t('dashboard.subtitle')}</p>
76
  </div>
77
  <div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 flex items-center gap-3 shadow-sm">
78
  <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
 
83
  {/* KPI Cards */}
84
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
85
  <StatCard
86
+ title={t('dashboard.stats.total_messages')}
87
  value={usage?.messages?.total || 0}
88
  icon={<MessageSquare className="w-5 h-5" />}
89
  trend="+12%"
 
91
  bg="bg-indigo-50"
92
  />
93
  <StatCard
94
+ title={t('dashboard.stats.active_users')}
95
  value={usage?.users?.activeLast24h || 0}
96
  icon={<Users className="w-5 h-5" />}
97
  trend="Stable"
 
99
  bg="bg-emerald-50"
100
  />
101
  <StatCard
102
+ title={t('dashboard.stats.completion_rate')}
103
  value={`${Math.round(pedagogy?.completion?.rate || 0)}%`}
104
  icon={<TrendingUp className="w-5 h-5" />}
105
  trend="+5%"
 
107
  bg="bg-amber-50"
108
  />
109
  <StatCard
110
+ title={t('dashboard.stats.ai_cost')}
111
  value={`$${usage?.costs?.estimatedUsd?.toFixed(2) || '0.00'}`}
112
  icon={<BrainCircuit className="w-5 h-5" />}
113
  trend="Optimisé"
 
120
  {/* Messages Chart */}
121
  <div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
122
  <h3 className="text-lg font-bold text-slate-800 mb-6 flex items-center gap-2">
123
+ {t('analytics.messages.title')}
124
  </h3>
125
  <div className="h-64 min-h-[256px] w-full">
126
  <ResponsiveContainer width="100%" height="100%">
 
139
 
140
  {/* Completion Pie */}
141
  <div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
142
+ <h3 className="text-lg font-bold text-slate-800 mb-6">{t('analytics.completion.title')}</h3>
143
  <div className="h-64 min-h-[256px] w-full flex items-center">
144
  <ResponsiveContainer width="100%" height="100%">
145
  <PieChart>
 
177
  <div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-12">
178
  <div>
179
  <div className="flex items-center gap-3 text-indigo-300 mb-4 font-bold uppercase tracking-widest text-[10px]">
180
+ <Award className="w-4 h-4" /> {t('analytics.performance.title')}
181
  </div>
182
  <div className="text-4xl font-bold mb-1">{pedagogy?.performance?.averageScore?.toFixed(1) || 0}</div>
183
+ <div className="text-sm text-slate-400">{t('analytics.performance.avg_score')}</div>
184
  </div>
185
  <div>
186
  <div className="flex items-center gap-3 text-indigo-300 mb-4 font-bold uppercase tracking-widest text-[10px]">
187
+ <Clock className="w-4 h-4" /> {t('analytics.engagement.title')}
188
  </div>
189
  <div className="text-4xl font-bold mb-1">{pedagogy?.performance?.averageProgressDays?.toFixed(1) || 0}</div>
190
+ <div className="text-sm text-slate-400">{t('analytics.engagement.avg_days')}</div>
191
  </div>
192
  <div className="flex items-end justify-end">
193
  <button className="bg-white/10 hover:bg-white/20 transition px-6 py-3 rounded-xl text-sm font-bold flex items-center gap-2">
194
+ {t('analytics.export')} <ChevronRight className="w-4 h-4" />
195
  </button>
196
  </div>
197
  </div>
apps/admin/src/pages/CampaignHistoryPage.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useState, useEffect } from 'react';
 
2
  import { Send, Search, ChevronLeft, ChevronRight, Loader2, Megaphone, CheckCheck, Eye, AlertCircle, Clock } from 'lucide-react';
3
  import { api } from '../lib/api';
4
  import { useAuth } from '../lib/auth';
@@ -31,15 +32,16 @@ interface CampaignResponse {
31
  const PAGE_SIZE = 30;
32
 
33
  const STATUS_CONFIG = {
34
- SENT: { label: 'Envoyé', icon: Send, color: 'text-blue-500', bg: 'bg-blue-50' },
35
- DELIVERED: { label: 'Livré', icon: CheckCheck, color: 'text-green-500', bg: 'bg-green-50' },
36
- READ: { label: 'Lu', icon: Eye, color: 'text-violet-500',bg: 'bg-violet-50'},
37
- FAILED: { label: 'Échoué', icon: AlertCircle, color: 'text-red-500', bg: 'bg-red-50' },
38
  } as const;
39
 
40
  type StatusFilter = 'ALL' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED';
41
 
42
  export default function CampaignHistoryPage() {
 
43
  const { token } = useAuth();
44
  const { selectedOrgId } = useTenant();
45
  const [data, setData] = useState<CampaignResponse | null>(null);
@@ -95,9 +97,9 @@ export default function CampaignHistoryPage() {
95
  <Megaphone className="w-5 h-5 text-amber-600" />
96
  </div>
97
  <div>
98
- <h1 className="text-2xl font-bold text-slate-900">Historique des Campagnes</h1>
99
  <p className="text-sm text-slate-500">
100
- {data ? `${data.total} messages envoyés au total` : 'Chargement…'}
101
  </p>
102
  </div>
103
  </div>
@@ -115,9 +117,9 @@ export default function CampaignHistoryPage() {
115
  >
116
  <div className={`flex items-center gap-2 ${cfg.color} mb-1`}>
117
  <Icon className="w-4 h-4" />
118
- <span className="text-xs font-semibold">{cfg.label}</span>
119
  </div>
120
- <p className="text-2xl font-bold text-slate-900">{stats[key].toLocaleString('fr-FR')}</p>
121
  </button>
122
  );
123
  })}
@@ -129,7 +131,7 @@ export default function CampaignHistoryPage() {
129
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
130
  <input
131
  type="text"
132
- placeholder="Rechercher par contact ou message…"
133
  value={search}
134
  onChange={e => setSearch(e.target.value)}
135
  className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-amber-400"
@@ -140,7 +142,7 @@ export default function CampaignHistoryPage() {
140
  onClick={() => setStatusFilter('ALL')}
141
  className="px-4 py-2 text-sm text-slate-500 border border-slate-200 rounded-xl hover:bg-slate-50 transition"
142
  >
143
- Effacer filtre
144
  </button>
145
  )}
146
  </div>
@@ -152,18 +154,18 @@ export default function CampaignHistoryPage() {
152
  ) : filteredRecords.length === 0 ? (
153
  <div className="text-center py-20 text-slate-400">
154
  <Send className="w-12 h-12 mx-auto mb-3 opacity-30" />
155
- <p className="font-medium">Aucune campagne trouvée</p>
156
- <p className="text-sm mt-1">Envoyez votre première campagne depuis la section Contacts.</p>
157
  </div>
158
  ) : (
159
  <div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
160
  <table className="w-full text-sm">
161
  <thead>
162
  <tr className="border-b border-slate-100">
163
- <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Contact</th>
164
- <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Message</th>
165
- <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Statut</th>
166
- <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Envoyé</th>
167
  </tr>
168
  </thead>
169
  <tbody className="divide-y divide-slate-50">
@@ -185,13 +187,13 @@ export default function CampaignHistoryPage() {
185
  <td className="px-5 py-3">
186
  <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${cfg.bg} ${cfg.color}`}>
187
  <Icon className="w-3 h-3" />
188
- {cfg.label}
189
  </span>
190
  </td>
191
  <td className="px-5 py-3 text-slate-400 whitespace-nowrap">
192
  <div className="flex items-center gap-1">
193
  <Clock className="w-3 h-3" />
194
- {new Date(record.sentAt).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })}
195
  </div>
196
  </td>
197
  </tr>
 
1
  import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { Send, Search, ChevronLeft, ChevronRight, Loader2, Megaphone, CheckCheck, Eye, AlertCircle, Clock } from 'lucide-react';
4
  import { api } from '../lib/api';
5
  import { useAuth } from '../lib/auth';
 
32
  const PAGE_SIZE = 30;
33
 
34
  const STATUS_CONFIG = {
35
+ SENT: { labelKey: 'crm.campaigns.status_sent', icon: Send, color: 'text-blue-500', bg: 'bg-blue-50' },
36
+ DELIVERED: { labelKey: 'crm.campaigns.status_delivered', icon: CheckCheck, color: 'text-green-500', bg: 'bg-green-50' },
37
+ READ: { labelKey: 'crm.campaigns.status_read', icon: Eye, color: 'text-violet-500',bg: 'bg-violet-50'},
38
+ FAILED: { labelKey: 'crm.campaigns.status_failed', icon: AlertCircle, color: 'text-red-500', bg: 'bg-red-50' },
39
  } as const;
40
 
41
  type StatusFilter = 'ALL' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED';
42
 
43
  export default function CampaignHistoryPage() {
44
+ const { t } = useTranslation();
45
  const { token } = useAuth();
46
  const { selectedOrgId } = useTenant();
47
  const [data, setData] = useState<CampaignResponse | null>(null);
 
97
  <Megaphone className="w-5 h-5 text-amber-600" />
98
  </div>
99
  <div>
100
+ <h1 className="text-2xl font-bold text-slate-900">{t('campaigns.title')}</h1>
101
  <p className="text-sm text-slate-500">
102
+ {data ? `${data.total} ${t('campaigns.total')}` : t('common.loading')}
103
  </p>
104
  </div>
105
  </div>
 
117
  >
118
  <div className={`flex items-center gap-2 ${cfg.color} mb-1`}>
119
  <Icon className="w-4 h-4" />
120
+ <span className="text-xs font-semibold">{t(cfg.labelKey)}</span>
121
  </div>
122
+ <p className="text-2xl font-bold text-slate-900">{stats[key].toLocaleString()}</p>
123
  </button>
124
  );
125
  })}
 
131
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
132
  <input
133
  type="text"
134
+ placeholder={t('common.search')}
135
  value={search}
136
  onChange={e => setSearch(e.target.value)}
137
  className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-amber-400"
 
142
  onClick={() => setStatusFilter('ALL')}
143
  className="px-4 py-2 text-sm text-slate-500 border border-slate-200 rounded-xl hover:bg-slate-50 transition"
144
  >
145
+ {t('campaigns.clear_filter')}
146
  </button>
147
  )}
148
  </div>
 
154
  ) : filteredRecords.length === 0 ? (
155
  <div className="text-center py-20 text-slate-400">
156
  <Send className="w-12 h-12 mx-auto mb-3 opacity-30" />
157
+ <p className="font-medium">{t('campaigns.no_records')}</p>
158
+ <p className="text-sm mt-1">{t('campaigns.first_hint')}</p>
159
  </div>
160
  ) : (
161
  <div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
162
  <table className="w-full text-sm">
163
  <thead>
164
  <tr className="border-b border-slate-100">
165
+ <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">{t('campaigns.columns.contact')}</th>
166
+ <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">{t('campaigns.columns.message')}</th>
167
+ <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">{t('campaigns.columns.status')}</th>
168
+ <th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">{t('campaigns.columns.sent')}</th>
169
  </tr>
170
  </thead>
171
  <tbody className="divide-y divide-slate-50">
 
187
  <td className="px-5 py-3">
188
  <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${cfg.bg} ${cfg.color}`}>
189
  <Icon className="w-3 h-3" />
190
+ {t(cfg.labelKey)}
191
  </span>
192
  </td>
193
  <td className="px-5 py-3 text-slate-400 whitespace-nowrap">
194
  <div className="flex items-center gap-1">
195
  <Clock className="w-3 h-3" />
196
+ {new Date(record.sentAt).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })}
197
  </div>
198
  </td>
199
  </tr>
apps/admin/src/pages/ContactsPage.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useState, useEffect } from 'react';
 
2
  import { Users, Upload, Search, Download, Trash2, Filter, Loader2, FileSpreadsheet, CheckCircle2, Sparkles, BrainCircuit, Send, Copy, RefreshCw } from 'lucide-react';
3
  import { api } from '../lib/api';
4
  import { useAuth } from '../lib/auth';
@@ -13,6 +14,7 @@ interface Contact {
13
  }
14
 
15
  export default function ContactsPage() {
 
16
  const { token } = useAuth();
17
  const { selectedOrgId } = useTenant();
18
  const [contacts, setContacts] = useState<Contact[]>([]);
@@ -266,9 +268,9 @@ export default function ContactsPage() {
266
  <div>
267
  <h1 className="text-4xl font-black text-slate-900 tracking-tight flex items-center gap-3">
268
  <Users className="w-10 h-10 text-blue-600" />
269
- Contacts CRM
270
  </h1>
271
- <p className="text-slate-500 mt-2 font-medium">Gérez votre base de données clients et importez vos fichiers Excel.</p>
272
  </div>
273
 
274
  <div className="flex items-center gap-3">
@@ -326,7 +328,7 @@ export default function ContactsPage() {
326
  <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
327
  <input
328
  type="text"
329
- placeholder="Rechercher un nom ou un numéro..."
330
  value={searchQuery}
331
  onChange={e => setSearchQuery(e.target.value)}
332
  className="w-full bg-slate-50 border-none rounded-2xl pl-12 pr-4 py-4 focus:ring-4 focus:ring-blue-50 transition font-medium"
@@ -374,8 +376,8 @@ export default function ContactsPage() {
374
  <div className="w-20 h-20 bg-slate-50 rounded-[2rem] flex items-center justify-center mx-auto mb-6">
375
  <Users className="w-10 h-10 text-slate-200" />
376
  </div>
377
- <h3 className="text-xl font-bold text-slate-900 mb-2">Aucun contact trouvé</h3>
378
- <p className="text-slate-500 max-w-sm mx-auto">Importez votre premier fichier Excel pour commencer à gérer vos clients.</p>
379
  </td>
380
  </tr>
381
  ) : filteredContacts.map(contact => (
 
1
  import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { Users, Upload, Search, Download, Trash2, Filter, Loader2, FileSpreadsheet, CheckCircle2, Sparkles, BrainCircuit, Send, Copy, RefreshCw } from 'lucide-react';
4
  import { api } from '../lib/api';
5
  import { useAuth } from '../lib/auth';
 
14
  }
15
 
16
  export default function ContactsPage() {
17
+ const { t } = useTranslation();
18
  const { token } = useAuth();
19
  const { selectedOrgId } = useTenant();
20
  const [contacts, setContacts] = useState<Contact[]>([]);
 
268
  <div>
269
  <h1 className="text-4xl font-black text-slate-900 tracking-tight flex items-center gap-3">
270
  <Users className="w-10 h-10 text-blue-600" />
271
+ {t('contacts.title')}
272
  </h1>
273
+ <p className="text-slate-500 mt-2 font-medium">{t('contacts.subtitle')}</p>
274
  </div>
275
 
276
  <div className="flex items-center gap-3">
 
328
  <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
329
  <input
330
  type="text"
331
+ placeholder={t('contacts.search_placeholder')}
332
  value={searchQuery}
333
  onChange={e => setSearchQuery(e.target.value)}
334
  className="w-full bg-slate-50 border-none rounded-2xl pl-12 pr-4 py-4 focus:ring-4 focus:ring-blue-50 transition font-medium"
 
376
  <div className="w-20 h-20 bg-slate-50 rounded-[2rem] flex items-center justify-center mx-auto mb-6">
377
  <Users className="w-10 h-10 text-slate-200" />
378
  </div>
379
+ <h3 className="text-xl font-bold text-slate-900 mb-2">{t('contacts.no_contacts')}</h3>
380
+ <p className="text-slate-500 max-w-sm mx-auto">{t('contacts.subtitle')}</p>
381
  </td>
382
  </tr>
383
  ) : filteredContacts.map(contact => (
apps/admin/src/pages/KnowledgeBasePage.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useState, useEffect } from 'react';
 
2
  import { Database, Trash2, RefreshCw, Search, ChevronLeft, ChevronRight, Loader2, FileText } from 'lucide-react';
3
  import { api } from '../lib/api';
4
  import { useAuth } from '../lib/auth';
@@ -21,6 +22,7 @@ interface KbResponse {
21
  const PAGE_SIZE = 20;
22
 
23
  export default function KnowledgeBasePage() {
 
24
  const { token } = useAuth();
25
  const { selectedOrgId } = useTenant();
26
  const [data, setData] = useState<KbResponse | null>(null);
@@ -53,7 +55,7 @@ export default function KnowledgeBasePage() {
53
 
54
  const handleDelete = async (id: string) => {
55
  if (!token || !selectedOrgId) return;
56
- if (!confirm('Supprimer ce chunk de la base de connaissances ?')) return;
57
  setDeletingId(id);
58
  try {
59
  await api.delete(`/v1/organizations/${selectedOrgId}/kb/${id}`, token);
@@ -96,9 +98,9 @@ export default function KnowledgeBasePage() {
96
  <Database className="w-5 h-5 text-violet-600" />
97
  </div>
98
  <div>
99
- <h1 className="text-2xl font-bold text-slate-900">Base de Connaissances</h1>
100
  <p className="text-sm text-slate-500">
101
- {data ? `${data.total} chunks indexés` : 'Chargement…'}
102
  </p>
103
  </div>
104
  </div>
@@ -108,7 +110,7 @@ export default function KnowledgeBasePage() {
108
  className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-xl text-sm font-medium hover:bg-violet-700 transition disabled:opacity-50"
109
  >
110
  <RefreshCw className={`w-4 h-4 ${reindexing ? 'animate-spin' : ''}`} />
111
- {reindexing ? 'Indexation…' : 'Re-indexer'}
112
  </button>
113
  </div>
114
 
@@ -116,7 +118,7 @@ export default function KnowledgeBasePage() {
116
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
117
  <input
118
  type="text"
119
- placeholder="Rechercher dans les chunks…"
120
  value={search}
121
  onChange={e => setSearch(e.target.value)}
122
  className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-violet-400"
@@ -130,8 +132,8 @@ export default function KnowledgeBasePage() {
130
  ) : filteredEntries.length === 0 ? (
131
  <div className="text-center py-20 text-slate-400">
132
  <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
133
- <p className="font-medium">Aucun chunk trouvé</p>
134
- <p className="text-sm mt-1">Importez un document dans l'onglet Agent IA pour commencer.</p>
135
  </div>
136
  ) : (
137
  <div className="space-y-3">
@@ -152,7 +154,7 @@ export default function KnowledgeBasePage() {
152
  </span>
153
  )}
154
  <span className="text-xs text-slate-300 ml-auto">
155
- {new Date(entry.createdAt).toLocaleDateString('fr-FR')}
156
  </span>
157
  </div>
158
  <p className="text-sm text-slate-700 leading-relaxed line-clamp-4">
 
1
  import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { Database, Trash2, RefreshCw, Search, ChevronLeft, ChevronRight, Loader2, FileText } from 'lucide-react';
4
  import { api } from '../lib/api';
5
  import { useAuth } from '../lib/auth';
 
22
  const PAGE_SIZE = 20;
23
 
24
  export default function KnowledgeBasePage() {
25
+ const { t } = useTranslation();
26
  const { token } = useAuth();
27
  const { selectedOrgId } = useTenant();
28
  const [data, setData] = useState<KbResponse | null>(null);
 
55
 
56
  const handleDelete = async (id: string) => {
57
  if (!token || !selectedOrgId) return;
58
+ if (!confirm(t('knowledge.confirm_delete'))) return;
59
  setDeletingId(id);
60
  try {
61
  await api.delete(`/v1/organizations/${selectedOrgId}/kb/${id}`, token);
 
98
  <Database className="w-5 h-5 text-violet-600" />
99
  </div>
100
  <div>
101
+ <h1 className="text-2xl font-bold text-slate-900">{t('knowledge.title')}</h1>
102
  <p className="text-sm text-slate-500">
103
+ {data ? `${data.total} ${t('knowledge.chunks')}` : t('common.loading')}
104
  </p>
105
  </div>
106
  </div>
 
110
  className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-xl text-sm font-medium hover:bg-violet-700 transition disabled:opacity-50"
111
  >
112
  <RefreshCw className={`w-4 h-4 ${reindexing ? 'animate-spin' : ''}`} />
113
+ {reindexing ? t('knowledge.reindexing') : t('knowledge.reindex')}
114
  </button>
115
  </div>
116
 
 
118
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
119
  <input
120
  type="text"
121
+ placeholder={t('knowledge.search_placeholder')}
122
  value={search}
123
  onChange={e => setSearch(e.target.value)}
124
  className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-violet-400"
 
132
  ) : filteredEntries.length === 0 ? (
133
  <div className="text-center py-20 text-slate-400">
134
  <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
135
+ <p className="font-medium">{t('knowledge.no_documents')}</p>
136
+ <p className="text-sm mt-1">{t('knowledge.import_hint')}</p>
137
  </div>
138
  ) : (
139
  <div className="space-y-3">
 
154
  </span>
155
  )}
156
  <span className="text-xs text-slate-300 ml-auto">
157
+ {new Date(entry.createdAt).toLocaleDateString()}
158
  </span>
159
  </div>
160
  <p className="text-sm text-slate-700 leading-relaxed line-clamp-4">
apps/admin/src/pages/LiveFeed.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useState, useEffect, useRef } from 'react';
 
2
  import { Square, Mic, Send, AlertCircle, CheckCircle2, Loader2, User, Briefcase } from 'lucide-react';
3
  import { useAuth } from '../lib/auth';
4
  import { useTenant } from '../lib/tenant';
@@ -25,6 +26,7 @@ interface PendingReview {
25
  }
26
 
27
  export default function LiveFeed() {
 
28
  const [reviews, setReviews] = useState<PendingReview[]>([]);
29
  const [loading, setLoading] = useState(true);
30
  const [error, setError] = useState<string | null>(null);
@@ -77,8 +79,8 @@ export default function LiveFeed() {
77
  <div className="p-8 max-w-5xl mx-auto">
78
  <div className="flex items-center justify-between mb-8">
79
  <div>
80
- <h1 className="text-3xl font-bold tracking-tight text-slate-900">Live Feed Modération</h1>
81
- <p className="text-slate-500 mt-2">Étudiants Wolof en attente d'intervention manuelle.</p>
82
  </div>
83
  <div className="bg-emerald-50 text-emerald-700 px-4 py-2 rounded-full font-medium flex items-center gap-2">
84
  <AlertCircle className="w-5 h-5" />
@@ -104,7 +106,7 @@ export default function LiveFeed() {
104
  {reviews.length === 0 ? (
105
  <div className="text-center py-24 bg-slate-50 rounded-2xl border border-dashed border-slate-200">
106
  <CheckCircle2 className="w-12 h-12 text-slate-300 mx-auto mb-4" />
107
- <p className="text-slate-500 text-lg">Aucune intervention requise. Excellent travail !</p>
108
  </div>
109
  ) : (
110
  reviews.map(review => (
 
1
  import { useState, useEffect, useRef } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { Square, Mic, Send, AlertCircle, CheckCircle2, Loader2, User, Briefcase } from 'lucide-react';
4
  import { useAuth } from '../lib/auth';
5
  import { useTenant } from '../lib/tenant';
 
26
  }
27
 
28
  export default function LiveFeed() {
29
+ const { t } = useTranslation();
30
  const [reviews, setReviews] = useState<PendingReview[]>([]);
31
  const [loading, setLoading] = useState(true);
32
  const [error, setError] = useState<string | null>(null);
 
79
  <div className="p-8 max-w-5xl mx-auto">
80
  <div className="flex items-center justify-between mb-8">
81
  <div>
82
+ <h1 className="text-3xl font-bold tracking-tight text-slate-900">{t('livefeed.title')}</h1>
83
+ <p className="text-slate-500 mt-2">{t('livefeed.subtitle')}</p>
84
  </div>
85
  <div className="bg-emerald-50 text-emerald-700 px-4 py-2 rounded-full font-medium flex items-center gap-2">
86
  <AlertCircle className="w-5 h-5" />
 
106
  {reviews.length === 0 ? (
107
  <div className="text-center py-24 bg-slate-50 rounded-2xl border border-dashed border-slate-200">
108
  <CheckCircle2 className="w-12 h-12 text-slate-300 mx-auto mb-4" />
109
+ <p className="text-slate-500 text-lg">{t('livefeed.no_messages')}</p>
110
  </div>
111
  ) : (
112
  reviews.map(review => (
apps/admin/src/pages/TrackDaysPage.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import React, { useEffect, useState } from 'react';
 
2
  import { useParams, useNavigate } from 'react-router-dom';
3
  import { Plus, Edit2, Trash2, ArrowLeft, X, Save } from 'lucide-react';
4
  import { useAuth } from '../lib/auth';
@@ -6,7 +7,8 @@ import { useTenant } from '../lib/tenant';
6
  import { API_URL, ah } from '../lib/api';
7
 
8
  export default function TrackDaysPage() {
9
- const { token } = useAuth();
 
10
  const { selectedOrgId } = useTenant();
11
  const { trackId } = useParams<{ trackId: string }>();
12
  const navigate = useNavigate();
@@ -41,8 +43,8 @@ export default function TrackDaysPage() {
41
  setSaving(false);
42
  };
43
 
44
- const del = async (dayId: string) => {
45
- if (!confirm('Supprimer ce jour?')) return;
46
  await fetch(`${API_URL}/v1/admin/tracks/${trackId}/days/${dayId}`, { method: 'DELETE', headers: ah(token!, selectedOrgId!) });
47
  load();
48
  };
@@ -54,9 +56,9 @@ export default function TrackDaysPage() {
54
  <div className="flex items-center gap-3 mb-6">
55
  <button onClick={() => navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4" /></button>
56
  <div><h1 className="text-2xl font-bold text-slate-800">{track?.title}</h1>
57
- <p className="text-sm text-slate-500">{days.length} jours configurés</p></div>
58
  <button onClick={() => setEditing(emptyDay)} className="ml-auto flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
59
- <Plus className="w-4 h-4" /> Ajouter un jour
60
  </button>
61
  </div>
62
  {editing && (
@@ -88,9 +90,9 @@ export default function TrackDaysPage() {
88
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Prompt exercice</label>
89
  <textarea className={inp} rows={2} value={editing.exercisePrompt || ''} onChange={e => setEditing((d: any) => ({ ...d, exercisePrompt: e.target.value }))} placeholder="Question posée à l'étudiant..." /></div>
90
  <div className="flex gap-3">
91
- <button type="button" onClick={() => setEditing(null)} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
92
  <button type="submit" disabled={saving} className="flex-1 bg-slate-900 text-white py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-700 disabled:opacity-50">
93
- <Save className="w-4 h-4" />{saving ? 'Enregistrement...' : 'Enregistrer'}
94
  </button>
95
  </div>
96
  </form>
 
1
  import React, { useEffect, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { useParams, useNavigate } from 'react-router-dom';
4
  import { Plus, Edit2, Trash2, ArrowLeft, X, Save } from 'lucide-react';
5
  import { useAuth } from '../lib/auth';
 
7
  import { API_URL, ah } from '../lib/api';
8
 
9
  export default function TrackDaysPage() {
10
+ const { t } = useTranslation();
11
+ const { token } = useAuth();
12
  const { selectedOrgId } = useTenant();
13
  const { trackId } = useParams<{ trackId: string }>();
14
  const navigate = useNavigate();
 
43
  setSaving(false);
44
  };
45
 
46
+ const del = async (dayId: string) => {
47
+ if (!confirm(t('tracks.confirm_delete'))) return;
48
  await fetch(`${API_URL}/v1/admin/tracks/${trackId}/days/${dayId}`, { method: 'DELETE', headers: ah(token!, selectedOrgId!) });
49
  load();
50
  };
 
56
  <div className="flex items-center gap-3 mb-6">
57
  <button onClick={() => navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4" /></button>
58
  <div><h1 className="text-2xl font-bold text-slate-800">{track?.title}</h1>
59
+ <p className="text-sm text-slate-500">{days.length} {t('tracks.days')}</p></div>
60
  <button onClick={() => setEditing(emptyDay)} className="ml-auto flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
61
+ <Plus className="w-4 h-4" /> {t('tracks.new')}
62
  </button>
63
  </div>
64
  {editing && (
 
90
  <div><label className="text-xs font-medium text-slate-600 mb-1 block">Prompt exercice</label>
91
  <textarea className={inp} rows={2} value={editing.exercisePrompt || ''} onChange={e => setEditing((d: any) => ({ ...d, exercisePrompt: e.target.value }))} placeholder="Question posée à l'étudiant..." /></div>
92
  <div className="flex gap-3">
93
+ <button type="button" onClick={() => setEditing(null)} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">{t('common.cancel')}</button>
94
  <button type="submit" disabled={saving} className="flex-1 bg-slate-900 text-white py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-700 disabled:opacity-50">
95
+ <Save className="w-4 h-4" />{saving ? t('common.loading') : t('common.save')}
96
  </button>
97
  </div>
98
  </form>
apps/admin/src/pages/TrackFormPage.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import React, { useEffect, useState } from 'react';
 
2
  import { useParams, useNavigate } from 'react-router-dom';
3
  import { ArrowLeft, Save } from 'lucide-react';
4
  import { useAuth } from '../lib/auth';
@@ -6,7 +7,8 @@ import { useTenant } from '../lib/tenant';
6
  import { API_URL, ah } from '../lib/api';
7
 
8
  export default function TrackFormPage() {
9
- const { token } = useAuth();
 
10
  const { selectedOrgId } = useTenant();
11
  const { id } = useParams<{ id: string }>();
12
  const navigate = useNavigate();
@@ -51,7 +53,7 @@ export default function TrackFormPage() {
51
  <div className="p-8 max-w-xl">
52
  <div className="flex items-center gap-3 mb-6">
53
  <button onClick={() => navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4" /></button>
54
- <h1 className="text-2xl font-bold text-slate-800">{isNew ? 'Nouveau parcours' : 'Modifier le parcours'}</h1>
55
  </div>
56
  <form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-slate-100 p-6 space-y-4 shadow-sm">
57
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Titre *</label>
@@ -75,14 +77,14 @@ export default function TrackFormPage() {
75
  <input type="number" className={inp} value={form.priceAmount} onChange={e => setForm(f => ({ ...f, priceAmount: parseInt(e.target.value) }))} />
76
  </div>}
77
  <div className="flex gap-3 pt-2">
78
- <button type="button" onClick={() => navigate('/content')} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
79
- <button
80
- type="submit"
81
- disabled={saving || (isNew && !selectedOrgId)}
82
  className="flex-[2] bg-slate-900 text-white py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-700 disabled:opacity-50"
83
  >
84
  <Save className="w-4 h-4" />
85
- {saving ? 'Enregistrement...' : (!selectedOrgId && isNew ? 'Sélect. Organisation' : 'Enregistrer')}
86
  </button>
87
  </div>
88
  </form>
 
1
  import React, { useEffect, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { useParams, useNavigate } from 'react-router-dom';
4
  import { ArrowLeft, Save } from 'lucide-react';
5
  import { useAuth } from '../lib/auth';
 
7
  import { API_URL, ah } from '../lib/api';
8
 
9
  export default function TrackFormPage() {
10
+ const { t } = useTranslation();
11
+ const { token } = useAuth();
12
  const { selectedOrgId } = useTenant();
13
  const { id } = useParams<{ id: string }>();
14
  const navigate = useNavigate();
 
53
  <div className="p-8 max-w-xl">
54
  <div className="flex items-center gap-3 mb-6">
55
  <button onClick={() => navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4" /></button>
56
+ <h1 className="text-2xl font-bold text-slate-800">{isNew ? t('tracks.new') : t('common.edit')}</h1>
57
  </div>
58
  <form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-slate-100 p-6 space-y-4 shadow-sm">
59
  <div><label className="text-sm font-medium text-slate-700 mb-1 block">Titre *</label>
 
77
  <input type="number" className={inp} value={form.priceAmount} onChange={e => setForm(f => ({ ...f, priceAmount: parseInt(e.target.value) }))} />
78
  </div>}
79
  <div className="flex gap-3 pt-2">
80
+ <button type="button" onClick={() => navigate('/content')} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">{t('common.cancel')}</button>
81
+ <button
82
+ type="submit"
83
+ disabled={saving || (isNew && !selectedOrgId)}
84
  className="flex-[2] bg-slate-900 text-white py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-700 disabled:opacity-50"
85
  >
86
  <Save className="w-4 h-4" />
87
+ {saving ? t('common.loading') : (!selectedOrgId && isNew ? t('common.select_org') : t('common.save'))}
88
  </button>
89
  </div>
90
  </form>
apps/admin/src/pages/TrackListPage.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useEffect, useState } from 'react';
 
2
  import { useNavigate } from 'react-router-dom';
3
  import { BookOpen, Plus, Edit2, Trash2, ChevronRight, Building2 } from 'lucide-react';
4
  import { useAuth } from '../lib/auth';
@@ -6,7 +7,8 @@ import { useTenant } from '../lib/tenant';
6
  import { API_URL, ah } from '../lib/api';
7
 
8
  export default function TrackListPage() {
9
- const { token } = useAuth();
 
10
  const { selectedOrgId } = useTenant();
11
  const navigate = useNavigate();
12
  const [tracks, setTracks] = useState<any[]>([]);
@@ -25,8 +27,8 @@ export default function TrackListPage() {
25
  }
26
  }, [selectedOrgId, token]);
27
 
28
- const del = async (id: string) => {
29
- if (!confirm('Supprimer ce parcours ?')) return;
30
  await fetch(`${API_URL}/v1/admin/tracks/${id}`, { method: 'DELETE', headers: ah(token!, selectedOrgId!) });
31
  load();
32
  };
@@ -35,8 +37,7 @@ export default function TrackListPage() {
35
  return (
36
  <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
37
  <Building2 className="w-12 h-12 mb-4 opacity-20" />
38
- <h3 className="text-lg font-bold text-slate-900">Aucune organisation sélectionnée</h3>
39
- <p className="max-w-xs text-center mt-2">Veuillez sélectionner une organisation dans le menu en haut à gauche pour voir ses parcours.</p>
40
  </div>
41
  );
42
  }
@@ -45,7 +46,7 @@ export default function TrackListPage() {
45
  return (
46
  <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
47
  <div className="w-8 h-8 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin mb-4"></div>
48
- <p>Chargement des parcours...</p>
49
  </div>
50
  );
51
  }
@@ -53,33 +54,33 @@ export default function TrackListPage() {
53
  return (
54
  <div className="p-8">
55
  <div className="flex justify-between items-center mb-6">
56
- <h1 className="text-3xl font-bold text-slate-800">Parcours</h1>
57
  <button onClick={() => navigate('/content/new')} className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
58
- <Plus className="w-4 h-4" /> Nouveau parcours
59
  </button>
60
  </div>
61
  <div className="grid gap-4">
62
- {tracks.map((t: any) => (
63
- <div key={t.id} className="bg-white rounded-xl border border-slate-100 p-5 flex items-center justify-between shadow-sm hover:shadow-md transition">
64
  <div className="flex items-center gap-4">
65
  <div className="bg-purple-100 p-3 rounded-xl"><BookOpen className="w-5 h-5 text-purple-600" /></div>
66
  <div>
67
  <div className="flex items-center gap-2">
68
- <h3 className="font-bold text-slate-800">{t.title}</h3>
69
- {t.isPremium && <span className="bg-amber-100 text-amber-700 text-xs px-2 py-0.5 rounded-full font-medium">Premium</span>}
70
- <span className="bg-slate-100 text-slate-600 text-xs px-2 py-0.5 rounded-full">{t.language}</span>
71
  </div>
72
- <p className="text-sm text-slate-500 mt-0.5">{t._count?.days || 0} jours · {t._count?.enrollments || 0} inscrits · {t.duration}j</p>
73
  </div>
74
  </div>
75
  <div className="flex items-center gap-2">
76
- <button onClick={() => navigate(`/content/${t.id}`)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit2 className="w-4 h-4" /></button>
77
- <button onClick={() => navigate(`/content/${t.id}/days`)} className="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900 px-3 py-2 rounded-lg hover:bg-slate-50">Jours <ChevronRight className="w-4 h-4" /></button>
78
- <button onClick={() => del(t.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button>
79
  </div>
80
  </div>
81
  ))}
82
- {!tracks.length && <div className="text-center py-16 text-slate-400"><BookOpen className="w-12 h-12 mx-auto mb-3 opacity-30" /><p>Aucun parcours. Créez-en un !</p></div>}
83
  </div>
84
  </div>
85
  );
 
1
  import { useEffect, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { useNavigate } from 'react-router-dom';
4
  import { BookOpen, Plus, Edit2, Trash2, ChevronRight, Building2 } from 'lucide-react';
5
  import { useAuth } from '../lib/auth';
 
7
  import { API_URL, ah } from '../lib/api';
8
 
9
  export default function TrackListPage() {
10
+ const { t } = useTranslation();
11
+ const { token } = useAuth();
12
  const { selectedOrgId } = useTenant();
13
  const navigate = useNavigate();
14
  const [tracks, setTracks] = useState<any[]>([]);
 
27
  }
28
  }, [selectedOrgId, token]);
29
 
30
+ const del = async (id: string) => {
31
+ if (!confirm(t('tracks.confirm_delete'))) return;
32
  await fetch(`${API_URL}/v1/admin/tracks/${id}`, { method: 'DELETE', headers: ah(token!, selectedOrgId!) });
33
  load();
34
  };
 
37
  return (
38
  <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
39
  <Building2 className="w-12 h-12 mb-4 opacity-20" />
40
+ <p className="max-w-xs text-center mt-2">{t('common.select_org')}</p>
 
41
  </div>
42
  );
43
  }
 
46
  return (
47
  <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
48
  <div className="w-8 h-8 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin mb-4"></div>
49
+ <p>{t('common.loading')}</p>
50
  </div>
51
  );
52
  }
 
54
  return (
55
  <div className="p-8">
56
  <div className="flex justify-between items-center mb-6">
57
+ <h1 className="text-3xl font-bold text-slate-800">{t('tracks.title')}</h1>
58
  <button onClick={() => navigate('/content/new')} className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
59
+ <Plus className="w-4 h-4" /> {t('tracks.new')}
60
  </button>
61
  </div>
62
  <div className="grid gap-4">
63
+ {tracks.map((track: any) => (
64
+ <div key={track.id} className="bg-white rounded-xl border border-slate-100 p-5 flex items-center justify-between shadow-sm hover:shadow-md transition">
65
  <div className="flex items-center gap-4">
66
  <div className="bg-purple-100 p-3 rounded-xl"><BookOpen className="w-5 h-5 text-purple-600" /></div>
67
  <div>
68
  <div className="flex items-center gap-2">
69
+ <h3 className="font-bold text-slate-800">{track.title}</h3>
70
+ {track.isPremium && <span className="bg-amber-100 text-amber-700 text-xs px-2 py-0.5 rounded-full font-medium">Premium</span>}
71
+ <span className="bg-slate-100 text-slate-600 text-xs px-2 py-0.5 rounded-full">{track.language}</span>
72
  </div>
73
+ <p className="text-sm text-slate-500 mt-0.5">{track._count?.days || 0} {t('tracks.days')} · {track._count?.enrollments || 0} {t('tracks.enrolled')} · {track.duration}j</p>
74
  </div>
75
  </div>
76
  <div className="flex items-center gap-2">
77
+ <button onClick={() => navigate(`/content/${track.id}`)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit2 className="w-4 h-4" /></button>
78
+ <button onClick={() => navigate(`/content/${track.id}/days`)} className="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900 px-3 py-2 rounded-lg hover:bg-slate-50">{t('tracks.days_label')} <ChevronRight className="w-4 h-4" /></button>
79
+ <button onClick={() => del(track.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button>
80
  </div>
81
  </div>
82
  ))}
83
+ {!tracks.length && <div className="text-center py-16 text-slate-400"><BookOpen className="w-12 h-12 mx-auto mb-3 opacity-30" /><p>{t('tracks.no_tracks')}</p></div>}
84
  </div>
85
  </div>
86
  );
apps/admin/src/pages/TrainingLab.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { useState, useEffect } from 'react';
 
2
  import { useAuth } from '../lib/auth';
3
  import { useTenant } from '../lib/tenant';
4
  import { API_URL, ah } from '../lib/api';
@@ -16,6 +17,7 @@ interface TrainingData {
16
  }
17
 
18
  export default function TrainingLab() {
 
19
  const { token, logout } = useAuth();
20
  const { selectedOrgId } = useTenant();
21
  const [mode, setMode] = useState<'db' | 'upload' | 'suggestions'>('db');
@@ -149,7 +151,7 @@ export default function TrainingLab() {
149
  <div className="p-8 max-w-5xl mx-auto">
150
  <div className="flex items-center gap-3 mb-8">
151
  <Activity className="w-8 h-8 text-purple-600" />
152
- <h1 className="text-3xl font-bold text-slate-800">Training Lab (WER)</h1>
153
  </div>
154
 
155
  <div className="bg-white p-2 rounded-xl border border-slate-200 inline-flex mb-8 shadow-sm">
 
1
  import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
  import { useAuth } from '../lib/auth';
4
  import { useTenant } from '../lib/tenant';
5
  import { API_URL, ah } from '../lib/api';
 
17
  }
18
 
19
  export default function TrainingLab() {
20
+ const { t } = useTranslation();
21
  const { token, logout } = useAuth();
22
  const { selectedOrgId } = useTenant();
23
  const [mode, setMode] = useState<'db' | 'upload' | 'suggestions'>('db');
 
151
  <div className="p-8 max-w-5xl mx-auto">
152
  <div className="flex items-center gap-3 mb-8">
153
  <Activity className="w-8 h-8 text-purple-600" />
154
+ <h1 className="text-3xl font-bold text-slate-800">{t('training.title')}</h1>
155
  </div>
156
 
157
  <div className="bg-white p-2 rounded-xl border border-slate-200 inline-flex mb-8 shadow-sm">