From f719d181c08da4840e21e448d7553f021597fa22 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 17:46:01 +0000 Subject: [PATCH] Add English language support with i18n implementation Implemented comprehensive internationalization (i18n) for both frontend and backend: Frontend: - Added react-i18next configuration with Spanish (default) and English - Created translation files (locales/es.json, locales/en.json) - Refactored core components to use i18n: LoginPage, DashboardHeader, DataUploader - Created LanguageSelector component with toggle between ES/EN - Updated API client to send Accept-Language header Backend: - Created i18n module with translation dictionary for error messages - Updated security.py to return localized authentication errors - Updated api/analysis.py to return localized validation errors - Implemented language detection from Accept-Language header Spanish remains the default language ensuring backward compatibility. Users can switch between languages using the language selector in the dashboard header. https://claude.ai/code/session_1N9VX --- backend/beyond_api/api/analysis.py | 20 ++- backend/beyond_api/i18n.py | 128 ++++++++++++++ backend/beyond_api/security.py | 16 +- frontend/components/DashboardHeader.tsx | 46 ++--- frontend/components/DataUploader.tsx | 38 +++-- frontend/components/LanguageSelector.tsx | 29 ++++ frontend/components/LoginPage.tsx | 18 +- frontend/i18n.ts | 21 +++ frontend/index.tsx | 1 + frontend/locales/en.json | 207 +++++++++++++++++++++++ frontend/locales/es.json | 207 +++++++++++++++++++++++ frontend/package-lock.json | 89 +++++++++- frontend/package.json | 2 + frontend/utils/AuthContext.tsx | 2 + frontend/utils/apiClient.ts | 2 + 15 files changed, 768 insertions(+), 58 deletions(-) create mode 100644 backend/beyond_api/i18n.py create mode 100644 frontend/components/LanguageSelector.tsx create mode 100644 frontend/i18n.ts create mode 100644 frontend/locales/en.json create mode 100644 frontend/locales/es.json diff --git a/backend/beyond_api/api/analysis.py b/backend/beyond_api/api/analysis.py index ccbc4df..67801a6 100644 --- a/backend/beyond_api/api/analysis.py +++ b/backend/beyond_api/api/analysis.py @@ -7,11 +7,12 @@ import math from uuid import uuid4 from typing import Optional, Any, Literal -from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Header from fastapi.responses import JSONResponse from beyond_api.security import get_current_user from beyond_api.services.analysis_service import run_analysis_collect_json +from beyond_api.i18n import t, get_language_from_header # Cache paths - same as in cache.py CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache")) @@ -52,6 +53,7 @@ async def analysis_endpoint( economy_json: Optional[str] = Form(default=None), analysis: Literal["basic", "premium"] = Form(default="premium"), current_user: str = Depends(get_current_user), + accept_language: Optional[str] = Header(None), ): """ Ejecuta el pipeline sobre un CSV subido (multipart/form-data) y devuelve @@ -62,12 +64,13 @@ async def analysis_endpoint( - "premium": usa la configuración completa por defecto (p.ej. beyond_metrics_config.json), sin romper lo existente. """ + lang = get_language_from_header(accept_language) # Validar `analysis` (por si llega algo raro) if analysis not in {"basic", "premium"}: raise HTTPException( status_code=400, - detail="analysis debe ser 'basic' o 'premium'.", + detail=t("analysis.invalid_type", lang), ) # 1) Parseo de economía (si viene) @@ -78,7 +81,7 @@ async def analysis_endpoint( except json.JSONDecodeError: raise HTTPException( status_code=400, - detail="economy_json no es un JSON válido.", + detail=t("analysis.invalid_economy_json", lang), ) # 2) Guardar el CSV subido en una carpeta de trabajo @@ -159,23 +162,26 @@ async def analysis_cached_endpoint( economy_json: Optional[str] = Form(default=None), analysis: Literal["basic", "premium"] = Form(default="premium"), current_user: str = Depends(get_current_user), + accept_language: Optional[str] = Header(None), ): """ Ejecuta el pipeline sobre el archivo CSV cacheado en el servidor. Útil para re-analizar sin tener que subir el archivo de nuevo. """ + lang = get_language_from_header(accept_language) + # Validar que existe el archivo cacheado if not CACHED_FILE.exists(): raise HTTPException( status_code=404, - detail="No hay archivo cacheado en el servidor. Sube un archivo primero.", + detail=t("analysis.no_cached_file", lang), ) # Validar `analysis` if analysis not in {"basic", "premium"}: raise HTTPException( status_code=400, - detail="analysis debe ser 'basic' o 'premium'.", + detail=t("analysis.invalid_type", lang), ) # Parseo de economía (si viene) @@ -186,7 +192,7 @@ async def analysis_cached_endpoint( except json.JSONDecodeError: raise HTTPException( status_code=400, - detail="economy_json no es un JSON válido.", + detail=t("analysis.invalid_economy_json", lang), ) # Extraer metadatos del CSV @@ -204,7 +210,7 @@ async def analysis_cached_endpoint( except Exception as e: raise HTTPException( status_code=500, - detail=f"Error ejecutando análisis: {str(e)}", + detail=t("analysis.execution_error", lang, error=str(e)), ) # Limpiar NaN/inf para que el JSON sea válido diff --git a/backend/beyond_api/i18n.py b/backend/beyond_api/i18n.py new file mode 100644 index 0000000..06799c0 --- /dev/null +++ b/backend/beyond_api/i18n.py @@ -0,0 +1,128 @@ +""" +Módulo de internacionalización para Beyond API. +Proporciona traducciones para mensajes de error, validaciones y clasificaciones. +""" + +from typing import Literal + +Language = Literal["es", "en"] + +# Diccionario de traducciones +MESSAGES = { + "es": { + # Errores de autenticación + "auth.credentials_required": "Credenciales requeridas", + "auth.incorrect_credentials": "Credenciales incorrectas", + + # Errores de análisis + "analysis.invalid_type": "analysis debe ser 'basic' o 'premium'.", + "analysis.invalid_economy_json": "economy_json no es un JSON válido.", + "analysis.no_cached_file": "No hay archivo cacheado en el servidor. Sube un archivo primero.", + "analysis.execution_error": "Error ejecutando análisis: {error}", + + # Errores de validación + "validation.field_not_numeric": "El campo '{field}' debe ser numérico (float). Valor recibido: {value}", + "validation.segments_not_dict": "customer_segments debe ser un diccionario {segment: level}", + "validation.segment_value_not_str": "El valor de customer_segments['{key}'] debe ser str. Valor recibido: {value}", + "validation.csv_not_found": "El CSV no existe: {path}", + "validation.not_csv_file": "La ruta no apunta a un fichero CSV: {path}", + "validation.missing_columns": "Faltan columnas obligatorias para {metric}: {missing}", + + # Clasificaciones Agentic Readiness + "agentic.ready_for_copilot": "Listo para Copilot", + "agentic.ready_for_copilot_desc": "Procesos con predictibilidad y simplicidad suficientes para asistencia IA (sugerencias en tiempo real, autocompletado).", + "agentic.optimize_first": "Optimizar primero", + "agentic.optimize_first_desc": "Estandarizar procesos y reducir variabilidad antes de implementar asistencia IA.", + "agentic.requires_human": "Requiere gestión humana", + "agentic.requires_human_desc": "Procesos complejos o variables que necesitan intervención humana antes de considerar automatización.", + }, + "en": { + # Authentication errors + "auth.credentials_required": "Credentials required", + "auth.incorrect_credentials": "Incorrect credentials", + + # Analysis errors + "analysis.invalid_type": "analysis must be 'basic' or 'premium'.", + "analysis.invalid_economy_json": "economy_json is not valid JSON.", + "analysis.no_cached_file": "No cached file on server. Upload a file first.", + "analysis.execution_error": "Error executing analysis: {error}", + + # Validation errors + "validation.field_not_numeric": "Field '{field}' must be numeric (float). Received value: {value}", + "validation.segments_not_dict": "customer_segments must be a dictionary {segment: level}", + "validation.segment_value_not_str": "Value of customer_segments['{key}'] must be str. Received value: {value}", + "validation.csv_not_found": "CSV does not exist: {path}", + "validation.not_csv_file": "Path does not point to a CSV file: {path}", + "validation.missing_columns": "Missing required columns for {metric}: {missing}", + + # Agentic Readiness classifications + "agentic.ready_for_copilot": "Ready for Copilot", + "agentic.ready_for_copilot_desc": "Processes with sufficient predictability and simplicity for AI assistance (real-time suggestions, autocomplete).", + "agentic.optimize_first": "Optimize first", + "agentic.optimize_first_desc": "Standardize processes and reduce variability before implementing AI assistance.", + "agentic.requires_human": "Requires human management", + "agentic.requires_human_desc": "Complex or variable processes that need human intervention before considering automation.", + } +} + + +def t(key: str, lang: Language = "es", **kwargs) -> str: + """ + Traduce un mensaje al idioma especificado. + + Args: + key: Clave del mensaje (ej: 'auth.credentials_required') + lang: Idioma ('es' o 'en'). Por defecto 'es' + **kwargs: Variables para interpolación (ej: field='nombre', value='123') + + Returns: + Mensaje traducido con variables interpoladas + + Examples: + >>> t('auth.credentials_required', 'en') + 'Credentials required' + + >>> t('validation.field_not_numeric', 'en', field='age', value='abc') + "Field 'age' must be numeric (float). Received value: abc" + """ + messages = MESSAGES.get(lang, MESSAGES["es"]) + message = messages.get(key, key) + + # Interpolar variables si se proporcionan + if kwargs: + try: + return message.format(**kwargs) + except KeyError: + # Si falta alguna variable, devolver el mensaje sin interpolar + return message + + return message + + +def get_language_from_header(accept_language: str | None) -> Language: + """ + Extrae el idioma preferido del header Accept-Language. + + Args: + accept_language: Valor del header Accept-Language (ej: 'en-US,en;q=0.9,es;q=0.8') + + Returns: + 'en' si el idioma preferido es inglés, 'es' en caso contrario + + Examples: + >>> get_language_from_header('en-US,en;q=0.9') + 'en' + + >>> get_language_from_header('es-ES,es;q=0.9') + 'es' + + >>> get_language_from_header(None) + 'es' + """ + if not accept_language: + return "es" + + # Extraer el primer idioma del header + primary_lang = accept_language.split(',')[0].split('-')[0].strip().lower() + + return "en" if primary_lang == "en" else "es" diff --git a/backend/beyond_api/security.py b/backend/beyond_api/security.py index 67e1b73..56d58ad 100644 --- a/backend/beyond_api/security.py +++ b/backend/beyond_api/security.py @@ -2,8 +2,11 @@ from __future__ import annotations import os import secrets -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Header from fastapi.security import HTTPBasic, HTTPBasicCredentials +from typing import Optional + +from .i18n import t, get_language_from_header # auto_error=False para que no dispare el popup nativo del navegador automáticamente security = HTTPBasic(auto_error=False) @@ -13,16 +16,21 @@ BASIC_USER = os.getenv("BASIC_AUTH_USERNAME", "beyond") BASIC_PASS = os.getenv("BASIC_AUTH_PASSWORD", "beyond2026") -def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security)) -> str: +def get_current_user( + credentials: HTTPBasicCredentials | None = Depends(security), + accept_language: Optional[str] = Header(None) +) -> str: """ Valida el usuario/contraseña vía HTTP Basic. NO envía WWW-Authenticate para evitar el popup nativo del navegador (el frontend tiene su propio formulario de login). """ + lang = get_language_from_header(accept_language) + if credentials is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Credenciales requeridas", + detail=t("auth.credentials_required", lang), ) correct_username = secrets.compare_digest(credentials.username, BASIC_USER) @@ -31,7 +39,7 @@ def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security if not (correct_username and correct_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Credenciales incorrectas", + detail=t("auth.incorrect_credentials", lang), ) return credentials.username diff --git a/frontend/components/DashboardHeader.tsx b/frontend/components/DashboardHeader.tsx index a067ee2..af7fd37 100644 --- a/frontend/components/DashboardHeader.tsx +++ b/frontend/components/DashboardHeader.tsx @@ -1,5 +1,7 @@ import { motion } from 'framer-motion'; import { LayoutDashboard, Layers, Bot, Map, ShieldCheck, Info, Scale } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { LanguageSelector } from './LanguageSelector'; export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap' | 'law10'; @@ -16,37 +18,41 @@ interface DashboardHeaderProps { onMetodologiaClick?: () => void; } -const TABS: TabConfig[] = [ - { id: 'executive', label: 'Resumen', icon: LayoutDashboard }, - { id: 'dimensions', label: 'Dimensiones', icon: Layers }, - { id: 'readiness', label: 'Agentic Readiness', icon: Bot }, - { id: 'roadmap', label: 'Roadmap', icon: Map }, - { id: 'law10', label: 'Ley 10/2025', icon: Scale }, -]; - export function DashboardHeader({ title = 'CLIENTE DEMO - Beyond CX Analytics', activeTab, onTabChange, onMetodologiaClick }: DashboardHeaderProps) { + const { t } = useTranslation(); + + const TABS: TabConfig[] = [ + { id: 'executive', label: t('tabs.executive'), icon: LayoutDashboard }, + { id: 'dimensions', label: t('tabs.dimensions'), icon: Layers }, + { id: 'readiness', label: t('tabs.agenticReadiness'), icon: Bot }, + { id: 'roadmap', label: t('tabs.roadmap'), icon: Map }, + { id: 'law10', label: t('tabs.law10'), icon: Scale }, + ]; return (
- {/* Top row: Title and Metodología Badge */} + {/* Top row: Title and Badges */}

{title}

- {onMetodologiaClick && ( - - )} +
+ + {onMetodologiaClick && ( + + )} +
diff --git a/frontend/components/DataUploader.tsx b/frontend/components/DataUploader.tsx index 0a4a4f2..74f19d0 100644 --- a/frontend/components/DataUploader.tsx +++ b/frontend/components/DataUploader.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback } from 'react'; import { UploadCloud, File, Sheet, Loader2, CheckCircle, Sparkles, Wand2, BarChart3 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { generateSyntheticCsv } from '../utils/syntheticDataGenerator'; import { TierKey } from '../types'; @@ -21,6 +22,7 @@ const formatFileSize = (bytes: number, decimals = 2) => { }; const DataUploader: React.FC = ({ selectedTier, onAnalysisReady, isAnalyzing }) => { + const { t } = useTranslation(); const [file, setFile] = useState(null); const [sheetUrl, setSheetUrl] = useState(''); const [status, setStatus] = useState('idle'); @@ -57,7 +59,7 @@ const DataUploader: React.FC = ({ selectedTier, onAnalysisRea setFile(selectedFile); setSheetUrl(''); } else { - setError('Tipo de archivo no válido. Sube un CSV o Excel.'); + setError(t('upload.invalidFileType')); setFile(null); } } @@ -89,19 +91,19 @@ const DataUploader: React.FC = ({ selectedTier, onAnalysisRea setStatus('generating'); setTimeout(() => { const csvData = generateSyntheticCsv(selectedTier); - handleDataReady('Datos Sintéticos Generados!'); + handleDataReady(t('upload.syntheticDataGenerated')); }, 2000); }; const handleSubmit = () => { if (!file && !sheetUrl) { - setError('Por favor, sube un archivo o introduce una URL de Google Sheet.'); + setError(t('upload.pleaseUploadFile')); return; } resetState(false); setStatus('uploading'); setTimeout(() => { - handleDataReady('Datos Recibidos!'); + handleDataReady(t('upload.dataReceived')); }, 2000); }; @@ -114,7 +116,7 @@ const DataUploader: React.FC = ({ selectedTier, onAnalysisRea className="w-full flex items-center justify-center gap-2 text-white px-6 py-3 rounded-lg transition-colors shadow-sm hover:shadow-md bg-green-600 hover:bg-green-700 disabled:opacity-75 disabled:cursor-not-allowed" > {isAnalyzing ? : } - {isAnalyzing ? 'Analizando...' : 'Ver Dashboard de Diagnóstico'} + {isAnalyzing ? t('upload.analyzingData') : t('dashboard.viewDashboard')} ); } @@ -126,7 +128,7 @@ const DataUploader: React.FC = ({ selectedTier, onAnalysisRea className="w-full flex items-center justify-center gap-2 text-white px-6 py-3 rounded-lg transition-colors shadow-sm hover:shadow-md bg-blue-600 hover:bg-blue-700 disabled:opacity-75 disabled:cursor-not-allowed" > {status === 'uploading' ? : } - {status === 'uploading' ? 'Procesando...' : 'Generar Análisis'} + {status === 'uploading' ? t('upload.processingData') : t('dashboard.generateAnalysis')} ); }; @@ -134,10 +136,10 @@ const DataUploader: React.FC = ({ selectedTier, onAnalysisRea return (
- Paso 2 -

Sube tus Datos y Ejecuta el Análisis

+ {t('stepper.step2')} +

{t('upload.title')}

- Usa una de las siguientes opciones para enviarnos tus datos para el análisis. + {t('upload.subtitle')}

@@ -158,33 +160,33 @@ const DataUploader: React.FC = ({ selectedTier, onAnalysisRea />

- O + {t('common.or')}
-

¿No tienes datos a mano? Genera un set de datos de ejemplo.

+

{t('upload.noDataPrompt')}


- O + {t('common.or')}
@@ -192,7 +194,7 @@ const DataUploader: React.FC = ({ selectedTier, onAnalysisRea { resetState(); @@ -249,7 +251,7 @@ const DataUploader: React.FC = ({ selectedTier, onAnalysisRea {status === 'success' && (
- {successMessage} ¡Listo para analizar! + {successMessage} {t('upload.readyToAnalyze')}
)} diff --git a/frontend/components/LanguageSelector.tsx b/frontend/components/LanguageSelector.tsx new file mode 100644 index 0000000..08988f0 --- /dev/null +++ b/frontend/components/LanguageSelector.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Languages } from 'lucide-react'; + +export function LanguageSelector() { + const { i18n } = useTranslation(); + + const toggleLanguage = () => { + const newLang = i18n.language === 'es' ? 'en' : 'es'; + i18n.changeLanguage(newLang); + localStorage.setItem('language', newLang); + }; + + const currentLang = i18n.language === 'es' ? 'ES' : 'EN'; + const nextLang = i18n.language === 'es' ? 'EN' : 'ES'; + + return ( + + ); +} + +export default LanguageSelector; diff --git a/frontend/components/LoginPage.tsx b/frontend/components/LoginPage.tsx index 94931e9..89f537e 100644 --- a/frontend/components/LoginPage.tsx +++ b/frontend/components/LoginPage.tsx @@ -3,9 +3,11 @@ import React, { useState } from 'react'; import { motion } from 'framer-motion'; import { Lock, User } from 'lucide-react'; import toast from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '../utils/AuthContext'; const LoginPage: React.FC = () => { + const { t } = useTranslation(); const { login } = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -14,14 +16,14 @@ const LoginPage: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!username || !password) { - toast.error('Introduce usuario y contraseña'); + toast.error(t('auth.credentialsRequired')); return; } setIsSubmitting(true); try { - await login(username, password); - toast.success('Sesión iniciada'); + await login(username, password); + toast.success(t('auth.sessionStarted')); } catch (err) { console.error('Error en login', err); const msg = @@ -48,14 +50,14 @@ const LoginPage: React.FC = () => { Beyond Diagnostic

- Inicia sesión para acceder al análisis + {t('auth.loginTitle')}

@@ -73,7 +75,7 @@ const LoginPage: React.FC = () => {
@@ -94,11 +96,11 @@ const LoginPage: React.FC = () => { disabled={isSubmitting} className="w-full inline-flex items-center justify-center rounded-2xl bg-indigo-600 text-white text-sm font-medium py-2.5 shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-60 disabled:cursor-not-allowed" > - {isSubmitting ? 'Entrando…' : 'Entrar'} + {isSubmitting ? t('auth.enteringButton') : t('auth.enterButton')}

- La sesión permanecerá activa durante 1 hora. + {t('auth.sessionInfo')}

diff --git a/frontend/i18n.ts b/frontend/i18n.ts new file mode 100644 index 0000000..10dc804 --- /dev/null +++ b/frontend/i18n.ts @@ -0,0 +1,21 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import es from './locales/es.json'; +import en from './locales/en.json'; + +// Configuración de i18next +i18n + .use(initReactI18next) + .init({ + resources: { + es: { translation: es }, + en: { translation: en }, + }, + lng: localStorage.getItem('language') || 'es', // Español por defecto + fallbackLng: 'es', // Si falla una traducción, usa español + interpolation: { + escapeValue: false, // React ya escapa por defecto + }, + }); + +export default i18n; diff --git a/frontend/index.tsx b/frontend/index.tsx index 88fa976..0701598 100644 --- a/frontend/index.tsx +++ b/frontend/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; +import './i18n'; // Inicializar i18n import App from './App'; const rootElement = document.getElementById('root'); diff --git a/frontend/locales/en.json b/frontend/locales/en.json new file mode 100644 index 0000000..0575ff0 --- /dev/null +++ b/frontend/locales/en.json @@ -0,0 +1,207 @@ +{ + "common": { + "or": "OR", + "loading": "Loading...", + "error": "Error", + "success": "Success", + "cancel": "Cancel", + "confirm": "Confirm", + "close": "Close", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "view": "View", + "back": "Back", + "next": "Next", + "previous": "Previous", + "search": "Search", + "filter": "Filter", + "export": "Export", + "import": "Import" + }, + "auth": { + "login": "Log in", + "logout": "Log out", + "loginTitle": "Log in to access the analysis", + "username": "Username", + "password": "Password", + "enterButton": "Enter", + "enteringButton": "Logging in…", + "sessionInfo": "The session will remain active for 1 hour.", + "sessionExpired": "Session expired or incorrect credentials. Please log in again.", + "loginRequired": "You must log in to analyze data.", + "credentialsRequired": "Enter username and password", + "sessionStarted": "Session started" + }, + "upload": { + "title": "Upload Your Data and Run Analysis", + "uploadFile": "Upload File", + "clickToUpload": "Click to upload a file", + "dragAndDrop": "or drag and drop here", + "fileTypes": "CSV, XLSX, or XLS", + "googleSheetPlaceholder": "Paste your Google Sheet URL here", + "invalidFileType": "Invalid file type. Upload a CSV or Excel file.", + "pleaseUploadFile": "Please upload a file or enter a Google Sheet URL.", + "generateSyntheticData": "Generate Synthetic Data", + "syntheticDataGenerated": "Synthetic Data Generated!", + "dataReceived": "Data Received!", + "noDataPrompt": "Don't have data on hand? Generate a sample dataset.", + "processingData": "Processing...", + "analyzingData": "Analyzing...", + "generating": "Generating...", + "readyToAnalyze": "Ready to analyze!", + "dataProcessed": "Data Processed", + "recordsAnalyzed": "Records analyzed", + "monthsHistory": "Months of history", + "sourceSystem": "Source system", + "highConfidence": "Confidence: High", + "mediumConfidence": "Confidence: Medium", + "lowConfidence": "Confidence: Low", + "subtitle": "Use one of the following options to send us your data for analysis." + }, + "stepper": { + "step1": "Step 1", + "step2": "Step 2", + "step3": "Step 3", + "selectTier": "Select Tier", + "uploadData": "Upload Data", + "viewResults": "View Results" + }, + "tiers": { + "gold": { + "name": "GOLD Analysis", + "description": "5 complete dimensions with advanced Agentic Readiness", + "features": [ + "5 dimensions: Volumetry, Efficiency, Effectiveness, Complexity, Agentic Readiness", + "Generative AI: Personalized insights by OpenAI", + "Transformation roadmap: AUTOMATE, ASSIST, AUGMENT phases", + "Prioritization matrix with maturity levels", + "Advanced benchmarking against industry", + "Exportable PDF reports" + ] + }, + "silver": { + "name": "SILVER Analysis", + "description": "4 core dimensions with operational metrics", + "features": [ + "4 dimensions: Volumetry, Efficiency, Effectiveness, Economy", + "Analysis without generative AI", + "Simplified roadmap", + "Basic benchmark", + "Opportunity matrix" + ] + }, + "express": { + "name": "EXPRESS Analysis", + "description": "Quick diagnosis of volumetry and efficiency", + "features": [ + "2 dimensions: Volumetry, Efficiency", + "Instant analysis", + "No AI or roadmap", + "Ideal for quick tests" + ] + } + }, + "dashboard": { + "title": "Diagnostic Dashboard", + "viewDashboard": "View Diagnostic Dashboard", + "generateAnalysis": "Generate Analysis", + "analyzing": "Analyzing...", + "analysisComplete": "Analysis completed!", + "dataLoadedFromCache": "Data loaded from cache!", + "reloadPage": "Reload Page" + }, + "tabs": { + "executive": "Summary", + "dimensions": "Dimensions", + "agenticReadiness": "Agentic Readiness", + "roadmap": "Roadmap", + "law10": "Law 10/2025" + }, + "dimensions": { + "volumetry": "Volumetry & Distribution", + "operationalPerformance": "Operational Efficiency", + "effectiveness": "Effectiveness & Resolution", + "complexity": "Complexity & Predictability", + "economy": "Economy & Costs", + "agenticReadiness": "Agentic Readiness" + }, + "healthStatus": { + "excellent": "EXCELLENT", + "excellentDesc": "Top quartile, role model", + "good": "GOOD", + "goodDesc": "Above benchmarks, solid performance", + "medium": "MEDIUM", + "mediumDesc": "Within expected range", + "low": "LOW", + "lowDesc": "Needs improvement, below benchmarks", + "critical": "CRITICAL", + "criticalDesc": "Requires immediate intervention" + }, + "benchmark": { + "title": "Industry Benchmark (P50)", + "aboveBenchmark": "Above benchmarks, solid performance", + "belowBenchmark": "Needs improvement, below benchmarks", + "withinRange": "Within expected range" + }, + "roadmap": { + "wave1": "Wave 1: AUTOMATE", + "wave2": "Wave 2: ASSIST", + "wave3": "Wave 3: AUGMENT", + "quickWins": "Quick Wins (0-6 months)", + "buildCapability": "Build Capability (6-12 months)", + "transform": "Transform (12-18 months)", + "automate": "Automate", + "duration3to6": "3-6 months", + "duration6to12": "6-12 months", + "duration12to18": "12-18 months" + }, + "opportunities": { + "viewCriticalActions": "View Critical Actions", + "exploreImprovements": "Explore Improvements", + "inGoodState": "In good state", + "prioritize": "Prioritize", + "optimize": "Optimize", + "maintain": "Maintain" + }, + "agenticReadiness": { + "score": "Agentic Readiness Score", + "confidence": "Confidence", + "readyForCopilot": "Ready for Copilot", + "readyForCopilotDesc": "Processes with sufficient predictability and simplicity for AI assistance (real-time suggestions, autocomplete).", + "optimizeFirst": "Optimize first", + "optimizeFirstDesc": "Standardize processes and reduce variability before implementing AI assistance.", + "requiresHumanManagement": "Requires human management", + "requiresHumanManagementDesc": "Complex or variable processes that need human intervention before considering automation." + }, + "economicModel": { + "title": "Economic Model", + "costPerInteraction": "Cost per interaction (CPI) by channel", + "totalCost": "Total Cost", + "avgCost": "Average Cost", + "costBreakdown": "Cost Breakdown", + "enterCostPerHour": "Please enter the cost per hour for the agent.", + "noCostConfig": "No cost configuration" + }, + "charts": { + "volumeByDayAndHour": "Volume by day of week and hour", + "ahtDistributionBySkill": "AHT distribution by skill", + "resolutionFunnelBySkill": "Resolution funnel (P50) by skill", + "csatDistribution": "CSAT distribution" + }, + "errors": { + "renderError": "Rendering Error", + "componentError": "This component encountered an error and could not render correctly.", + "viewTechnicalDetails": "View technical details", + "errorInComponent": "Error in {{componentName}}", + "somethingWentWrong": "Something went wrong", + "tryAgain": "Try again" + }, + "methodology": { + "title": "Methodology", + "description": "Learn how we calculate metrics", + "close": "Close", + "appliedBadge": "Data Transformation Methodology Applied", + "appliedBadgeShort": "Methodology" + } +} diff --git a/frontend/locales/es.json b/frontend/locales/es.json new file mode 100644 index 0000000..83db7c7 --- /dev/null +++ b/frontend/locales/es.json @@ -0,0 +1,207 @@ +{ + "common": { + "or": "O", + "loading": "Cargando...", + "error": "Error", + "success": "Éxito", + "cancel": "Cancelar", + "confirm": "Confirmar", + "close": "Cerrar", + "save": "Guardar", + "delete": "Eliminar", + "edit": "Editar", + "view": "Ver", + "back": "Volver", + "next": "Siguiente", + "previous": "Anterior", + "search": "Buscar", + "filter": "Filtrar", + "export": "Exportar", + "import": "Importar" + }, + "auth": { + "login": "Iniciar sesión", + "logout": "Cerrar sesión", + "loginTitle": "Inicia sesión para acceder al análisis", + "username": "Usuario", + "password": "Contraseña", + "enterButton": "Entrar", + "enteringButton": "Entrando…", + "sessionInfo": "La sesión permanecerá activa durante 1 hora.", + "sessionExpired": "Sesión caducada o credenciales incorrectas. Vuelve a iniciar sesión.", + "loginRequired": "Debes iniciar sesión para analizar datos.", + "credentialsRequired": "Introduce usuario y contraseña", + "sessionStarted": "Sesión iniciada" + }, + "upload": { + "title": "Sube tus Datos y Ejecuta el Análisis", + "uploadFile": "Subir Archivo", + "clickToUpload": "Haz clic para subir un fichero", + "dragAndDrop": "o arrastra y suelta aquí", + "fileTypes": "CSV, XLSX, o XLS", + "googleSheetPlaceholder": "Pega la URL de tu Google Sheet aquí", + "invalidFileType": "Tipo de archivo no válido. Sube un CSV o Excel.", + "pleaseUploadFile": "Por favor, sube un archivo o introduce una URL de Google Sheet.", + "generateSyntheticData": "Generar Datos Sintéticos", + "syntheticDataGenerated": "Datos Sintéticos Generados!", + "dataReceived": "Datos Recibidos!", + "noDataPrompt": "¿No tienes datos a mano? Genera un set de datos de ejemplo.", + "processingData": "Procesando...", + "analyzingData": "Analizando...", + "generating": "Generando...", + "readyToAnalyze": "¡Listo para analizar!", + "dataProcessed": "Datos Procesados", + "recordsAnalyzed": "Registros analizados", + "monthsHistory": "Meses de histórico", + "sourceSystem": "Sistema origen", + "highConfidence": "Confianza: Alta", + "mediumConfidence": "Confianza: Media", + "lowConfidence": "Confianza: Baja", + "subtitle": "Usa una de las siguientes opciones para enviarnos tus datos para el análisis." + }, + "stepper": { + "step1": "Paso 1", + "step2": "Paso 2", + "step3": "Paso 3", + "selectTier": "Seleccionar Tier", + "uploadData": "Subir Datos", + "viewResults": "Ver Resultados" + }, + "tiers": { + "gold": { + "name": "Análisis GOLD", + "description": "5 dimensiones completas con Agentic Readiness avanzado", + "features": [ + "5 dimensiones: Volumetría, Eficiencia, Efectividad, Complejidad, Agentic Readiness", + "IA Generativa: Insights personalizados por OpenAI", + "Roadmap de transformación: Fases AUTOMATE, ASSIST, AUGMENT", + "Matriz de priorización con niveles de madurez", + "Benchmarking avanzado contra industria", + "Informes exportables en PDF" + ] + }, + "silver": { + "name": "Análisis SILVER", + "description": "4 dimensiones core con métricas operativas", + "features": [ + "4 dimensiones: Volumetría, Eficiencia, Efectividad, Economía", + "Análisis sin IA generativa", + "Roadmap simplificado", + "Benchmark básico", + "Matriz de oportunidades" + ] + }, + "express": { + "name": "Análisis EXPRESS", + "description": "Diagnóstico rápido de volumetría y eficiencia", + "features": [ + "2 dimensiones: Volumetría, Eficiencia", + "Análisis instantáneo", + "Sin IA ni roadmap", + "Ideal para pruebas rápidas" + ] + } + }, + "dashboard": { + "title": "Dashboard de Diagnóstico", + "viewDashboard": "Ver Dashboard de Diagnóstico", + "generateAnalysis": "Generar Análisis", + "analyzing": "Analizando...", + "analysisComplete": "¡Análisis completado!", + "dataLoadedFromCache": "¡Datos cargados desde caché!", + "reloadPage": "Recargar Página" + }, + "tabs": { + "executive": "Resumen", + "dimensions": "Dimensiones", + "agenticReadiness": "Agentic Readiness", + "roadmap": "Roadmap", + "law10": "Ley 10/2025" + }, + "dimensions": { + "volumetry": "Volumetría & Distribución", + "operationalPerformance": "Eficiencia Operativa", + "effectiveness": "Efectividad & Resolución", + "complexity": "Complejidad & Predictibilidad", + "economy": "Economía & Costes", + "agenticReadiness": "Agentic Readiness" + }, + "healthStatus": { + "excellent": "EXCELENTE", + "excellentDesc": "Top quartile, modelo a seguir", + "good": "BUENO", + "goodDesc": "Por encima de benchmarks, desempeño sólido", + "medium": "MEDIO", + "mediumDesc": "Dentro de rango esperado", + "low": "BAJO", + "lowDesc": "Requiere mejora, por debajo de benchmarks", + "critical": "CRÍTICO", + "criticalDesc": "Necesita intervención inmediata" + }, + "benchmark": { + "title": "Benchmark Industria (P50)", + "aboveBenchmark": "Por encima de benchmarks, desempeño sólido", + "belowBenchmark": "Requiere mejora, por debajo de benchmarks", + "withinRange": "Dentro de rango esperado" + }, + "roadmap": { + "wave1": "Wave 1: AUTOMATE", + "wave2": "Wave 2: ASSIST", + "wave3": "Wave 3: AUGMENT", + "quickWins": "Quick Wins (0-6 meses)", + "buildCapability": "Build Capability (6-12 meses)", + "transform": "Transform (12-18 meses)", + "automate": "Automatizar", + "duration3to6": "3-6 meses", + "duration6to12": "6-12 meses", + "duration12to18": "12-18 meses" + }, + "opportunities": { + "viewCriticalActions": "Ver Acciones Críticas", + "explorImprovements": "Explorar Mejoras", + "inGoodState": "En buen estado", + "prioritize": "Priorizar", + "optimize": "Optimizar", + "maintain": "Mantener" + }, + "agenticReadiness": { + "score": "Agentic Readiness Score", + "confidence": "Confianza", + "readyForCopilot": "Listo para Copilot", + "readyForCopilotDesc": "Procesos con predictibilidad y simplicidad suficientes para asistencia IA (sugerencias en tiempo real, autocompletado).", + "optimizeFirst": "Optimizar primero", + "optimizeFirstDesc": "Estandarizar procesos y reducir variabilidad antes de implementar asistencia IA.", + "requiresHumanManagement": "Requiere gestión humana", + "requiresHumanManagementDesc": "Procesos complejos o variables que necesitan intervención humana antes de considerar automatización." + }, + "economicModel": { + "title": "Modelo Económico", + "costPerInteraction": "Coste por interacción (CPI) por canal", + "totalCost": "Coste Total", + "avgCost": "Coste Promedio", + "costBreakdown": "Desglose de Costes", + "enterCostPerHour": "Por favor, introduce el coste por hora del agente.", + "noCostConfig": "Sin configuración de costes" + }, + "charts": { + "volumeByDayAndHour": "Volumen por día de la semana y hora", + "ahtDistributionBySkill": "Distribución de AHT por skill", + "resolutionFunnelBySkill": "Funnel de resolución (P50) por skill", + "csatDistribution": "Distribución de CSAT" + }, + "errors": { + "renderError": "Error de Renderizado", + "componentError": "Este componente encontró un error y no pudo renderizarse correctamente.", + "viewTechnicalDetails": "Ver detalles técnicos", + "errorInComponent": "Error en {{componentName}}", + "somethingWentWrong": "Algo salió mal", + "tryAgain": "Intentar de nuevo" + }, + "methodology": { + "title": "Metodología", + "description": "Conoce cómo calculamos las métricas", + "close": "Cerrar", + "appliedBadge": "Metodología de Transformación de Datos aplicada", + "appliedBadgeShort": "Metodología" + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 475d50d..51abf14 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,11 +12,13 @@ "@radix-ui/react-tooltip": "^1.2.8", "clsx": "^2.1.1", "framer-motion": "^12.23.24", + "i18next": "^25.8.4", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-countup": "^6.5.3", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^16.5.4", "recharts": "^3.4.1", "xlsx": "^0.18.5" }, @@ -261,6 +263,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -2300,6 +2311,46 @@ "csstype": "^3.0.10" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.8.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz", + "integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -2518,6 +2569,33 @@ "react-dom": ">=16" } }, + "node_modules/react-i18next": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", + "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", @@ -2791,7 +2869,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2988,6 +3066,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 81a4b5a..1bfd4a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,11 +13,13 @@ "@radix-ui/react-tooltip": "^1.2.8", "clsx": "^2.1.1", "framer-motion": "^12.23.24", + "i18next": "^25.8.4", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-countup": "^6.5.3", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^16.5.4", "recharts": "^3.4.1", "xlsx": "^0.18.5" }, diff --git a/frontend/utils/AuthContext.tsx b/frontend/utils/AuthContext.tsx index f2eca62..21d8bc0 100644 --- a/frontend/utils/AuthContext.tsx +++ b/frontend/utils/AuthContext.tsx @@ -1,5 +1,6 @@ // utils/AuthContext.tsx import React, { createContext, useContext, useEffect, useState } from 'react'; +import i18n from '../i18n'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'; @@ -61,6 +62,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children method: 'GET', headers: { Authorization: basic, + 'Accept-Language': i18n.language || 'es', }, }); } catch (err) { diff --git a/frontend/utils/apiClient.ts b/frontend/utils/apiClient.ts index 187a5d1..faa767b 100644 --- a/frontend/utils/apiClient.ts +++ b/frontend/utils/apiClient.ts @@ -1,5 +1,6 @@ // utils/apiClient.ts import type { TierKey } from '../types'; +import i18n from '../i18n'; type SegmentMapping = { high_value_queues: string[]; @@ -85,6 +86,7 @@ export async function callAnalysisApiRaw(params: { body: formData, headers: { ...authHeaders, + 'Accept-Language': i18n.language || 'es', }, });