CognxSafeTrack Claude Sonnet 4.6 commited on
Commit ·
6b2ad5a
1
Parent(s): ab43d7b
feat: wire i18n to all remaining admin pages
Browse filesAIAgentSetup, 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 +26 -24
- apps/admin/src/pages/AnalyticsPage.tsx +21 -19
- apps/admin/src/pages/CampaignHistoryPage.tsx +20 -18
- apps/admin/src/pages/ContactsPage.tsx +7 -5
- apps/admin/src/pages/KnowledgeBasePage.tsx +10 -8
- apps/admin/src/pages/LiveFeed.tsx +5 -3
- apps/admin/src/pages/TrackDaysPage.tsx +9 -7
- apps/admin/src/pages/TrackFormPage.tsx +9 -7
- apps/admin/src/pages/TrackListPage.tsx +19 -18
- apps/admin/src/pages/TrainingLab.tsx +3 -1
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">
|
| 112 |
-
<p className="text-slate-500 mb-8">
|
| 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>
|
| 120 |
</h2>
|
| 121 |
<p className="text-sm text-slate-600 mb-6">
|
| 122 |
-
|
| 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' && '
|
| 148 |
-
{uploadStatus === 'UPLOADING' && '
|
| 149 |
-
{uploadStatus === 'SUCCESS' && '
|
| 150 |
-
{uploadStatus === 'ERROR' && (uploadError || '
|
| 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>
|
| 166 |
</h2>
|
| 167 |
<div className="space-y-4">
|
| 168 |
<div>
|
| 169 |
-
<label className="block text-sm font-medium text-slate-600 mb-1">
|
| 170 |
<input
|
| 171 |
type="text"
|
| 172 |
value={role}
|
| 173 |
onChange={e => setRole(e.target.value)}
|
| 174 |
-
placeholder=
|
| 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">
|
| 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' ? '
|
| 204 |
-
saveStatus === 'SAVED' ?
|
| 205 |
-
saveStatus === 'ERROR' ? '
|
| 206 |
-
'
|
| 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">
|
| 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">
|
| 232 |
{kbStats === null ? (
|
| 233 |
-
<p className="text-xs text-slate-400">
|
| 234 |
) : !kbStats.hasKnowledgeBase ? (
|
| 235 |
-
<p className="text-xs text-slate-400">
|
| 236 |
) : (
|
| 237 |
<div className="space-y-3">
|
| 238 |
<div className="flex justify-between text-sm">
|
| 239 |
-
<span className="text-slate-500">
|
| 240 |
-
<span className="text-emerald-600 font-bold">
|
| 241 |
</div>
|
| 242 |
<div className="flex justify-between text-sm">
|
| 243 |
-
<span className="text-slate-500">
|
| 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">
|
| 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 |
-
|
| 52 |
</div>
|
| 53 |
);
|
| 54 |
}
|
| 55 |
|
| 56 |
-
if (loading) return <div className="p-12 animate-pulse text-gray-400">
|
| 57 |
|
| 58 |
const messageData = [
|
| 59 |
-
{ name: '
|
| 60 |
-
{ name: '
|
| 61 |
];
|
| 62 |
|
| 63 |
const completionData = [
|
| 64 |
-
{ name: '
|
| 65 |
-
{ name: '
|
| 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">
|
| 73 |
-
<p className="text-slate-500">
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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 |
-
|
| 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">
|
| 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" />
|
| 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">
|
| 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" />
|
| 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">
|
| 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 |
-
|
| 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: {
|
| 35 |
-
DELIVERED: {
|
| 36 |
-
READ: {
|
| 37 |
-
FAILED: {
|
| 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">
|
| 99 |
<p className="text-sm text-slate-500">
|
| 100 |
-
{data ? `${data.total}
|
| 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.
|
| 119 |
</div>
|
| 120 |
-
<p className="text-2xl font-bold text-slate-900">{stats[key].toLocaleString(
|
| 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=
|
| 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 |
-
|
| 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">
|
| 156 |
-
<p className="text-sm mt-1">
|
| 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">
|
| 164 |
-
<th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
| 165 |
-
<th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
| 166 |
-
<th className="text-left px-5 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
| 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.
|
| 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(
|
| 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 |
-
|
| 270 |
</h1>
|
| 271 |
-
<p className="text-slate-500 mt-2 font-medium">
|
| 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=
|
| 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">
|
| 378 |
-
<p className="text-slate-500 max-w-sm mx-auto">
|
| 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('
|
| 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">
|
| 100 |
<p className="text-sm text-slate-500">
|
| 101 |
-
{data ? `${data.total} chunks
|
| 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 ? '
|
| 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=
|
| 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">
|
| 134 |
-
<p className="text-sm mt-1">
|
| 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(
|
| 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">
|
| 81 |
-
<p className="text-slate-500 mt-2">
|
| 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">
|
| 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 {
|
|
|
|
| 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('
|
| 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}
|
| 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" />
|
| 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">
|
| 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 ? '
|
| 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 {
|
|
|
|
| 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 ? '
|
| 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">
|
| 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 ? '
|
| 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 {
|
|
|
|
| 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('
|
| 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 |
-
<
|
| 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>
|
| 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">
|
| 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" />
|
| 59 |
</button>
|
| 60 |
</div>
|
| 61 |
<div className="grid gap-4">
|
| 62 |
-
{tracks.map((
|
| 63 |
-
<div key={
|
| 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">{
|
| 69 |
-
{
|
| 70 |
-
<span className="bg-slate-100 text-slate-600 text-xs px-2 py-0.5 rounded-full">{
|
| 71 |
</div>
|
| 72 |
-
<p className="text-sm text-slate-500 mt-0.5">{
|
| 73 |
</div>
|
| 74 |
</div>
|
| 75 |
<div className="flex items-center gap-2">
|
| 76 |
-
<button onClick={() => navigate(`/content/${
|
| 77 |
-
<button onClick={() => navigate(`/content/${
|
| 78 |
-
<button onClick={() => del(
|
| 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>
|
| 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">
|
| 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">
|