Merge pull request #1 from sujucu70/claude/add-english-language-1N9VX
Add English language support with i18n implementation
This commit is contained in:
@@ -7,11 +7,12 @@ import math
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from typing import Optional, Any, Literal
|
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 fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from beyond_api.security import get_current_user
|
from beyond_api.security import get_current_user
|
||||||
from beyond_api.services.analysis_service import run_analysis_collect_json
|
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 paths - same as in cache.py
|
||||||
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache"))
|
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache"))
|
||||||
@@ -52,6 +53,7 @@ async def analysis_endpoint(
|
|||||||
economy_json: Optional[str] = Form(default=None),
|
economy_json: Optional[str] = Form(default=None),
|
||||||
analysis: Literal["basic", "premium"] = Form(default="premium"),
|
analysis: Literal["basic", "premium"] = Form(default="premium"),
|
||||||
current_user: str = Depends(get_current_user),
|
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
|
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
|
- "premium": usa la configuración completa por defecto
|
||||||
(p.ej. beyond_metrics_config.json), sin romper lo existente.
|
(p.ej. beyond_metrics_config.json), sin romper lo existente.
|
||||||
"""
|
"""
|
||||||
|
lang = get_language_from_header(accept_language)
|
||||||
|
|
||||||
# Validar `analysis` (por si llega algo raro)
|
# Validar `analysis` (por si llega algo raro)
|
||||||
if analysis not in {"basic", "premium"}:
|
if analysis not in {"basic", "premium"}:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="analysis debe ser 'basic' o 'premium'.",
|
detail=t("analysis.invalid_type", lang),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1) Parseo de economía (si viene)
|
# 1) Parseo de economía (si viene)
|
||||||
@@ -78,7 +81,7 @@ async def analysis_endpoint(
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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
|
# 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),
|
economy_json: Optional[str] = Form(default=None),
|
||||||
analysis: Literal["basic", "premium"] = Form(default="premium"),
|
analysis: Literal["basic", "premium"] = Form(default="premium"),
|
||||||
current_user: str = Depends(get_current_user),
|
current_user: str = Depends(get_current_user),
|
||||||
|
accept_language: Optional[str] = Header(None),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Ejecuta el pipeline sobre el archivo CSV cacheado en el servidor.
|
Ejecuta el pipeline sobre el archivo CSV cacheado en el servidor.
|
||||||
Útil para re-analizar sin tener que subir el archivo de nuevo.
|
Ú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
|
# Validar que existe el archivo cacheado
|
||||||
if not CACHED_FILE.exists():
|
if not CACHED_FILE.exists():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="No hay archivo cacheado en el servidor. Sube un archivo primero.",
|
detail=t("analysis.no_cached_file", lang),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validar `analysis`
|
# Validar `analysis`
|
||||||
if analysis not in {"basic", "premium"}:
|
if analysis not in {"basic", "premium"}:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="analysis debe ser 'basic' o 'premium'.",
|
detail=t("analysis.invalid_type", lang),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parseo de economía (si viene)
|
# Parseo de economía (si viene)
|
||||||
@@ -186,7 +192,7 @@ async def analysis_cached_endpoint(
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="economy_json no es un JSON válido.",
|
detail=t("analysis.invalid_economy_json", lang),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extraer metadatos del CSV
|
# Extraer metadatos del CSV
|
||||||
@@ -204,7 +210,7 @@ async def analysis_cached_endpoint(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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
|
# Limpiar NaN/inf para que el JSON sea válido
|
||||||
|
|||||||
128
backend/beyond_api/i18n.py
Normal file
128
backend/beyond_api/i18n.py
Normal file
@@ -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"
|
||||||
@@ -2,8 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status, Header
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
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
|
# auto_error=False para que no dispare el popup nativo del navegador automáticamente
|
||||||
security = HTTPBasic(auto_error=False)
|
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")
|
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.
|
Valida el usuario/contraseña vía HTTP Basic.
|
||||||
NO envía WWW-Authenticate para evitar el popup nativo del navegador
|
NO envía WWW-Authenticate para evitar el popup nativo del navegador
|
||||||
(el frontend tiene su propio formulario de login).
|
(el frontend tiene su propio formulario de login).
|
||||||
"""
|
"""
|
||||||
|
lang = get_language_from_header(accept_language)
|
||||||
|
|
||||||
if credentials is None:
|
if credentials is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Credenciales requeridas",
|
detail=t("auth.credentials_required", lang),
|
||||||
)
|
)
|
||||||
|
|
||||||
correct_username = secrets.compare_digest(credentials.username, BASIC_USER)
|
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):
|
if not (correct_username and correct_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Credenciales incorrectas",
|
detail=t("auth.incorrect_credentials", lang),
|
||||||
)
|
)
|
||||||
|
|
||||||
return credentials.username
|
return credentials.username
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { LayoutDashboard, Layers, Bot, Map, ShieldCheck, Info, Scale } from 'lucide-react';
|
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';
|
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap' | 'law10';
|
||||||
|
|
||||||
@@ -16,39 +18,43 @@ interface DashboardHeaderProps {
|
|||||||
onMetodologiaClick?: () => void;
|
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({
|
export function DashboardHeader({
|
||||||
title = 'CLIENTE DEMO - Beyond CX Analytics',
|
title = 'CLIENTE DEMO - Beyond CX Analytics',
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
onMetodologiaClick
|
onMetodologiaClick
|
||||||
}: DashboardHeaderProps) {
|
}: 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 (
|
return (
|
||||||
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
|
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
|
||||||
{/* Top row: Title and Metodología Badge */}
|
{/* Top row: Title and Badges */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
|
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LanguageSelector />
|
||||||
{onMetodologiaClick && (
|
{onMetodologiaClick && (
|
||||||
<button
|
<button
|
||||||
onClick={onMetodologiaClick}
|
onClick={onMetodologiaClick}
|
||||||
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer flex-shrink-0"
|
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer flex-shrink-0"
|
||||||
>
|
>
|
||||||
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||||
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
|
<span className="hidden md:inline">{t('methodology.appliedBadge')}</span>
|
||||||
<span className="md:hidden">Metodología</span>
|
<span className="md:hidden">{t('methodology.appliedBadgeShort')}</span>
|
||||||
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
|
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<nav className="max-w-7xl mx-auto px-2 sm:px-6 overflow-x-auto">
|
<nav className="max-w-7xl mx-auto px-2 sm:px-6 overflow-x-auto">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { UploadCloud, File, Sheet, Loader2, CheckCircle, Sparkles, Wand2, BarChart3 } from 'lucide-react';
|
import { UploadCloud, File, Sheet, Loader2, CheckCircle, Sparkles, Wand2, BarChart3 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generateSyntheticCsv } from '../utils/syntheticDataGenerator';
|
import { generateSyntheticCsv } from '../utils/syntheticDataGenerator';
|
||||||
import { TierKey } from '../types';
|
import { TierKey } from '../types';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const formatFileSize = (bytes: number, decimals = 2) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisReady, isAnalyzing }) => {
|
const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisReady, isAnalyzing }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [sheetUrl, setSheetUrl] = useState('');
|
const [sheetUrl, setSheetUrl] = useState('');
|
||||||
const [status, setStatus] = useState<UploadStatus>('idle');
|
const [status, setStatus] = useState<UploadStatus>('idle');
|
||||||
@@ -57,7 +59,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
|
|||||||
setFile(selectedFile);
|
setFile(selectedFile);
|
||||||
setSheetUrl('');
|
setSheetUrl('');
|
||||||
} else {
|
} else {
|
||||||
setError('Tipo de archivo no válido. Sube un CSV o Excel.');
|
setError(t('upload.invalidFileType'));
|
||||||
setFile(null);
|
setFile(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,19 +91,19 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
|
|||||||
setStatus('generating');
|
setStatus('generating');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const csvData = generateSyntheticCsv(selectedTier);
|
const csvData = generateSyntheticCsv(selectedTier);
|
||||||
handleDataReady('Datos Sintéticos Generados!');
|
handleDataReady(t('upload.syntheticDataGenerated'));
|
||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!file && !sheetUrl) {
|
if (!file && !sheetUrl) {
|
||||||
setError('Por favor, sube un archivo o introduce una URL de Google Sheet.');
|
setError(t('upload.pleaseUploadFile'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetState(false);
|
resetState(false);
|
||||||
setStatus('uploading');
|
setStatus('uploading');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
handleDataReady('Datos Recibidos!');
|
handleDataReady(t('upload.dataReceived'));
|
||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ 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"
|
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 ? <Loader2 className="animate-spin" size={20} /> : <BarChart3 size={20} />}
|
{isAnalyzing ? <Loader2 className="animate-spin" size={20} /> : <BarChart3 size={20} />}
|
||||||
{isAnalyzing ? 'Analizando...' : 'Ver Dashboard de Diagnóstico'}
|
{isAnalyzing ? t('upload.analyzingData') : t('dashboard.viewDashboard')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,7 +128,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ 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"
|
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' ? <Loader2 className="animate-spin" size={20} /> : <Wand2 size={20} />}
|
{status === 'uploading' ? <Loader2 className="animate-spin" size={20} /> : <Wand2 size={20} />}
|
||||||
{status === 'uploading' ? 'Procesando...' : 'Generar Análisis'}
|
{status === 'uploading' ? t('upload.processingData') : t('dashboard.generateAnalysis')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -134,10 +136,10 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
|
|||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<span className="text-blue-600 font-semibold mb-1 block">Paso 2</span>
|
<span className="text-blue-600 font-semibold mb-1 block">{t('stepper.step2')}</span>
|
||||||
<h2 className="text-2xl font-bold text-slate-900">Sube tus Datos y Ejecuta el Análisis</h2>
|
<h2 className="text-2xl font-bold text-slate-900">{t('upload.title')}</h2>
|
||||||
<p className="text-slate-600 mt-1">
|
<p className="text-slate-600 mt-1">
|
||||||
Usa una de las siguientes opciones para enviarnos tus datos para el análisis.
|
{t('upload.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,33 +160,33 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
|
|||||||
/>
|
/>
|
||||||
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center justify-center">
|
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center justify-center">
|
||||||
<UploadCloud className="w-12 h-12 text-slate-400 mb-2" />
|
<UploadCloud className="w-12 h-12 text-slate-400 mb-2" />
|
||||||
<span className="font-semibold text-blue-600">Haz clic para subir un fichero</span>
|
<span className="font-semibold text-blue-600">{t('upload.clickToUpload')}</span>
|
||||||
<span className="text-slate-500"> o arrástralo aquí</span>
|
<span className="text-slate-500"> {t('upload.dragAndDrop')}</span>
|
||||||
<p className="text-xs text-slate-400 mt-2">CSV, XLSX, o XLS</p>
|
<p className="text-xs text-slate-400 mt-2">{t('upload.fileTypes')}</p>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center text-slate-500">
|
<div className="flex items-center text-slate-500">
|
||||||
<hr className="w-full border-slate-300" />
|
<hr className="w-full border-slate-300" />
|
||||||
<span className="px-4 font-medium text-sm">O</span>
|
<span className="px-4 font-medium text-sm">{t('common.or')}</span>
|
||||||
<hr className="w-full border-slate-300" />
|
<hr className="w-full border-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center p-4 bg-slate-50 rounded-lg">
|
<div className="text-center p-4 bg-slate-50 rounded-lg">
|
||||||
<p className="text-sm text-slate-600 mb-3">¿No tienes datos a mano? Genera un set de datos de ejemplo.</p>
|
<p className="text-sm text-slate-600 mb-3">{t('upload.noDataPrompt')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateSyntheticData}
|
onClick={handleGenerateSyntheticData}
|
||||||
disabled={isActionInProgress}
|
disabled={isActionInProgress}
|
||||||
className="flex items-center justify-center gap-2 w-full sm:w-auto mx-auto bg-fuchsia-100 text-fuchsia-700 px-6 py-3 rounded-lg hover:bg-fuchsia-200 hover:text-fuchsia-800 transition-colors shadow-sm hover:shadow-md disabled:opacity-75 disabled:cursor-not-allowed font-semibold"
|
className="flex items-center justify-center gap-2 w-full sm:w-auto mx-auto bg-fuchsia-100 text-fuchsia-700 px-6 py-3 rounded-lg hover:bg-fuchsia-200 hover:text-fuchsia-800 transition-colors shadow-sm hover:shadow-md disabled:opacity-75 disabled:cursor-not-allowed font-semibold"
|
||||||
>
|
>
|
||||||
{status === 'generating' ? <Loader2 className="animate-spin" size={20} /> : <Sparkles size={20} />}
|
{status === 'generating' ? <Loader2 className="animate-spin" size={20} /> : <Sparkles size={20} />}
|
||||||
{status === 'generating' ? 'Generando...' : 'Generar Datos Sintéticos'}
|
{status === 'generating' ? t('upload.generating') : t('upload.generateSyntheticData')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center text-slate-500">
|
<div className="flex items-center text-slate-500">
|
||||||
<hr className="w-full border-slate-300" />
|
<hr className="w-full border-slate-300" />
|
||||||
<span className="px-4 font-medium text-sm">O</span>
|
<span className="px-4 font-medium text-sm">{t('common.or')}</span>
|
||||||
<hr className="w-full border-slate-300" />
|
<hr className="w-full border-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -192,7 +194,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
|
|||||||
<Sheet className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
<Sheet className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="Pega la URL de tu Google Sheet aquí"
|
placeholder={t('upload.googleSheetPlaceholder')}
|
||||||
value={sheetUrl}
|
value={sheetUrl}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
resetState();
|
resetState();
|
||||||
@@ -249,7 +251,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
|
|||||||
{status === 'success' && (
|
{status === 'success' && (
|
||||||
<div className="flex items-center justify-center gap-2 p-4 bg-green-50 border border-green-200 text-green-800 rounded-lg">
|
<div className="flex items-center justify-center gap-2 p-4 bg-green-50 border border-green-200 text-green-800 rounded-lg">
|
||||||
<CheckCircle className="w-6 h-6 flex-shrink-0" />
|
<CheckCircle className="w-6 h-6 flex-shrink-0" />
|
||||||
<span className="font-semibold">{successMessage} ¡Listo para analizar!</span>
|
<span className="font-semibold">{successMessage} {t('upload.readyToAnalyze')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
29
frontend/components/LanguageSelector.tsx
Normal file
29
frontend/components/LanguageSelector.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-full text-xs font-medium hover:bg-slate-200 transition-colors cursor-pointer"
|
||||||
|
title={`Switch to ${nextLang}`}
|
||||||
|
>
|
||||||
|
<Languages className="w-3.5 h-3.5" />
|
||||||
|
<span className="font-semibold">{currentLang}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
||||||
@@ -3,9 +3,11 @@ import React, { useState } from 'react';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Lock, User } from 'lucide-react';
|
import { Lock, User } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuth } from '../utils/AuthContext';
|
import { useAuth } from '../utils/AuthContext';
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -14,14 +16,14 @@ const LoginPage: React.FC = () => {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
toast.error('Introduce usuario y contraseña');
|
toast.error(t('auth.credentialsRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
toast.success('Sesión iniciada');
|
toast.success(t('auth.sessionStarted'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error en login', err);
|
console.error('Error en login', err);
|
||||||
const msg =
|
const msg =
|
||||||
@@ -48,14 +50,14 @@ const LoginPage: React.FC = () => {
|
|||||||
Beyond Diagnostic
|
Beyond Diagnostic
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Inicia sesión para acceder al análisis
|
{t('auth.loginTitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-slate-700">
|
<label className="block text-sm font-medium text-slate-700">
|
||||||
Usuario
|
{t('auth.username')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
|
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
|
||||||
@@ -73,7 +75,7 @@ const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-slate-700">
|
<label className="block text-sm font-medium text-slate-700">
|
||||||
Contraseña
|
{t('auth.password')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
|
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
|
||||||
@@ -94,11 +96,11 @@ const LoginPage: React.FC = () => {
|
|||||||
disabled={isSubmitting}
|
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"
|
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')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-[11px] text-slate-400 text-center mt-2">
|
<p className="text-[11px] text-slate-400 text-center mt-2">
|
||||||
La sesión permanecerá activa durante 1 hora.
|
{t('auth.sessionInfo')}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
21
frontend/i18n.ts
Normal file
21
frontend/i18n.ts
Normal file
@@ -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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './i18n'; // Inicializar i18n
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
|
|||||||
207
frontend/locales/en.json
Normal file
207
frontend/locales/en.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
207
frontend/locales/es.json
Normal file
207
frontend/locales/es.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
89
frontend/package-lock.json
generated
89
frontend/package-lock.json
generated
@@ -12,11 +12,13 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
"i18next": "^25.8.4",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-countup": "^6.5.3",
|
"react-countup": "^6.5.3",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-i18next": "^16.5.4",
|
||||||
"recharts": "^3.4.1",
|
"recharts": "^3.4.1",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
@@ -261,6 +263,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.2",
|
"version": "7.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||||
@@ -2300,6 +2311,46 @@
|
|||||||
"csstype": "^3.0.10"
|
"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": {
|
"node_modules/immer": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
@@ -2518,6 +2569,33 @@
|
|||||||
"react-dom": ">=16"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
|
||||||
@@ -2791,7 +2869,7 @@
|
|||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"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": {
|
"node_modules/wmf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
|||||||
@@ -13,11 +13,13 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
"i18next": "^25.8.4",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-countup": "^6.5.3",
|
"react-countup": "^6.5.3",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-i18next": "^16.5.4",
|
||||||
"recharts": "^3.4.1",
|
"recharts": "^3.4.1",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// utils/AuthContext.tsx
|
// utils/AuthContext.tsx
|
||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: basic,
|
Authorization: basic,
|
||||||
|
'Accept-Language': i18n.language || 'es',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// utils/apiClient.ts
|
// utils/apiClient.ts
|
||||||
import type { TierKey } from '../types';
|
import type { TierKey } from '../types';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
type SegmentMapping = {
|
type SegmentMapping = {
|
||||||
high_value_queues: string[];
|
high_value_queues: string[];
|
||||||
@@ -85,6 +86,7 @@ export async function callAnalysisApiRaw(params: {
|
|||||||
body: formData,
|
body: formData,
|
||||||
headers: {
|
headers: {
|
||||||
...authHeaders,
|
...authHeaders,
|
||||||
|
'Accept-Language': i18n.language || 'es',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user