Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions src/front/components/AIReplyPanel.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {useEffect, useState} from 'react';
import {aiMessages} from '../i18n/ai';
import {aiRequest} from '../services/aiService';
import {inboxService} from '../services/inboxService';
import {useLanguage} from '../context/LanguageContext';

export function AIReplyPanel({conversationId, token, companyId, onChanged, latestInbound}) {
const {ui} = useLanguage();
const [agents, setAgents] = useState([]);
const [agentId, setAgentId] = useState('');
const [question, setQuestion] = useState('');
Expand All @@ -28,28 +29,29 @@ export function AIReplyPanel({conversationId, token, companyId, onChanged, lates
catch(e) {setError(e.message);}
finally {setBusy(false);}
}
return <section className="p-3 border-top" aria-label="Asistente IA">
<strong>Asistente IA · revisión humana obligatoria</strong>
const statuses={pending_review:ui.pendingReview,approved:ui.approvedDraft,rejected:ui.rejectedDraft,handoff:ui.humanAttention};
return <section className="p-3 border-top" aria-label={ui.aiAssistant}>
<strong>{ui.humanReview}</strong>
{error && <p role="alert" className="text-danger">{error}</p>}
<div className="d-flex gap-2 my-2">
<select aria-label="Agente" className="form-select" value={agentId} onChange={e=>setAgentId(e.target.value)} disabled={busy}>
<option value="">Seleccionar agente</option>{agents.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}
<select aria-label={ui.agent} className="form-select" value={agentId} onChange={e=>setAgentId(e.target.value)} disabled={busy}>
<option value="">{ui.selectAgent}</option>{agents.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<button className="btn btn-outline-primary" disabled={busy || !agentId} onClick={()=>run(()=>inboxService.updateControl(conversationId,'ai',Number(agentId),options))}>Asignar</button>
<button className="btn btn-outline-primary" disabled={busy || !agentId} onClick={()=>run(()=>inboxService.updateControl(conversationId,'ai',Number(agentId),options))}>{ui.assign}</button>
</div>
{!agents.length && <p>Configura un agente en Agentes IA y vincula documentos procesados.</p>}
{!agents.length && <p>{ui.configureAgentHint}</p>}
{latestInbound && <button type="button" className="btn btn-link px-0" disabled={busy}
onClick={() => setQuestion(latestInbound.content)}>{aiMessages.es.latestMessage}</button>}
<textarea className="form-control" aria-label="Pregunta para el agente" placeholder="Pregunta del cliente que quieres responder" maxLength={4000} value={question} onChange={e=>setQuestion(e.target.value)} disabled={busy}/>
<button className="btn btn-primary my-2" disabled={busy || !question.trim()} onClick={()=>run(()=>aiRequest(`/conversations/${conversationId}/ai-drafts`,options,{question}))}>{busy?'Procesando…':'Generar borrador'}</button>
onClick={() => setQuestion(latestInbound.content)}>{ui.useLatestMessage}</button>}
<textarea className="form-control" aria-label={ui.customerQuestion} placeholder={ui.customerQuestion} maxLength={4000} value={question} onChange={e=>setQuestion(e.target.value)} disabled={busy}/>
<button className="btn btn-primary my-2" disabled={busy || !question.trim()} onClick={()=>run(()=>aiRequest(`/conversations/${conversationId}/ai-drafts`,options,{question}))}>{busy?ui.processing:ui.generateDraft}</button>
{drafts.map(d=><article className="border rounded p-2 my-2" key={d.id}>
<strong>{{pending_review:'Pendiente de revisión',approved:'Aprobado · guardado en la conversación',rejected:'Rechazado',handoff:'Requiere atención humana'}[d.status]}</strong>
<strong>{statuses[d.status]}</strong>
{d.reply && <p style={{whiteSpace:'pre-wrap'}}>{d.reply}</p>}
{d.status==='handoff' && <p>{aiMessages.es.reasons[d.reason] || aiMessages.es.fallback}</p>}
{d.sources.map(s=><details key={s.chunk_id}><summary>Documento {s.document_id} · fragmento {s.chunk_index+1}</summary><p>{s.content}</p></details>)}
{d.status==='handoff' && <p>{ui.aiHandoffFallback}</p>}
{d.sources.map(s=><details key={s.chunk_id}><summary>{ui.document} {s.document_id} · {ui.chunk.toLowerCase()} {s.chunk_index+1}</summary><p>{s.content}</p></details>)}
{d.status==='pending_review' && <div className="d-flex gap-2 mt-2">
<button className="btn btn-success" disabled={busy} onClick={()=>run(()=>aiRequest(`/ai/drafts/${d.id}/decision`,options,{action:'approve'}))}>Aprobar respuesta</button>
<button className="btn btn-outline-danger" disabled={busy} onClick={()=>run(()=>aiRequest(`/ai/drafts/${d.id}/decision`,options,{action:'reject'}))}>Rechazar y atender</button>
<button className="btn btn-success" disabled={busy} onClick={()=>run(()=>aiRequest(`/ai/drafts/${d.id}/decision`,options,{action:'approve'}))}>{ui.approveResponse}</button>
<button className="btn btn-outline-danger" disabled={busy} onClick={()=>run(()=>aiRequest(`/ai/drafts/${d.id}/decision`,options,{action:'reject'}))}>{ui.rejectAndHandle}</button>
</div>}
</article>)}
</section>;
Expand Down
14 changes: 10 additions & 4 deletions src/front/components/AuthLayout.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React from "react";
import { useLanguage } from "../context/LanguageContext";
import { WorkspacePreferences } from "./WorkspacePreferences";

export const AuthLayout = ({ children }) => {
const { ui } = useLanguage();
return (
<div className="container-fluid min-vh-100 p-0 m-0">
<div className="row g-0 min-vh-100">
Expand All @@ -17,13 +20,13 @@ export const AuthLayout = ({ children }) => {

<div className="z-1 max-w-md my-auto">
<span className="badge text-purple bg-purple-subtle px-3 py-1 rounded-pill border border-purple-subtle mb-3" style={{ color: "#c084fc", backgroundColor: "rgba(59, 7, 100, 0.6)", borderColor: "rgba(88, 28, 135, 0.4)" }}>
Sistema Operativo Inteligente
{ui.intelligentOperatingSystem}
</span>
<h1 className="display-5 fw-bold tracking-tight lh-sm mb-3">
Todo tu negocio.<br />Un solo flujo.
{ui.allBusinessOneFlow}
</h1>
<p className="text-secondary small lh-base" style={{ color: "#9c3aaf" }}>
Desde la primera consulta hasta el trabajo terminado: clientes, equipo, operaciones, conocimiento y agentes de IA en una sola plataforma.
{ui.platformDescription}
</p>
</div>

Expand All @@ -33,7 +36,10 @@ export const AuthLayout = ({ children }) => {
</div>

{/* Panel Derecho (Contenido / Formularios adaptativo al navegador) */}
<div className="col-12 col-lg-6 d-flex flex-column justify-content-center align-items-center p-4 p-sm-5 overflow-y-auto bg-body text-body">
<div className="col-12 col-lg-6 d-flex flex-column justify-content-center align-items-center p-4 p-sm-5 overflow-y-auto bg-body text-body position-relative">
<div className="position-absolute top-0 end-0 p-3">
<WorkspacePreferences />
</div>
<div className="w-100 mx-auto bg-body p-4 p-sm-5 rounded-4 shadow-lg border border-opacity-10 my-auto" style={{ maxWidth: "28rem" }}>
{children}
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/front/components/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useLanguage } from "../context/LanguageContext";
export const Sidebar = ({ isOpen, onClose }) => {
const location = useLocation();
const navigate = useNavigate();
const { t } = useLanguage();
const { t, ui } = useLanguage();
const [account, setAccount] = useState({ user: null, company: null });

useEffect(() => {
Expand Down Expand Up @@ -126,7 +126,7 @@ export const Sidebar = ({ isOpen, onClose }) => {
<button
className="btn btn-link text-white-50 d-md-none text-decoration-none p-1"
onClick={onClose}
aria-label="Cerrar menú"
aria-label={ui.closeMenu}
>
<i className="fa-solid fa-xmark fs-5"></i>
</button>
Expand Down
16 changes: 9 additions & 7 deletions src/front/components/WebChat.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { webChatService } from "../services/webChatService";
import { useLanguage } from "../context/LanguageContext";

export function WebChat({ token }) {
return <ChatSession key={token} token={token} />;
}

function ChatSession({ token }) {
const { ui } = useLanguage();
const [messages, setMessages] = useState([]);
const [content, setContent] = useState("");
const [historyError, setHistoryError] = useState("");
Expand Down Expand Up @@ -134,28 +136,28 @@ function ChatSession({ token }) {
}

if (!token) {
return <p role="alert">A valid chat session is required.</p>;
return <p role="alert">{ui.chatSessionRequired}</p>;
}

return (
<section className="card p-3">
<h2 className="h5">Customer support</h2>
<h2 className="h5">{ui.webChatTitle}</h2>

{historyError && <p role="alert">{historyError}</p>}

<div
role="log"
aria-label="Conversation messages"
aria-label={ui.conversationMessages}
aria-live="polite"
className="my-3"
style={{ maxHeight: "50vh", overflowY: "auto" }}
>
{messages.length === 0 && <p>No messages yet.</p>}
{messages.length === 0 && <p>{ui.noMessages}</p>}

{messages.map((message) => (
<div key={message.id} className="border rounded p-2 mb-2">
<strong>
{message.direction === "inbound" ? "You" : "Support"}
{message.direction === "inbound" ? ui.you : ui.support}
</strong>
<p className="mb-0" style={{ whiteSpace: "pre-wrap" }}>
{message.content}
Expand All @@ -166,7 +168,7 @@ function ChatSession({ token }) {

<form onSubmit={handleSubmit}>
<label className="form-label" htmlFor="web-chat-message">
Message
{ui.messageLabel}
</label>
<textarea
id="web-chat-message"
Expand All @@ -187,7 +189,7 @@ function ChatSession({ token }) {
className="btn btn-primary mt-2"
disabled={sending || expired || !content.trim()}
>
{sending ? "Sending..." : pendingMessage.current ? "Retry send" : "Send"} </button>
{sending ? ui.sending : pendingMessage.current ? ui.retrySend : ui.send} </button>
</form>
</section>
);
Expand Down
46 changes: 45 additions & 1 deletion src/front/context/LanguageContext.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React, { createContext, useContext, useState, useEffect } from "react";
import { translations } from "../i18n/translations";
import { interfaceText, validateInterfaceMessages } from "../i18n/messages.mjs";
import { literalMessage, translateLiteral, validateLiteralTranslations } from "../i18n/literalTranslations.mjs";

const LanguageContext = createContext();
const supportedLocales = ["es", "en", "pt"];
const literalOrigins = new WeakMap();

export const LanguageProvider = ({ children }) => {
// Recupera el idioma guardado o por defecto usa español ('es')
Expand All @@ -16,6 +19,47 @@ export const LanguageProvider = ({ children }) => {
document.documentElement.lang = locale;
}, [locale]);

useEffect(() => {
if (!import.meta.env.DEV) return;
const missing = [...validateInterfaceMessages(), ...validateLiteralTranslations()];
if (missing.length) console.warn(`Missing translations: ${missing.join(", ")}`);
}, []);

useEffect(() => {
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return undefined;
const attributes = ["placeholder", "aria-label", "title"];
const translateNode = (root) => {
if (root.nodeType === Node.TEXT_NODE) {
const match = root.nodeValue.match(/^(\s*)(.*?)(\s*)$/s);
if (!match?.[2]) return;
let translations = literalOrigins.get(root);
if (!translations || !Object.values(translations).includes(match[2])) {
translations = literalMessage(match[2]);
if (translations) literalOrigins.set(root, translations);
}
const translated = translations?.[locale] || match[2];
if (translated !== match[2]) root.nodeValue = `${match[1]}${translated}${match[3]}`;
return;
}
if (root.nodeType !== Node.ELEMENT_NODE) return;
for (const attribute of attributes) {
if (root.hasAttribute(attribute)) {
const value = root.getAttribute(attribute);
const translated = translateLiteral(value, locale);
if (translated !== value) root.setAttribute(attribute, translated);
}
}
root.childNodes.forEach(translateNode);
};
translateNode(document.body);
const observer = new MutationObserver((records) => records.forEach((record) => {
if (record.type === "characterData") translateNode(record.target);
record.addedNodes.forEach(translateNode);
}));
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
return () => observer.disconnect();
}, [locale]);

// Función traductora recursiva (ej: t('agenda.title'))
const t = (path) => {
const keys = path.split(".");
Expand All @@ -38,7 +82,7 @@ export const LanguageProvider = ({ children }) => {
};

return (
<LanguageContext.Provider value={{ locale, setLocale, supportedLocales, t }}>
<LanguageContext.Provider value={{ locale, setLocale, supportedLocales, t, ui: interfaceText(locale) }}>
{children}
</LanguageContext.Provider>
);
Expand Down
Loading
Loading