diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0d1ed3..103017d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { ToastProvider, Button } from './components/ui'; import ErrorBoundary from './components/ErrorBoundary'; import { PwaInstallPrompt, OfflineBanner } from './components/PwaNotices'; import { AuthProvider, useAuth } from './lib/auth'; +import { useI18n } from './lib/i18n'; import { GlosarioProvider } from './lib/glosario'; import { SucursalesProvider } from './lib/sucursales'; import { ServiceCategoriesProvider } from './lib/service-categories'; @@ -52,17 +53,18 @@ const FullScreenLoader: FC = () => ( // Pantalla cuando el servidor no responde al arrancar (red lenta o caída): // en lugar de quedarse cargando para siempre, ofrece reintentar. -const BootErrorScreen: FC<{ onRetry: () => void }> = ({ onRetry }) => ( -
-
-

No se pudo conectar

-

- El servidor no responde en este momento. Revisa tu conexión a internet e intenta de nuevo. -

- +const BootErrorScreen: FC<{ onRetry: () => void }> = ({ onRetry }) => { + const { t } = useI18n(); + return ( +
+
+

{t('app.bootTitle')}

+

{t('app.bootBody')}

+ +
-
-); + ); +}; const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href }) => { const { user, loading, bootError, retryBoot, canSee } = useAuth(); @@ -144,6 +146,7 @@ const LiquidacionesRoute: FC = () => { const HomeRoute: FC = () => { const { user, canSee } = useAuth(); + const { t } = useI18n(); if (user?.role === 'medico') return ; if (canSee('/')) return ; for (const href of LANDING_CANDIDATOS) { @@ -151,8 +154,8 @@ const HomeRoute: FC = () => { } return (
-

Sin acceso a módulos

-

Tu usuario no tiene secciones habilitadas. Contacta al administrador.

+

{t('app.noAccessTitle')}

+

{t('app.noAccessBody')}

); }; diff --git a/frontend/src/components/ClasicoLayout.tsx b/frontend/src/components/ClasicoLayout.tsx index f027645..7b10189 100644 --- a/frontend/src/components/ClasicoLayout.tsx +++ b/frontend/src/components/ClasicoLayout.tsx @@ -37,7 +37,9 @@ import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; import GlobalSearch from './GlobalSearch'; import ThemePicker from './ThemePicker'; import NotificationBell from './NotificationBell'; +import { LanguageToggle } from './LanguageSelect'; import { useAuth, ROLE_LABELS } from '../lib/auth'; +import { useI18n, useClasicoLabel } from '../lib/i18n'; import { useClinicSettings } from '../lib/clinic-settings'; import { useChatUnread } from '../lib/chat'; import { useQuickLinks } from '../lib/quicklinks'; @@ -59,6 +61,8 @@ interface MenuChild { interface MenuEntry { label: string; + /** Etiqueta canónica (español) para estado/keys; `label` es la visible. */ + id?: string; icon: FC<{ size?: number | string; className?: string }>; href?: string; children?: MenuChild[]; @@ -146,6 +150,8 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit const location = useLocation(); const navigate = useNavigate(); const { user, canSee, hasRole, logout, brand } = useAuth(); + const { t } = useI18n(); + const cl = useClasicoLabel(); const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings(); const [sidebarOpen, setSidebarOpen] = useState(false); const chatUnread = useChatUnread(); @@ -164,8 +170,13 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit if (comisionesCfg.activo) { tree = [...tree, { label: 'Liquidaciones', icon: Receipt, href: '/comisiones/liquidaciones' }]; } - return tree; - }, [membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]); + return tree.map((e) => ({ + ...e, + id: e.label, + label: cl(e.label), + children: e.children?.map((c) => ({ ...c, label: cl(c.label) })), + })); + }, [membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, cl]); const menuLeavesT = useMemo( () => menuTreeT.flatMap((e) => e.children @@ -275,7 +286,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit const current = `${location.pathname}${location.search}`; return menuTreeT .filter((e) => e.children?.some((c) => current.startsWith(c.href.split('?')[0]) && (!c.href.includes('?') || current.includes(c.href.split('?')[1])))) - .map((e) => e.label); + .map((e) => e.id as string); }); const toggleGroup = (label: string) => @@ -296,7 +307,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit const base = c.href.split('?')[0]; return current === c.href || (current.startsWith(base) && (!c.href.includes('?') || current.includes(c.href.split('?')[1]))); })) - .map((e) => e.label); + .map((e) => e.id as string); if (!abiertos.length) return; setOpenGroups((prev) => { const faltan = abiertos.filter((l) => !prev.includes(l)); @@ -325,7 +336,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit {quickItems.length > 0 && quick.mode === 'menu' && (
-

Accesos rápidos

+

{t('nav.quickLinks')}

@@ -363,15 +374,15 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit {quick.editing && ( @@ -396,7 +407,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit const active = isActive(entry.href as string); const pinned = quick.isPinned(entry.href as string); return ( -
  • +
  • setSidebarOpen(false)} @@ -420,7 +431,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400' }`} style={{ color: pinned ? undefined : 'var(--sidebar-text)' }} - title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'} + title={pinned ? t('user.unpinQuick') : t('user.pinQuick')} > @@ -429,13 +440,13 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit } // Entry con submenú (treeview AdminLTE) - const open = openGroups.includes(entry.label); + const open = openGroups.includes(entry.id as string); const childActive = leaves.some((c) => isActive(c.href)); return ( -
  • +
  • @@ -513,18 +524,22 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit
  • {user?.name || 'Usuario'}

    -

    {user ? ROLE_LABELS[user.role] : ''}

    +

    {user ? (ROLE_LABELS[user.role] ? t(`role.${user.role}`) : (user.role_name || user.role)) : ''}

    +
    + {t('user.language')} + +
    ); @@ -548,7 +563,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit size="sm" className="lg:hidden text-white" onClick={() => setSidebarOpen(true)} - aria-label="Abrir menú" + aria-label={t('user.openMenu')} > diff --git a/frontend/src/components/GlobalSearch.tsx b/frontend/src/components/GlobalSearch.tsx index c4b0cba..b55fb74 100644 --- a/frontend/src/components/GlobalSearch.tsx +++ b/frontend/src/components/GlobalSearch.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { Search, Users, Calendar, Briefcase, Loader2, X } from 'lucide-react'; import { odooApi } from '../services/odoo'; import { cn } from '../lib/utils'; +import { useI18n } from '../lib/i18n'; interface Results { patients: { id: number; name: string; phone: string }[]; @@ -24,7 +25,9 @@ interface GlobalSearchProps { * Búsqueda global del header: UN solo campo con resultados en vivo * (debounce 250 ms) navegables con flechas + Enter. */ -export const GlobalSearch: FC = ({ placeholder = 'Buscar paciente, cita, servicio...', className = '' }) => { +export const GlobalSearch: FC = ({ placeholder, className = '' }) => { + const { t } = useI18n(); + const ph = placeholder ?? t('search.placeholder'); const navigate = useNavigate(); const [query, setQuery] = useState(''); const [results, setResults] = useState(EMPTY); @@ -155,10 +158,10 @@ export const GlobalSearch: FC = ({ placeholder = 'Buscar paci role="combobox" aria-expanded={showDropdown} aria-autocomplete="list" - aria-label="Búsqueda global" + aria-label={t('search.aria')} autoComplete="off" value={query} - placeholder={placeholder} + placeholder={ph} onChange={(e) => setQuery(e.target.value)} onFocus={() => { if (options.length > 0) setOpen(true); }} onKeyDown={onKeyDown} @@ -169,8 +172,8 @@ export const GlobalSearch: FC = ({ placeholder = 'Buscar paci @@ -323,7 +334,7 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti e.currentTarget.style.color = 'var(--text-muted)'; }} > - Más + {t('user.more')}
    @@ -356,6 +367,7 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti +
    {initials || 'US'} @@ -369,7 +381,7 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)'; }} - title="Cerrar sesión" + title={t('user.logout')} > @@ -385,7 +397,7 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti
    - Meta del mes + {t('user.goalMonth')}
    {goal && goal.goal > 0 ? ( ) : ( )}
    @@ -410,7 +422,7 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti
    - Accesos rápidos + {t('nav.quickLinks')} @@ -418,7 +430,7 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti type="button" onClick={() => quick.setEditing(!quick.editing)} style={{ color: 'var(--text-muted)' }} - title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'} + title={quick.editing ? t('dock.finishEditing') : t('dock.editQuick')} > {quick.editing ? : } @@ -456,15 +468,15 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti {quick.editing && ( @@ -476,16 +488,16 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti
    )} {visibleSections.map((section) => ( -
    +
    - {expanded.includes(section.label) && ( + {expanded.includes(section.id) && (
      {section.items.map((item) => { const Icon = item.icon; @@ -554,7 +566,7 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti
    @@ -642,18 +654,18 @@ export const HomeNestLayout: FC = ({ children, title: _title, subti setGoalModalOpen(false)} - title="Meta de ventas del mes" + title={t('user.goalTitle')} maxWidth="sm" footer={ <> - - + + } >
    -

    Define el objetivo de ventas (MXN) para el mes en curso.

    - setGoalInput(e.target.value)} placeholder="ej. 500000" /> +

    {t('user.goalBody')}

    + setGoalInput(e.target.value)} placeholder="ej. 500000" />
    diff --git a/frontend/src/components/LanguageSelect.tsx b/frontend/src/components/LanguageSelect.tsx new file mode 100644 index 0000000..947b4fd --- /dev/null +++ b/frontend/src/components/LanguageSelect.tsx @@ -0,0 +1,63 @@ +import type { FC } from 'react'; +import { Languages } from 'lucide-react'; +import { useI18n, IDIOMAS, type Locale } from '../lib/i18n'; + +// Selector de idioma. Dos variantes: +// - LanguageToggle: control segmentado ES | EN, para el pie del menú lateral. +// - LanguageSelect: desplegable, para Configuración. +// El idioma elegido persiste en localStorage (skeen_idioma) por navegador. + +export const LanguageToggle: FC<{ className?: string; color?: string }> = ({ className = '', color }) => { + const { locale, setLocale, t } = useI18n(); + return ( +
    + + {IDIOMAS.map((i) => { + const active = locale === i.code; + return ( + + ); + })} +
    + ); +}; + +export const LanguageSelect: FC<{ className?: string }> = ({ className = '' }) => { + const { locale, setLocale, t } = useI18n(); + return ( + + ); +}; + +export default LanguageToggle; diff --git a/frontend/src/components/NotificationBell.tsx b/frontend/src/components/NotificationBell.tsx index 8c4bfa0..a628991 100644 --- a/frontend/src/components/NotificationBell.tsx +++ b/frontend/src/components/NotificationBell.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Bell, Boxes, CalendarX, Clock, Target, MessageSquare } from 'lucide-react'; import { odooApi, type AppNotification } from '../services/odoo'; +import { useI18n } from '../lib/i18n'; const TIPO_ICON: Record = { stock_bajo: Boxes, @@ -24,6 +25,7 @@ interface NotificationBellProps { export const NotificationBell: FC = ({ buttonClassName }) => { const navigate = useNavigate(); + const { t } = useI18n(); const [notifications, setNotifications] = useState([]); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(true); @@ -68,8 +70,8 @@ export const NotificationBell: FC = ({ buttonClassName }) +
    )}
    @@ -56,7 +58,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left } ${align === 'right' ? 'right-0' : 'left-0'}`} >

    - Mostrar accesos rápidos + {t('dock.showModes')}

    {(Object.keys(MODE_ICONS) as QuickLinksMode[]).map((m) => { const ModeIcon = MODE_ICONS[m]; @@ -71,7 +73,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left }`} > - {QUICK_LINKS_MODE_LABELS[m]} + {t(`dock.mode.${m}`)} {selected && ●} ); @@ -90,6 +92,7 @@ export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left export const QuickLinksDock: FC = ({ items }) => { const location = useLocation(); const quick = useQuickLinks(); + const { t } = useI18n(); if (quick.mode === 'menu') return null; @@ -104,7 +107,7 @@ export const QuickLinksDock: FC = ({ items }) => { return (
    + {!collapsed && ( +
    + {t('user.language')} + +
    + )}
    ); @@ -506,7 +520,7 @@ export const SkeenLayout: FC = ({ children, title, subtitle }) => { >
    {brand.nombre} -
    @@ -524,7 +538,7 @@ export const SkeenLayout: FC = ({ children, title, subtitle }) => { className="lg:hidden" style={{ color: 'var(--text-heading)' }} onClick={() => setSidebarOpen(true)} - aria-label="Abrir menú" + aria-label={t('user.openMenu')} > @@ -540,10 +554,10 @@ export const SkeenLayout: FC = ({ children, title, subtitle }) => { onClick={hasRole('admin') ? openGoalModal : undefined} className={`hidden md:flex flex-col w-44 xl:w-56 text-left rounded-xl border px-3 py-1.5 shadow-card ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }} - title={hasRole('admin') ? 'Click para editar la meta' : 'Meta de ventas del mes'} + title={hasRole('admin') ? t('user.goalEditHint') : t('user.goalMonthHint')} >
    - Meta del mes + {t('user.goalMonth')} {Math.round(goal.pct)}%
    @@ -551,13 +565,13 @@ export const SkeenLayout: FC = ({ children, title, subtitle }) => {
    {fmtMoney(goal.current)} - de {fmtMoney(goal.goal)} + {t('user.goalOf')} {fmtMoney(goal.goal)}
    )} {goal && goal.goal <= 0 && hasRole('admin') && ( )} @@ -578,18 +592,18 @@ export const SkeenLayout: FC = ({ children, title, subtitle }) => { setGoalModalOpen(false)} - title="Meta de ventas del mes" + title={t('user.goalTitle')} maxWidth="sm" footer={ <> - - + + } >
    -

    Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.

    - setGoalInput(e.target.value)} placeholder="ej. 500000" /> +

    {t('user.goalBody')}

    + setGoalInput(e.target.value)} placeholder="ej. 500000" />
    diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx new file mode 100644 index 0000000..a9efb47 --- /dev/null +++ b/frontend/src/lib/i18n.tsx @@ -0,0 +1,1445 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import type { FC, ReactNode } from 'react'; +import type { GlossaryKey } from '../services/odoo'; +import { useClinicSettings } from './clinic-settings'; +import { useGlosario, MENU_LABEL_TERMS } from './glosario'; + +// ============================================================ +// i18n mínimo (español + inglés) sin dependencias externas. +// +// Diseño: +// - Un diccionario plano con claves namespaced ('nav.agenda', 'common.save'...). +// - El diccionario `es` contiene EXACTAMENTE los literales que usa hoy la +// interfaz: en español nada cambia (regla de oro del proyecto). +// - Resolución de `t(clave)`: diccionario del idioma -> diccionario `es` -> +// la propia clave. Nunca se ve vacío si falta una traducción; en desarrollo +// se avisa por consola cuando falta una clave en `en`. +// - Interpolación simple: t('greeting', { nombre }) reemplaza {nombre}. +// - Pluralización básica: si `vars.count` es número y existen `clave_one` / +// `clave_other`, se elige según count === 1. +// +// Persistencia: localStorage ('skeen_idioma'), elegido por el usuario en el +// menú de usuario o en Configuración. `clinic-settings.idioma_default` se lee +// como default cuando el backend lo provee (hoy no lo persiste: ver reporte). +// ============================================================ + +export type Locale = 'es' | 'en'; + +export const IDIOMA_STORAGE_KEY = 'skeen_idioma'; + +export interface IdiomaOpcion { + code: Locale; + /** Etiqueta corta para el selector (ES / EN). */ + label: string; + /** Nombre del idioma en su propio idioma. */ + nombre: string; +} + +export const IDIOMAS: IdiomaOpcion[] = [ + { code: 'es', label: 'ES', nombre: 'Español' }, + { code: 'en', label: 'EN', nombre: 'English' }, +]; + +export type Dict = Record; + +// ------------------------------------------------------------ +// Diccionario ESPAÑOL: literales actuales, copiados tal cual. +// ------------------------------------------------------------ +export const es: Dict = { + // --- comunes --- + 'common.save': 'Guardar', + 'common.cancel': 'Cancelar', + 'common.close': 'Cerrar', + 'common.delete': 'Eliminar', + 'common.edit': 'Editar', + 'common.search': 'Buscar', + 'common.all': 'Todos', + 'common.new': 'Nuevo', + 'common.loading': 'Cargando…', + 'common.export': 'Exportar', + 'common.filter': 'Filtrar', + 'common.apply': 'Aplicar', + 'common.clear': 'Limpiar', + 'common.viewAll': 'Ver todos', + 'common.actions': 'Acciones', + 'common.status': 'Estado', + 'common.date': 'Fecha', + 'common.time': 'Hora', + 'common.total': 'Total', + 'common.subtotal': 'Subtotal', + 'common.notes': 'Notas', + 'common.name': 'Nombre', + 'common.phone': 'Teléfono', + 'common.email': 'Correo', + 'common.yes': 'Sí', + 'common.no': 'No', + 'common.back': 'Atrás', + 'common.next': 'Siguiente', + 'common.accept': 'Aceptar', + 'common.select': 'Seleccionar', + 'common.optional': 'Opcional', + 'common.required': 'Obligatorio', + 'common.none': 'Ninguno', + 'common.saving': 'Guardando...', + + // --- app (arranque / sin acceso) --- + 'app.bootTitle': 'No se pudo conectar', + 'app.bootBody': 'El servidor no responde en este momento. Revisa tu conexión a internet e intenta de nuevo.', + 'app.retry': 'Reintentar', + 'app.noAccessTitle': 'Sin acceso a módulos', + 'app.noAccessBody': 'Tu usuario no tiene secciones habilitadas. Contacta al administrador.', + + // --- login --- + 'login.welcome': 'Bienvenido', + 'login.subtitle': 'Inicia sesión para continuar', + 'login.username': 'Usuario', + 'login.password': 'Contraseña', + 'login.submit': 'Entrar', + 'login.errRequired': 'Escribe usuario y contraseña', + 'login.errInvalid': 'Usuario o contraseña incorrectos', + 'login.errGeneric': 'No se pudo iniciar sesión. Intenta de nuevo.', + 'login.bookOnline': 'Reservar en línea', + 'login.showPassword': 'Mostrar contraseña', + 'login.hidePassword': 'Ocultar contraseña', + + // --- PWA / avisos --- + 'pwa.installTitle': 'Instalar la app', + 'pwa.installHint': 'Accede más rápido desde tu pantalla de inicio.', + 'pwa.iosStepPrefix': 'Toca', + 'pwa.iosStepSuffix': 'Compartir y luego «Añadir a pantalla de inicio».', + 'pwa.install': 'Instalar', + 'pwa.dismiss': 'Descartar', + 'pwa.offline': 'Sin conexión — los cambios se guardarán al recuperar la red', + + // --- accesos rápidos (dock) --- + 'dock.aria': 'Accesos rápidos', + 'dock.hint': 'Fija accesos con la ★ del menú', + 'dock.changeMode': 'Cambiar presentación de accesos rápidos', + 'dock.showModes': 'Mostrar accesos rápidos', + 'dock.mode.menu': 'En el menú lateral', + 'dock.mode.right': 'Barra de iconos a la derecha', + 'dock.mode.bottom': 'Barra de iconos abajo', + 'dock.finishEditing': 'Terminar edición', + 'dock.editQuick': 'Editar accesos rápidos', + 'dock.up': 'Subir', + 'dock.down': 'Bajar', + 'dock.remove': 'Quitar', + + // --- navegación (menú lateral) --- + 'nav.group.principal': 'Principal', + 'nav.group.clinica': 'Clínica', + 'nav.group.operaciones': 'Operaciones', + 'nav.group.analitica': 'Analítica', + 'nav.group.analiticaMas': 'Analítica y más', + 'nav.group.comunicacion': 'Comunicación', + 'nav.group.wacrm': 'WACRM', + 'nav.quickLinks': 'Accesos rápidos', + 'nav.dashboard': 'Dashboard', + 'nav.agenda': 'Agenda', + 'nav.consultas': 'Consultas', + 'nav.mySales': 'Mis ventas', + 'nav.pos': 'Punto de Venta', + 'nav.sales': 'Ventas', + 'nav.tickets': 'Tickets', + 'nav.payments': 'Pagos', + 'nav.debts': 'Adeudos', + 'nav.wallet': 'Monedero', + 'nav.inventory': 'Inventario', + 'nav.purchases': 'Compras', + 'nav.cashCuts': 'Cortes de Caja', + 'nav.reports': 'Reportes', + 'nav.commissions': 'Comisiones', + 'nav.agendaHours': 'Horas agenda', + 'nav.exportData': 'Exportar datos', + 'nav.config': 'Configuración', + 'nav.chat': 'Chat', + 'nav.birthdays': 'Cumpleañeros', + 'nav.messages': 'Mensajes', + 'nav.leads': 'Leads', + 'nav.memberships': 'Membresías', + 'nav.liquidations': 'Liquidaciones', + 'nav.packages': 'Paquetes', + + // --- menú del tema "clásico" (etiquetas propias) --- + 'clasico.inicio': 'Inicio', + 'clasico.visitas': 'Visitas', + 'clasico.consultasMedicas': 'Consultas Médicas', + 'clasico.expedientes': 'Expedientes', + 'clasico.medicos': 'Médicos', + 'clasico.cosmetologia': 'Cosmetología', + 'clasico.clinica': 'Clínica', + 'clasico.usuarios': 'Usuarios', + 'clasico.recetas': 'Recetas', + 'clasico.diagnosticos': 'Diagnósticos', + 'clasico.procedimientos': 'Procedimientos médicos', + 'clasico.administrativo': 'Administrativo', + 'clasico.movDiario': 'Mov. diario', + 'clasico.cortesCaja': 'Cortes de caja', + 'clasico.ingresos': 'Ingresos', + 'clasico.exportarCorreos': 'Exportar correos', + 'clasico.adeudosClientes': 'Adeudos clientes', + 'clasico.pagosServicios': 'Pagos | Servicios', + 'clasico.pagosClientes': 'Pagos | Clientes', + 'clasico.devoluciones': 'Devoluciones', + 'clasico.vendedores': 'Vendedores', + 'clasico.recomendaciones': 'Recomendaciones', + 'clasico.concentrado': 'Concentrado', + 'clasico.topVentas': 'Top ventas', + 'clasico.monedero': 'Monedero', + 'clasico.monederos': 'Monederos', + 'clasico.reportePuntos': 'Reporte puntos', + 'clasico.comunicacion': 'Comunicación', + 'clasico.crm': 'CRM', + 'clasico.comprobantes': 'Comprobantes del día', + 'clasico.consumibles': 'Consumibles', + 'clasico.inventarioActual': 'Inventario actual', + 'clasico.bajas': 'Bajas', + 'clasico.ordenesCompra': 'Órdenes de compra', + 'clasico.ventasDia': 'Ventas del día', + 'clasico.clientes': 'Clientes', + + // --- usuario / layout --- + 'user.logout': 'Cerrar sesión', + 'user.expandMenu': 'Expandir menú', + 'user.collapseMenu': 'Colapsar menú', + 'user.openMenu': 'Abrir menú', + 'user.closeMenu': 'Cerrar menú', + 'user.pinQuick': 'Fijar en accesos rápidos', + 'user.unpinQuick': 'Quitar de accesos rápidos', + 'user.language': 'Idioma', + 'user.more': 'Más', + 'user.goalTitle': 'Meta de ventas del mes', + 'user.goalMonth': 'Meta del mes', + 'user.goalOf': 'de', + 'user.goalDefine': 'Definir meta', + 'user.goalSave': 'Guardar meta', + 'user.goalField': 'Meta mensual (MXN)', + 'user.goalBody': 'Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.', + 'user.goalEditHint': 'Click para editar la meta', + 'user.goalMonthHint': 'Meta de ventas del mes', + 'search.placeholder': 'Buscar paciente, cita, servicio...', + 'search.aria': 'Búsqueda global', + 'search.clear': 'Limpiar búsqueda', + 'search.noResults': 'Sin resultados para "{q}". Prueba con otro nombre o teléfono.', + 'search.hintKeys': 'Usa ↑ ↓ para moverte y Enter para abrir', + 'search.group.Pacientes': 'Pacientes', + 'search.group.Citas próximas': 'Citas próximas', + 'search.group.Servicios': 'Servicios', + 'notif.title': 'Notificaciones', + 'notif.loading': 'Cargando...', + 'notif.empty': 'Sin notificaciones', + 'role.admin': 'Administrador', + 'role.medico': 'Médico', + 'role.recepcion': 'Recepción', + 'role.enfermeria': 'Enfermería', + 'role.cosmetologia': 'Cosmetología', + 'role.psicologa': 'Psicóloga', + 'role.farmacia': 'Farmacia', + 'role.gerencia': 'Gerencia', + 'role.marketing': 'Marketing', + 'role.lectura': 'Lectura', + + // --- dashboard --- + 'dashboard.title': 'Dashboard', + 'dashboard.subtitleBrand': 'Panel de control de {brand}', + 'dashboard.subtitle': 'Resumen general de la clínica', + 'dashboard.welcome': 'Bienvenido', + 'dashboard.welcomeUser': 'Bienvenido, {name}', + 'dashboard.welcomeBack': 'Bienvenido de vuelta', + 'dashboard.theClinic': 'la clínica', + 'dashboard.newAppointmentWord': 'Nueva', + 'dashboard.checking': 'Consultando…', + 'dashboard.stateDone': 'Completadas', + 'dashboard.stateConfirmed': 'Confirmadas', + 'dashboard.stateCancelled': 'Canceladas', + 'dashboard.ariaUpcoming': 'Ver próximas citas en la agenda', + 'dashboard.ariaWaiting': 'Ver pacientes en sala de espera', + 'dashboard.ariaCash': 'Ir a cortes de caja', + 'dashboard.ariaCharges': 'Ver cobros del día en ventas', + 'dashboard.openPatient': 'Abrir ficha del paciente', + 'dashboard.vip': 'VIP', + 'dashboard.heroText': 'Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.', + 'dashboard.heroTextNest': 'Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con calidez y confianza.', + 'dashboard.viewReports': 'Ver reportes', + 'dashboard.newAppointment': 'Nueva cita', + 'dashboard.todayTitle': 'Hoy en la clínica', + 'dashboard.upcoming': 'Próximas citas', + 'dashboard.noUpcoming': 'Sin citas activas para hoy.', + 'dashboard.waitingRoom': 'Sala de espera', + 'dashboard.waiting_one': 'paciente en espera', + 'dashboard.waiting_other': 'pacientes en espera', + 'dashboard.notAvailable': 'no disponible', + 'dashboard.cash': 'Caja', + 'dashboard.cashOpen': 'Abierta', + 'dashboard.cashClosed': 'Cerrada', + 'dashboard.cashNone': 'Sin abrir', + 'dashboard.noCashCut': 'Sin corte de caja hoy', + 'dashboard.dayCharges': 'Cobros del día', + 'dashboard.completedPayments': 'pagos completados hoy', + 'dashboard.statAppointments': 'Citas Hoy', + 'dashboard.statPatients': 'Pacientes', + 'dashboard.statRevenue': 'Ingresos Mes', + 'dashboard.statSales': 'Ventas Hoy', + 'dashboard.statPending': 'Pendientes', + 'dashboard.statWallet': 'Puntos Monedero', + 'dashboard.thisMonth': '{n} este mes', + 'dashboard.newPatients': '+{n} nuevos', + 'dashboard.vsPrevMonth': 'vs mes anterior', + 'dashboard.vsYesterday': 'vs ayer', + 'dashboard.birthdaysTitle': 'Cumpleaños de hoy', + 'dashboard.birthdaysSub': 'Pacientes que cumplen años hoy — contáctalos', + 'dashboard.noBirthdays': 'Ningún paciente cumple años hoy.', + 'dashboard.turning': 'cumple {age}', + 'dashboard.noDoctor': 'Sin médico', + 'dashboard.chartWeeklyTitle': 'Actividad semanal', + 'dashboard.chartWeeklySub': 'Citas e ingresos de los últimos 7 días', + 'dashboard.noDataTitle': 'Sin datos', + 'dashboard.noWeeklySub': 'No hay información para la gráfica semanal.', + 'dashboard.upcomingTodaySub': 'Citas programadas para hoy', + 'dashboard.noAppointmentsTitle': 'Sin citas', + 'dashboard.noAppointmentsSub': 'No hay citas programadas para hoy.', + 'dashboard.topServicesTitle': 'Top servicios del mes', + 'dashboard.topServicesSub': 'Por ingresos generados', + 'dashboard.noMonthSalesSub': 'No hay ventas en el mes.', + 'dashboard.attendanceTitle': 'Asistencia del mes', + 'dashboard.noMonthAppointmentsSub': 'No hay citas en el mes.', + 'dashboard.featuredTitle': 'Servicios destacados', + 'dashboard.featuredSub': 'Catálogo activo de tratamientos', + 'dashboard.noServicesTitle': 'Sin servicios', + 'dashboard.noServicesSub': 'No hay servicios registrados.', + 'dashboard.recentTitle': 'Actividad reciente', + 'dashboard.recentSub': 'Últimos movimientos en la clínica', + 'dashboard.noActivityTitle': 'Sin actividad', + 'dashboard.noActivitySub': 'No hay movimientos recientes.', + + // --- configuración (pestañas) --- + 'config.title': 'Configuración', + 'config.tabs.clinica': 'Clínica', + 'config.tabs.reservas': 'Reservas en línea', + 'config.tabs.notificaciones': 'Notificaciones', + 'config.tabs.notifEnviadas': 'Notificaciones enviadas', + 'config.tabs.formularios': 'Formularios', + 'config.tabs.sucursales': 'Sucursales', + 'config.tabs.categorias': 'Categorías', + 'config.tabs.recursos': 'Recursos', + 'config.tabs.membresias': 'Membresías', + 'config.tabs.compras': 'Compras', + 'config.tabs.comisiones': 'Comisiones', + 'config.tabs.terminologia': 'Terminología', + 'config.tabs.usuarios': 'Usuarios', + 'config.tabs.roles': 'Roles', + 'config.tabs.recetas': 'Recetas', + 'config.tabs.catalogos': 'Catálogos', + 'config.tabs.tickets': 'Tickets', + 'config.tabs.licencia': 'Licencia', + 'config.tabs.api': 'API', + 'config.tabs.webhooks': 'Webhooks', + 'config.tabs.auditoria': 'Auditoría', + 'config.tabs.diagnosticos': 'Diagnósticos', + 'config.tabs.procedimientos': 'Procedimientos', + 'config.subtitle': 'Ajustes de la clínica', + 'config.pageSubtitle': 'Información y ajustes de la clínica', + 'config.noPermission': 'No tienes permiso para ninguna sección de Configuración.', + 'config.settings': 'Ajustes', + 'config.clinicInfo': 'Información de la clínica', + 'config.clinicIdentity': 'Identidad de la clínica', + 'config.exchangeRate': 'Tipo de cambio USD / MXN', + 'config.commissions': 'Comisiones', + 'config.useThemeColor': 'Usar el color del tema', + 'config.updateRate': 'Actualizar TC', + + // --- reportes --- + 'reports.title': 'Reportes', + 'reports.subtitle': 'Análisis del mes y día', + 'reports.opSubtitle': 'Análisis de la operación', + 'reports.export': 'Exportar', + 'reports.exportData': 'Exportar datos', + 'reports.exportDataTitle': 'Exportar listas de pacientes (correos, VIP, etiquetas)', + 'reports.period': 'Periodo', + 'reports.start': 'Inicio', + 'reports.end': 'Fin', + 'reports.effectiveDay': 'Día efectivo', + 'reports.favAdd': 'Fijar en favoritos', + 'reports.favRemove': 'Quitar de favoritos', + 'reports.family.dinero': 'Dinero', + 'reports.family.dinero.hint': 'Ingresos, cobros y cartera', + 'reports.family.clientes': 'Clientes', + 'reports.family.clientes.hint': 'Quién compra y cuánto', + 'reports.family.operacion': 'Operación', + 'reports.family.operacion.hint': 'Agenda, inventario y servicios', + 'reports.family.equipo': 'Equipo', + 'reports.family.equipo.hint': 'Rendimiento del personal', + 'reports.preset.hoy': 'Hoy', + 'reports.preset.ayer': 'Ayer', + 'reports.preset.semana': 'Esta semana', + 'reports.preset.mes': 'Este mes', + 'reports.preset.mes_pasado': 'Mes pasado', + 'reports.preset.anio': 'Este año', + 'reports.preset.custom': 'Personalizado', + 'reports.tab.general': 'General', + 'reports.tab.general.desc': 'Resumen del periodo, citas y comisiones', + 'reports.tab.diario': 'Movimientos diarios', + 'reports.tab.diario.desc': 'Citas, ventas, cobros e inventario del día', + 'reports.tab.ingresos': 'Ingresos', + 'reports.tab.ingresos.desc': 'Ventas y formas de pago', + 'reports.tab.impuestos': 'Impuestos', + 'reports.tab.impuestos.desc': 'Base e impuesto del periodo por mes, servicio y sucursal', + 'reports.tab.cortes': 'Cortes de caja', + 'reports.tab.cortes.desc': 'Arqueos y cierres por día', + 'reports.tab.adeudos': 'Adeudos', + 'reports.tab.adeudos.desc': 'Cartera por cobrar y planes de pago', + 'reports.tab.devoluciones': 'Devoluciones', + 'reports.tab.devoluciones.desc': 'Ventas devueltas y montos', + 'reports.tab.top-clientes': 'Top clientes', + 'reports.tab.top-clientes.desc': 'Pacientes con mayor consumo', + 'reports.tab.pagos-clientes': 'Pagos · Clientes', + 'reports.tab.pagos-clientes.desc': 'Cobros agrupados por paciente', + 'reports.tab.recomendaciones': 'Recomendaciones', + 'reports.tab.recomendaciones.desc': 'Pacientes que llegan recomendados', + 'reports.tab.paquetes': 'Paquetes', + 'reports.tab.paquetes.desc': 'Ventas y avance de paquetes', + 'reports.tab.membresias': 'Membresías', + 'reports.tab.membresias.desc': 'Altas, ingresos recurrentes y sesiones consumidas', + 'reports.tab.horas-agenda': 'Horas agenda', + 'reports.tab.horas-agenda.desc': 'Citas atendidas por hora y médico', + 'reports.tab.recursos': 'Recursos', + 'reports.tab.recursos.desc': 'Ocupación de cabinas, salas y equipos', + 'reports.tab.inventario': 'Inventario', + 'reports.tab.inventario.desc': 'Existencias, mínimos y caducidades', + 'reports.tab.compras': 'Compras', + 'reports.tab.compras.desc': 'Gasto por periodo, proveedor y artículo', + 'reports.tab.margen': 'Margen', + 'reports.tab.margen.desc': 'Margen por artículo: precio menos costo promedio', + 'reports.tab.pagos-servicios': 'Pagos · Servicios', + 'reports.tab.pagos-servicios.desc': 'Cobros agrupados por servicio', + 'reports.tab.concentrado': 'Concentrado', + 'reports.tab.concentrado.desc': 'Todo el periodo en una tabla', + 'reports.tab.comisiones': 'Comisiones', + 'reports.tab.comisiones.desc': 'Comisión por médico en el periodo', + 'reports.tab.vendedores': 'Vendedores', + 'reports.tab.vendedores.desc': 'Ventas atribuidas por vendedor', + 'reports.tab.kpis': 'KPIs', + 'reports.tab.kpis.desc': 'No-show, ocupación y conversión', + 'reports.tab.exportar': 'Exportar', + 'reports.tab.exportar.desc': 'Exportar datos', + + // --- páginas públicas (reservas / formularios) --- + 'public.unavailableTitle': 'Reservas en línea no disponibles', + 'public.unavailableBody': 'Por el momento no puedes agendar desde aquí. Comunícate directamente con la clínica.', + 'public.others': 'Otros', + 'public.errName': 'Escribe tu nombre', + 'public.errPhone': 'Escribe un teléfono válido (10 dígitos)', + 'public.errGeneric': 'No se pudo completar la reserva. Intenta de nuevo.', + 'public.requestReceived': 'Solicitud recibida', + 'public.confirmed': '¡Reserva confirmada!', + 'public.folioWord': 'Folio', + 'public.addCalendar': 'Agregar al calendario', + 'public.manage': 'Ver o cancelar mi reserva', + 'public.willConfirm': 'Tu reserva quedó registrada y la clínica la confirmará en breve.', + 'public.saveLink': 'Guarda este enlace para gestionar tu reserva', + 'public.copied': 'Copiado', + 'public.copy': 'Copiar', + 'public.stepService': 'Elige tu {term}', + 'public.stepBranch': 'Elige {term}', + 'public.stepDoctor': 'Elige {term}', + 'public.stepDateTime': 'Elige fecha y hora', + 'public.stepData': 'Tus datos', + 'public.anyAvailable': 'Cualquiera disponible', + 'public.noSlots': 'No hay horarios disponibles ese día. Prueba con otra fecha.', + 'public.continue': 'Continuar', + 'public.fullName': 'Nombre completo', + 'public.phoneWhatsapp': 'Teléfono (WhatsApp)', + 'public.emailOptional': 'Email (opcional)', + 'public.notesOptional': 'Notas (opcional)', + 'public.honeypot': 'No llenar este campo', + 'public.requestBooking': 'Solicitar reserva', + 'public.confirmBooking': 'Confirmar reserva', + 'public.doubts': '¿Dudas?', + 'public.form.errSend': 'No se pudo enviar. Intenta de nuevo.', + 'public.form.errSendNet': 'No se pudo enviar. Revisa tu conexión e intenta de nuevo.', + 'public.form.unavailableTitle': 'Formulario no disponible', + 'public.form.unavailableBody': 'El enlace no es válido o ya expiró. Pide uno nuevo a la clínica.', + 'public.form.thanks': '¡Gracias!', + 'public.form.thanksName': '¡Gracias, {name}!', + 'public.form.registered': 'Tu formulario quedó registrado.', + 'public.form.alreadyFilled': 'Este formulario ya había sido llenado.', + 'public.form.canClose': 'Ya puedes cerrar esta página.', + 'public.form.form': 'Formulario', + 'public.form.takesMinutes': 'Tarda unos {n} minutos.', + 'public.form.legalRecord': 'Al enviar aceptas que tus datos y tu firma se guarden en el {record} de la clínica.', + 'public.form.sending': 'Enviando…', + 'public.form.submit': 'Enviar formulario', + 'public.form.errSignature': 'Escribe el nombre de quien firma', + 'public.manage.invalidLink': 'Enlace no válido', + 'public.manage.notFound': 'No encontramos esta reserva.', + 'public.manage.cancelError': 'No se pudo cancelar la reserva.', + 'public.manage.expired': 'El enlace no es válido o ya expiró.', + 'public.manage.notFoundTitle': 'No encontramos tu reserva', + 'public.manage.makeBooking': 'Hacer una reserva', + 'public.manage.bookAnother': 'Reservar otra cita', + 'public.manage.title': 'Tu reserva', + 'public.manage.cancelledOk': 'Tu reserva quedó cancelada. Si fue un error, puedes agendar de nuevo.', + 'public.manage.cancelMine': 'Cancelar mi reserva', + 'public.manage.confirmQuestion': '¿Seguro que quieres cancelar? Puedes dejarnos un motivo (opcional).', + 'public.manage.reason': 'Motivo (opcional)', + 'public.manage.yesCancel': 'Sí, cancelar', + 'public.manage.cannotCancel': 'Esta reserva ya no se puede cancelar en línea', + 'public.manage.leadTime': ' (con menos de {h} h de anticipación)', + 'public.manage.contactPhone': ' Comunícate al {phone}.', + 'public.manage.contactClinic': ' Comunícate con la clínica.', + + // --- POS --- + 'pos.title': 'Punto de Venta', + 'pos.subtitle': 'Arma el ticket y cobra en un solo paso', + 'pos.method.cash': 'Efectivo', + 'pos.method.card': 'Tarjeta', + 'pos.method.transfer': 'Transferencia', + 'pos.method.credit': 'Saldo a favor', + 'pos.methodLabel': 'Método de pago', + 'pos.points': 'Puntos', + 'pos.discount': 'Descuento', + 'pos.taxIncluded': ' (incluido)', + 'pos.chargeAmount': 'Cobrar {amount}', + 'pos.chargeTitle': 'Cobrar (F9)', + 'pos.noSellPerm': 'Sin permiso para vender', + 'pos.noDiscountPerm': 'Sin permiso para aplicar descuentos', + 'pos.setRate': 'Configura el tipo de cambio en Configuración', + 'pos.add': 'Agregar', + 'pos.addToTicket': 'Agregar al ticket', + 'pos.emptyTicket': 'Vaciar ticket', + 'pos.confirmEmptyTicket': '¿Vaciar el ticket?', + 'pos.shortcutsHint': 'F2 buscar · F9 cobrar · +/− cantidad · Esc cerrar', + 'pos.viewShortcuts': 'Ver atajos', + 'pos.searchService': 'Buscar servicio... (F2)', + 'pos.searchPackage': 'Buscar paquete... (F2)', + 'pos.searchItem': 'Buscar artículo... (F2)', + 'pos.searchPatient': 'Buscar paciente por nombre o teléfono...', + 'pos.nameRequired': 'Nombre *', + 'pos.phoneRequired': 'Teléfono *', + 'pos.received': 'Recibido', + 'pos.receivedUsd': 'Recibido (USD)', + 'pos.change': 'Cambio: ', + 'pos.initialCash': 'Efectivo inicial (fondo de caja)', + 'pos.unitPrice': 'Precio unitario', + + // --- pacientes --- + 'patients.subtitle': 'Directorio de pacientes', + 'patients.pageSubtitle': 'Busca, crea y gestiona pacientes', + 'patients.exportCsv': 'Exportar CSV', + 'patients.newWord': 'Nuevo', + 'patients.noExportPerm': 'Sin permiso para exportar', + 'patients.searchPlaceholder': 'Buscar por nombre, apellidos o teléfono...', + 'patients.clearSearch': 'Limpiar búsqueda', + 'patients.allDoctors': 'Todos los médicos', + 'patients.filterRecent': 'Visitas últimos 6 meses', + 'patients.filterStale': 'Sin visita +6 meses', + 'patients.filterIncomplete': 'Expediente incompleto', + 'patients.clearFilters': 'Quitar filtros', + 'patients.emptyTitle': 'Sin pacientes', + 'patients.emptySub': 'No se encontraron pacientes.', + 'patients.colName': 'Nombre', + 'patients.colRecord': 'Expediente', + 'patients.colPhone': 'Teléfono', + 'patients.colAge': 'Edad', + 'patients.colLastVisit': 'Última visita', + 'patients.colPoints': 'Puntos', + 'patients.colActions': 'Acciones', + 'patients.colService': 'Servicio', + 'patients.colQty': 'Cant.', + 'patients.colPrice': 'Precio', + 'patients.complete': 'Expediente completo', + 'patients.missingRecord': 'Falta: {list}', + 'patients.unassigned': 'Sin asignar', + 'patients.tabInfo': 'Información general', + 'patients.tabClinical': 'Historia clínica', + 'patients.tabTimeline': 'Historial', + 'patients.tabDocs': 'Documentos', + 'patients.tabForms': 'Formularios', + 'patients.scannedRecord': 'Expediente escaneado', + + // --- agenda --- + 'agenda.subtitle': 'Gestión de citas', + 'agenda.pageSubtitle': 'Filtra por fecha, estado o médico', + 'agenda.waitlist': 'Lista de espera', + 'agenda.closeAgendas': 'Cerrar agendas', + 'agenda.express': 'Valoración express', + 'agenda.newAppointmentWord': 'Nueva', + 'agenda.newAppointment': 'Nueva {term}', + 'agenda.editAppointment': 'Editar {term}', + 'agenda.freeSlots': 'Espacios libres', + 'agenda.filterFirstTime': 'Primera vez', + 'agenda.filterCheckin': 'Check-in', + 'agenda.filterNoShow': 'No show', + 'agenda.filterToConfirm': 'Por confirmar', + 'agenda.filterCancelled': 'Canceló', + 'agenda.filterWaitlist': 'En lista', + 'agenda.stateAll': 'Todos los estados', + 'agenda.state.pending': 'Agendada', + 'agenda.state.confirmed': 'Confirmada', + 'agenda.state.arrived': 'En {room}', + 'agenda.state.inProgress': 'En consulta', + 'agenda.state.done': 'Completada', + 'agenda.state.cancelled': 'Canceló', + 'agenda.state.noShow': 'No se presentó', + 'agenda.state.waitlist': 'Lista de espera', + 'agenda.quickMode': 'Cita rápida (solo nombre y teléfono)', + 'agenda.quickModeHint': 'Si el teléfono ya existe, se reutiliza ese paciente; si no, se crea con datos mínimos.', + 'agenda.nameField': 'Nombre *', + 'agenda.namePlaceholder': 'Nombre del paciente', + 'agenda.phoneField': 'Teléfono *', + 'agenda.phonePlaceholder': 'Teléfono del paciente', + 'agenda.saveChanges': 'Guardar cambios', + 'agenda.saveQuick': 'Guardar cita rápida', + 'agenda.saveAppointment': 'Guardar cita', + 'agenda.expressTitle': 'Valoración express', + 'agenda.rescheduleTitle': 'Reagendar cita', + 'agenda.detailTitle': 'Detalle de la cita', + 'agenda.conflictTitle': 'Conflicto de horario', + 'agenda.understood': 'Entendido', + 'agenda.back': 'Volver', + 'agenda.noDoctorsTitle': 'Sin médicos', + 'agenda.noDoctorsSub': 'No hay médicos dados de alta para mostrar en la vista por médico.', + 'agenda.noDoctorsSubHour': 'No hay médicos dados de alta para mostrar en la vista por hora.', + 'agenda.noDoctorsSubWeek': 'No hay médicos dados de alta para mostrar en la vista semanal.', + 'agenda.noResourcesTitle': 'Sin recursos', + 'agenda.noResourcesSub': 'Agrega recursos en Configuración › Recursos para ver esta vista.', + 'config.idiomaTitle': 'Idioma de la interfaz', + 'config.idiomaBody': 'Elige el idioma con el que se muestra el sistema en este navegador.', +}; + +// ------------------------------------------------------------ +// Diccionario INGLÉS. +// ------------------------------------------------------------ +export const en: Dict = { + // --- common --- + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.close': 'Close', + 'common.delete': 'Delete', + 'common.edit': 'Edit', + 'common.search': 'Search', + 'common.all': 'All', + 'common.new': 'New', + 'common.loading': 'Loading…', + 'common.export': 'Export', + 'common.filter': 'Filter', + 'common.apply': 'Apply', + 'common.clear': 'Clear', + 'common.viewAll': 'View all', + 'common.actions': 'Actions', + 'common.status': 'Status', + 'common.date': 'Date', + 'common.time': 'Time', + 'common.total': 'Total', + 'common.subtotal': 'Subtotal', + 'common.notes': 'Notes', + 'common.name': 'Name', + 'common.phone': 'Phone', + 'common.email': 'Email', + 'common.yes': 'Yes', + 'common.no': 'No', + 'common.back': 'Back', + 'common.next': 'Next', + 'common.accept': 'OK', + 'common.select': 'Select', + 'common.optional': 'Optional', + 'common.required': 'Required', + 'common.none': 'None', + 'common.saving': 'Saving...', + + // --- app --- + 'app.bootTitle': 'Could not connect', + 'app.bootBody': 'The server is not responding right now. Check your internet connection and try again.', + 'app.retry': 'Retry', + 'app.noAccessTitle': 'No module access', + 'app.noAccessBody': 'Your user has no sections enabled. Contact the administrator.', + + // --- login --- + 'login.welcome': 'Welcome', + 'login.subtitle': 'Sign in to continue', + 'login.username': 'Username', + 'login.password': 'Password', + 'login.submit': 'Sign in', + 'login.errRequired': 'Enter username and password', + 'login.errInvalid': 'Incorrect username or password', + 'login.errGeneric': 'Could not sign in. Please try again.', + 'login.bookOnline': 'Book online', + 'login.showPassword': 'Show password', + 'login.hidePassword': 'Hide password', + + // --- PWA --- + 'pwa.installTitle': 'Install the app', + 'pwa.installHint': 'Open it faster from your home screen.', + 'pwa.iosStepPrefix': 'Tap', + 'pwa.iosStepSuffix': 'Share and then “Add to Home Screen”.', + 'pwa.install': 'Install', + 'pwa.dismiss': 'Dismiss', + 'pwa.offline': 'Offline — changes will be saved once the network returns', + + // --- quick links dock --- + 'dock.aria': 'Quick links', + 'dock.hint': 'Pin links with the ★ in the menu', + 'dock.changeMode': 'Change quick links layout', + 'dock.showModes': 'Show quick links', + 'dock.mode.menu': 'In the side menu', + 'dock.mode.right': 'Icon bar on the right', + 'dock.mode.bottom': 'Icon bar at the bottom', + 'dock.finishEditing': 'Finish editing', + 'dock.editQuick': 'Edit quick links', + 'dock.up': 'Move up', + 'dock.down': 'Move down', + 'dock.remove': 'Remove', + + // --- nav --- + 'nav.group.principal': 'Main', + 'nav.group.clinica': 'Clinic', + 'nav.group.operaciones': 'Operations', + 'nav.group.analitica': 'Analytics', + 'nav.group.analiticaMas': 'Analytics & more', + 'nav.group.comunicacion': 'Communication', + 'nav.group.wacrm': 'WACRM', + 'nav.quickLinks': 'Quick links', + 'nav.dashboard': 'Dashboard', + 'nav.agenda': 'Schedule', + 'nav.consultas': 'Consultations', + 'nav.mySales': 'My sales', + 'nav.pos': 'Point of Sale', + 'nav.sales': 'Sales', + 'nav.tickets': 'Tickets', + 'nav.payments': 'Payments', + 'nav.debts': 'Debts', + 'nav.wallet': 'Wallet', + 'nav.inventory': 'Inventory', + 'nav.purchases': 'Purchases', + 'nav.cashCuts': 'Cash Register', + 'nav.reports': 'Reports', + 'nav.commissions': 'Commissions', + 'nav.agendaHours': 'Schedule hours', + 'nav.exportData': 'Export data', + 'nav.config': 'Settings', + 'nav.chat': 'Chat', + 'nav.birthdays': 'Birthdays', + 'nav.messages': 'Messages', + 'nav.leads': 'Leads', + 'nav.memberships': 'Memberships', + 'nav.liquidations': 'Settlements', + 'nav.packages': 'Packages', + + // --- classic theme menu labels --- + 'clasico.inicio': 'Home', + 'clasico.visitas': 'Visits', + 'clasico.consultasMedicas': 'Medical consultations', + 'clasico.expedientes': 'Records', + 'clasico.medicos': 'Doctors', + 'clasico.cosmetologia': 'Cosmetology', + 'clasico.clinica': 'Clinic', + 'clasico.usuarios': 'Users', + 'clasico.recetas': 'Prescriptions', + 'clasico.diagnosticos': 'Diagnoses', + 'clasico.procedimientos': 'Medical procedures', + 'clasico.administrativo': 'Administrative', + 'clasico.movDiario': 'Daily movements', + 'clasico.cortesCaja': 'Cash closings', + 'clasico.ingresos': 'Revenue', + 'clasico.exportarCorreos': 'Export emails', + 'clasico.adeudosClientes': 'Client debts', + 'clasico.pagosServicios': 'Payments | Services', + 'clasico.pagosClientes': 'Payments | Clients', + 'clasico.devoluciones': 'Refunds', + 'clasico.vendedores': 'Sellers', + 'clasico.recomendaciones': 'Referrals', + 'clasico.concentrado': 'Summary', + 'clasico.topVentas': 'Top sales', + 'clasico.monedero': 'Wallet', + 'clasico.monederos': 'Wallets', + 'clasico.reportePuntos': 'Points report', + 'clasico.comunicacion': 'Communication', + 'clasico.crm': 'CRM', + 'clasico.comprobantes': 'Daily receipts', + 'clasico.consumibles': 'Consumables', + 'clasico.inventarioActual': 'Current inventory', + 'clasico.bajas': 'Write-offs', + 'clasico.ordenesCompra': 'Purchase orders', + 'clasico.ventasDia': 'Daily sales', + 'clasico.clientes': 'Clients', + + // --- user / layout --- + 'user.logout': 'Sign out', + 'user.expandMenu': 'Expand menu', + 'user.collapseMenu': 'Collapse menu', + 'user.openMenu': 'Open menu', + 'user.closeMenu': 'Close menu', + 'user.pinQuick': 'Pin to quick links', + 'user.unpinQuick': 'Remove from quick links', + 'user.language': 'Language', + 'user.more': 'More', + 'user.goalTitle': 'Monthly sales goal', + 'user.goalMonth': 'Monthly goal', + 'user.goalOf': 'of', + 'user.goalDefine': 'Set goal', + 'user.goalSave': 'Save goal', + 'user.goalField': 'Monthly goal (MXN)', + 'user.goalBody': 'Set the sales target (MXN) for the current month. The header bar shows progress in real time.', + 'user.goalEditHint': 'Click to edit the goal', + 'user.goalMonthHint': 'Monthly sales goal', + 'search.placeholder': 'Search patient, appointment, service...', + 'search.aria': 'Global search', + 'search.clear': 'Clear search', + 'search.noResults': 'No results for "{q}". Try another name or phone.', + 'search.hintKeys': 'Use ↑ ↓ to move and Enter to open', + 'search.group.Pacientes': 'Patients', + 'search.group.Citas próximas': 'Upcoming appointments', + 'search.group.Servicios': 'Services', + 'notif.title': 'Notifications', + 'notif.loading': 'Loading...', + 'notif.empty': 'No notifications', + 'role.admin': 'Administrator', + 'role.medico': 'Doctor', + 'role.recepcion': 'Front desk', + 'role.enfermeria': 'Nursing', + 'role.cosmetologia': 'Cosmetology', + 'role.psicologa': 'Psychologist', + 'role.farmacia': 'Pharmacy', + 'role.gerencia': 'Management', + 'role.marketing': 'Marketing', + 'role.lectura': 'Read only', + + // --- dashboard --- + 'dashboard.title': 'Dashboard', + 'dashboard.subtitleBrand': 'Control panel for {brand}', + 'dashboard.subtitle': 'Clinic overview', + 'dashboard.welcome': 'Welcome', + 'dashboard.welcomeUser': 'Welcome, {name}', + 'dashboard.welcomeBack': 'Welcome back', + 'dashboard.theClinic': 'the clinic', + 'dashboard.newAppointmentWord': 'New', + 'dashboard.checking': 'Checking…', + 'dashboard.stateDone': 'Completed', + 'dashboard.stateConfirmed': 'Confirmed', + 'dashboard.stateCancelled': 'Cancelled', + 'dashboard.ariaUpcoming': 'View upcoming appointments in the schedule', + 'dashboard.ariaWaiting': 'View patients in the waiting room', + 'dashboard.ariaCash': 'Go to cash register', + 'dashboard.ariaCharges': 'View the day charges in sales', + 'dashboard.openPatient': 'Open patient record', + 'dashboard.vip': 'VIP', + 'dashboard.heroText': 'Science, aesthetics and personalized care in every treatment. Manage your clinic with confidence.', + 'dashboard.heroTextNest': 'Science, aesthetics and personalized care in every treatment. Manage your clinic with warmth and confidence.', + 'dashboard.viewReports': 'View reports', + 'dashboard.newAppointment': 'New appointment', + 'dashboard.todayTitle': 'Today at the clinic', + 'dashboard.upcoming': 'Upcoming appointments', + 'dashboard.noUpcoming': 'No active appointments for today.', + 'dashboard.waitingRoom': 'Waiting room', + 'dashboard.waiting_one': 'patient waiting', + 'dashboard.waiting_other': 'patients waiting', + 'dashboard.notAvailable': 'not available', + 'dashboard.cash': 'Register', + 'dashboard.cashOpen': 'Open', + 'dashboard.cashClosed': 'Closed', + 'dashboard.cashNone': 'Not opened', + 'dashboard.noCashCut': 'No cash closing today', + 'dashboard.dayCharges': 'Day charges', + 'dashboard.completedPayments': 'payments completed today', + 'dashboard.statAppointments': 'Appointments Today', + 'dashboard.statPatients': 'Patients', + 'dashboard.statRevenue': 'Revenue This Month', + 'dashboard.statSales': 'Sales Today', + 'dashboard.statPending': 'Pending', + 'dashboard.statWallet': 'Wallet Points', + 'dashboard.thisMonth': '{n} this month', + 'dashboard.newPatients': '+{n} new', + 'dashboard.vsPrevMonth': 'vs previous month', + 'dashboard.vsYesterday': 'vs yesterday', + 'dashboard.birthdaysTitle': 'Birthdays today', + 'dashboard.birthdaysSub': 'Patients with a birthday today — reach out to them', + 'dashboard.noBirthdays': 'No patient has a birthday today.', + 'dashboard.turning': 'turns {age}', + 'dashboard.noDoctor': 'No doctor', + 'dashboard.chartWeeklyTitle': 'Weekly activity', + 'dashboard.chartWeeklySub': 'Appointments and revenue for the last 7 days', + 'dashboard.noDataTitle': 'No data', + 'dashboard.noWeeklySub': 'No information for the weekly chart.', + 'dashboard.upcomingTodaySub': 'Appointments scheduled for today', + 'dashboard.noAppointmentsTitle': 'No appointments', + 'dashboard.noAppointmentsSub': 'No appointments scheduled for today.', + 'dashboard.topServicesTitle': 'Top services this month', + 'dashboard.topServicesSub': 'By revenue generated', + 'dashboard.noMonthSalesSub': 'No sales this month.', + 'dashboard.attendanceTitle': 'Monthly attendance', + 'dashboard.noMonthAppointmentsSub': 'No appointments this month.', + 'dashboard.featuredTitle': 'Featured services', + 'dashboard.featuredSub': 'Active treatment catalog', + 'dashboard.noServicesTitle': 'No services', + 'dashboard.noServicesSub': 'No services registered.', + 'dashboard.recentTitle': 'Recent activity', + 'dashboard.recentSub': 'Latest movements in the clinic', + 'dashboard.noActivityTitle': 'No activity', + 'dashboard.noActivitySub': 'No recent movements.', + + // --- config --- + 'config.title': 'Settings', + 'config.tabs.clinica': 'Clinic', + 'config.tabs.reservas': 'Online booking', + 'config.tabs.notificaciones': 'Notifications', + 'config.tabs.notifEnviadas': 'Sent notifications', + 'config.tabs.formularios': 'Forms', + 'config.tabs.sucursales': 'Branches', + 'config.tabs.categorias': 'Categories', + 'config.tabs.recursos': 'Resources', + 'config.tabs.membresias': 'Memberships', + 'config.tabs.compras': 'Purchases', + 'config.tabs.comisiones': 'Commissions', + 'config.tabs.terminologia': 'Terminology', + 'config.tabs.usuarios': 'Users', + 'config.tabs.roles': 'Roles', + 'config.tabs.recetas': 'Prescriptions', + 'config.tabs.catalogos': 'Catalogs', + 'config.tabs.tickets': 'Tickets', + 'config.tabs.licencia': 'License', + 'config.tabs.api': 'API', + 'config.tabs.webhooks': 'Webhooks', + 'config.tabs.auditoria': 'Audit log', + 'config.tabs.diagnosticos': 'Diagnoses', + 'config.tabs.procedimientos': 'Procedures', + 'config.subtitle': 'Clinic settings', + 'config.pageSubtitle': 'Clinic information and settings', + 'config.noPermission': 'You do not have permission for any Settings section.', + 'config.settings': 'Settings', + 'config.clinicInfo': 'Clinic information', + 'config.clinicIdentity': 'Clinic identity', + 'config.exchangeRate': 'Exchange rate USD / MXN', + 'config.commissions': 'Commissions', + 'config.useThemeColor': 'Use theme color', + 'config.updateRate': 'Update rate', + + // --- reports --- + 'reports.title': 'Reports', + 'reports.subtitle': 'Month and day analysis', + 'reports.opSubtitle': 'Operation analysis', + 'reports.export': 'Export', + 'reports.exportData': 'Export data', + 'reports.exportDataTitle': 'Export patient lists (emails, VIP, labels)', + 'reports.period': 'Period', + 'reports.start': 'Start', + 'reports.end': 'End', + 'reports.effectiveDay': 'Effective day', + 'reports.favAdd': 'Pin to favorites', + 'reports.favRemove': 'Remove from favorites', + 'reports.family.dinero': 'Money', + 'reports.family.dinero.hint': 'Revenue, charges and receivables', + 'reports.family.clientes': 'Clients', + 'reports.family.clientes.hint': 'Who buys and how much', + 'reports.family.operacion': 'Operation', + 'reports.family.operacion.hint': 'Schedule, inventory and services', + 'reports.family.equipo': 'Team', + 'reports.family.equipo.hint': 'Staff performance', + 'reports.preset.hoy': 'Today', + 'reports.preset.ayer': 'Yesterday', + 'reports.preset.semana': 'This week', + 'reports.preset.mes': 'This month', + 'reports.preset.mes_pasado': 'Last month', + 'reports.preset.anio': 'This year', + 'reports.preset.custom': 'Custom', + 'reports.tab.general': 'General', + 'reports.tab.general.desc': 'Period summary, appointments and commissions', + 'reports.tab.diario': 'Daily movements', + 'reports.tab.diario.desc': 'Appointments, sales, charges and inventory for the day', + 'reports.tab.ingresos': 'Revenue', + 'reports.tab.ingresos.desc': 'Sales and payment methods', + 'reports.tab.impuestos': 'Taxes', + 'reports.tab.impuestos.desc': 'Base and tax for the period by month, service and branch', + 'reports.tab.cortes': 'Cash closings', + 'reports.tab.cortes.desc': 'Daily counts and closings', + 'reports.tab.adeudos': 'Debts', + 'reports.tab.adeudos.desc': 'Accounts receivable and payment plans', + 'reports.tab.devoluciones': 'Refunds', + 'reports.tab.devoluciones.desc': 'Returned sales and amounts', + 'reports.tab.top-clientes': 'Top clients', + 'reports.tab.top-clientes.desc': 'Patients with the highest spend', + 'reports.tab.pagos-clientes': 'Payments · Clients', + 'reports.tab.pagos-clientes.desc': 'Charges grouped by patient', + 'reports.tab.recomendaciones': 'Referrals', + 'reports.tab.recomendaciones.desc': 'Patients who arrive by referral', + 'reports.tab.paquetes': 'Packages', + 'reports.tab.paquetes.desc': 'Package sales and progress', + 'reports.tab.membresias': 'Memberships', + 'reports.tab.membresias.desc': 'Sign-ups, recurring revenue and sessions used', + 'reports.tab.horas-agenda': 'Schedule hours', + 'reports.tab.horas-agenda.desc': 'Appointments by hour and doctor', + 'reports.tab.recursos': 'Resources', + 'reports.tab.recursos.desc': 'Room and equipment occupancy', + 'reports.tab.inventario': 'Inventory', + 'reports.tab.inventario.desc': 'Stock, minimums and expirations', + 'reports.tab.compras': 'Purchases', + 'reports.tab.compras.desc': 'Spending by period, supplier and item', + 'reports.tab.margen': 'Margin', + 'reports.tab.margen.desc': 'Margin per item: price minus average cost', + 'reports.tab.pagos-servicios': 'Payments · Services', + 'reports.tab.pagos-servicios.desc': 'Charges grouped by service', + 'reports.tab.concentrado': 'Summary', + 'reports.tab.concentrado.desc': 'The whole period in one table', + 'reports.tab.comisiones': 'Commissions', + 'reports.tab.comisiones.desc': 'Commission per doctor in the period', + 'reports.tab.vendedores': 'Sellers', + 'reports.tab.vendedores.desc': 'Sales attributed per seller', + 'reports.tab.kpis': 'KPIs', + 'reports.tab.kpis.desc': 'No-show, occupancy and conversion', + 'reports.tab.exportar': 'Export', + 'reports.tab.exportar.desc': 'Export data', + + // --- public pages (booking / forms) --- + 'public.unavailableTitle': 'Online booking unavailable', + 'public.unavailableBody': 'You cannot book from here right now. Please contact the clinic directly.', + 'public.others': 'Others', + 'public.errName': 'Enter your name', + 'public.errPhone': 'Enter a valid phone number (10 digits)', + 'public.errGeneric': 'Could not complete the booking. Please try again.', + 'public.requestReceived': 'Request received', + 'public.confirmed': 'Booking confirmed!', + 'public.folioWord': 'Reference', + 'public.addCalendar': 'Add to calendar', + 'public.manage': 'View or cancel my booking', + 'public.willConfirm': 'Your booking was registered and the clinic will confirm it shortly.', + 'public.saveLink': 'Save this link to manage your booking', + 'public.copied': 'Copied', + 'public.copy': 'Copy', + 'public.stepService': 'Choose your {term}', + 'public.stepBranch': 'Choose {term}', + 'public.stepDoctor': 'Choose {term}', + 'public.stepDateTime': 'Choose date and time', + 'public.stepData': 'Your details', + 'public.anyAvailable': 'Anyone available', + 'public.noSlots': 'No available times that day. Try another date.', + 'public.continue': 'Continue', + 'public.fullName': 'Full name', + 'public.phoneWhatsapp': 'Phone (WhatsApp)', + 'public.emailOptional': 'Email (optional)', + 'public.notesOptional': 'Notes (optional)', + 'public.honeypot': 'Do not fill this field', + 'public.requestBooking': 'Request booking', + 'public.confirmBooking': 'Confirm booking', + 'public.doubts': 'Questions?', + 'public.form.errSend': 'Could not send. Please try again.', + 'public.form.errSendNet': 'Could not send. Check your connection and try again.', + 'public.form.unavailableTitle': 'Form unavailable', + 'public.form.unavailableBody': 'The link is invalid or has expired. Ask the clinic for a new one.', + 'public.form.thanks': 'Thank you!', + 'public.form.thanksName': 'Thank you, {name}!', + 'public.form.registered': 'Your form was submitted.', + 'public.form.alreadyFilled': 'This form had already been filled out.', + 'public.form.canClose': 'You can close this page now.', + 'public.form.form': 'Form', + 'public.form.takesMinutes': 'Takes about {n} minutes.', + 'public.form.legalRecord': 'By submitting you agree that your data and signature are stored in the clinic {record}.', + 'public.form.sending': 'Sending…', + 'public.form.submit': 'Submit form', + 'public.form.errSignature': 'Enter the name of the signer', + 'public.manage.invalidLink': 'Invalid link', + 'public.manage.notFound': 'We could not find this booking.', + 'public.manage.cancelError': 'Could not cancel the booking.', + 'public.manage.expired': 'The link is invalid or has expired.', + 'public.manage.notFoundTitle': 'We could not find your booking', + 'public.manage.makeBooking': 'Make a booking', + 'public.manage.bookAnother': 'Book another appointment', + 'public.manage.title': 'Your booking', + 'public.manage.cancelledOk': 'Your booking was cancelled. If it was a mistake, you can book again.', + 'public.manage.cancelMine': 'Cancel my booking', + 'public.manage.confirmQuestion': 'Are you sure you want to cancel? You can leave us a reason (optional).', + 'public.manage.reason': 'Reason (optional)', + 'public.manage.yesCancel': 'Yes, cancel', + 'public.manage.cannotCancel': 'This booking can no longer be cancelled online', + 'public.manage.leadTime': ' (less than {h} h in advance)', + 'public.manage.contactPhone': ' Contact us at {phone}.', + 'public.manage.contactClinic': ' Contact the clinic.', + + // --- POS --- + 'pos.title': 'Point of Sale', + 'pos.subtitle': 'Build the ticket and charge in one step', + 'pos.method.cash': 'Cash', + 'pos.method.card': 'Card', + 'pos.method.transfer': 'Transfer', + 'pos.method.credit': 'Store credit', + 'pos.methodLabel': 'Payment method', + 'pos.points': 'Points', + 'pos.discount': 'Discount', + 'pos.taxIncluded': ' (included)', + 'pos.chargeAmount': 'Charge {amount}', + 'pos.chargeTitle': 'Charge (F9)', + 'pos.noSellPerm': 'No permission to sell', + 'pos.noDiscountPerm': 'No permission to apply discounts', + 'pos.setRate': 'Set the exchange rate in Settings', + 'pos.add': 'Add', + 'pos.addToTicket': 'Add to ticket', + 'pos.emptyTicket': 'Empty ticket', + 'pos.confirmEmptyTicket': 'Empty the ticket?', + 'pos.shortcutsHint': 'F2 search · F9 charge · +/− quantity · Esc close', + 'pos.viewShortcuts': 'View shortcuts', + 'pos.searchService': 'Search service... (F2)', + 'pos.searchPackage': 'Search package... (F2)', + 'pos.searchItem': 'Search item... (F2)', + 'pos.searchPatient': 'Search patient by name or phone...', + 'pos.nameRequired': 'Name *', + 'pos.phoneRequired': 'Phone *', + 'pos.received': 'Received', + 'pos.receivedUsd': 'Received (USD)', + 'pos.change': 'Change: ', + 'pos.initialCash': 'Opening cash (register float)', + 'pos.unitPrice': 'Unit price', + + // --- patients --- + 'patients.subtitle': 'Patient directory', + 'patients.pageSubtitle': 'Search, create and manage patients', + 'patients.exportCsv': 'Export CSV', + 'patients.newWord': 'New', + 'patients.noExportPerm': 'No permission to export', + 'patients.searchPlaceholder': 'Search by name, surname or phone...', + 'patients.clearSearch': 'Clear search', + 'patients.allDoctors': 'All doctors', + 'patients.filterRecent': 'Visits in the last 6 months', + 'patients.filterStale': 'No visit in +6 months', + 'patients.filterIncomplete': 'Incomplete record', + 'patients.clearFilters': 'Clear filters', + 'patients.emptyTitle': 'No patients', + 'patients.emptySub': 'No patients found.', + 'patients.colName': 'Name', + 'patients.colRecord': 'Record', + 'patients.colPhone': 'Phone', + 'patients.colAge': 'Age', + 'patients.colLastVisit': 'Last visit', + 'patients.colPoints': 'Points', + 'patients.colActions': 'Actions', + 'patients.colService': 'Service', + 'patients.colQty': 'Qty', + 'patients.colPrice': 'Price', + 'patients.complete': 'Record complete', + 'patients.missingRecord': 'Missing: {list}', + 'patients.unassigned': 'Unassigned', + 'patients.tabInfo': 'General information', + 'patients.tabClinical': 'Clinical history', + 'patients.tabTimeline': 'History', + 'patients.tabDocs': 'Documents', + 'patients.tabForms': 'Forms', + 'patients.scannedRecord': 'Scanned record', + + // --- agenda --- + 'agenda.subtitle': 'Appointment management', + 'agenda.pageSubtitle': 'Filter by date, status or doctor', + 'agenda.waitlist': 'Waitlist', + 'agenda.closeAgendas': 'Close schedules', + 'agenda.express': 'Express assessment', + 'agenda.newAppointmentWord': 'New', + 'agenda.newAppointment': 'New {term}', + 'agenda.editAppointment': 'Edit {term}', + 'agenda.freeSlots': 'Free slots', + 'agenda.filterFirstTime': 'First visit', + 'agenda.filterCheckin': 'Check-in', + 'agenda.filterNoShow': 'No show', + 'agenda.filterToConfirm': 'To confirm', + 'agenda.filterCancelled': 'Cancelled', + 'agenda.filterWaitlist': 'On waitlist', + 'agenda.stateAll': 'All statuses', + 'agenda.state.pending': 'Scheduled', + 'agenda.state.confirmed': 'Confirmed', + 'agenda.state.arrived': 'In {room}', + 'agenda.state.inProgress': 'In consultation', + 'agenda.state.done': 'Completed', + 'agenda.state.cancelled': 'Cancelled', + 'agenda.state.noShow': 'No-show', + 'agenda.state.waitlist': 'Waitlist', + 'agenda.quickMode': 'Quick appointment (name and phone only)', + 'agenda.quickModeHint': 'If the phone already exists, that patient is reused; otherwise a new one is created with minimal data.', + 'agenda.nameField': 'Name *', + 'agenda.namePlaceholder': 'Patient name', + 'agenda.phoneField': 'Phone *', + 'agenda.phonePlaceholder': 'Patient phone', + 'agenda.saveChanges': 'Save changes', + 'agenda.saveQuick': 'Save quick appointment', + 'agenda.saveAppointment': 'Save appointment', + 'agenda.expressTitle': 'Express assessment', + 'agenda.rescheduleTitle': 'Reschedule appointment', + 'agenda.detailTitle': 'Appointment details', + 'agenda.conflictTitle': 'Schedule conflict', + 'agenda.understood': 'Understood', + 'agenda.back': 'Back', + 'agenda.noDoctorsTitle': 'No doctors', + 'agenda.noDoctorsSub': 'No doctors are registered to show in the by-doctor view.', + 'agenda.noDoctorsSubHour': 'No doctors are registered to show in the by-hour view.', + 'agenda.noDoctorsSubWeek': 'No doctors are registered to show in the weekly view.', + 'agenda.noResourcesTitle': 'No resources', + 'agenda.noResourcesSub': 'Add resources in Settings › Resources to see this view.', + 'config.idiomaTitle': 'Interface language', + 'config.idiomaBody': 'Choose the language the system displays in this browser.', +}; + +export const DICT: Record = { es, en }; + +// ------------------------------------------------------------ +// Glosario ↔ idioma +// +// Regla: el glosario define el VOCABULARIO DEL NEGOCIO (paciente↔cliente, +// cita↔reserva...) y el idioma define el RESTO del texto. Los packs del +// glosario viven en español, así que: +// - En español: se usa el término del pack configurado (manda el glosario). +// - En inglés: se usa el término base en inglés (abajo), porque no hay +// pack en inglés; si algún día existiera, se preferiría ese. +// ------------------------------------------------------------ +export const GLOSARIO_EN: Record = { + patient_singular: 'Patient', + patient_plural: 'Patients', + appointment_singular: 'Appointment', + appointment_plural: 'Appointments', + visit_singular: 'Visit', + visit_plural: 'Visits', + record_singular: 'Record', + record_plural: 'Records', + doctor_singular: 'Doctor', + doctor_plural: 'Doctors', + staff_singular: 'Staff', + staff_plural: 'Staff', + branch_singular: 'Branch', + branch_plural: 'Branches', + room_singular: 'Room', + room_plural: 'Rooms', + service_singular: 'Service', + service_plural: 'Services', + package_singular: 'Package', + package_plural: 'Packages', + prescription_singular: 'Prescription', + prescription_plural: 'Prescriptions', + treatment_singular: 'Treatment', + treatment_plural: 'Treatments', +}; + +// Etiquetas del menú lateral y sus grupos -> clave i18n. +export const MENU_LABEL_KEYS: Record = { // grupos + 'Principal': 'nav.group.principal', + 'Clínica': 'nav.group.clinica', + 'Operaciones': 'nav.group.operaciones', + 'Analítica': 'nav.group.analitica', + 'Comunicación': 'nav.group.comunicacion', + 'WACRM': 'nav.group.wacrm', + // items + 'Dashboard': 'nav.dashboard', + 'Agenda': 'nav.agenda', + 'Consultas': 'nav.consultas', + 'Mis ventas': 'nav.mySales', + 'Punto de Venta': 'nav.pos', + 'Ventas': 'nav.sales', + 'Tickets': 'nav.tickets', + 'Pagos': 'nav.payments', + 'Adeudos': 'nav.debts', + 'Monedero': 'nav.wallet', + 'Inventario': 'nav.inventory', + 'Compras': 'nav.purchases', + 'Cortes de Caja': 'nav.cashCuts', + 'Reportes': 'nav.reports', + 'Comisiones': 'nav.commissions', + 'Horas agenda': 'nav.agendaHours', + 'Exportar datos': 'nav.exportData', + 'Configuración': 'nav.config', + 'Chat': 'nav.chat', + 'Cumpleañeros': 'nav.birthdays', + 'Mensajes': 'nav.messages', + 'Leads': 'nav.leads', + 'Membresías': 'nav.memberships', + 'Liquidaciones': 'nav.liquidations', +}; + +// Etiquetas del tema "clásico" -> clave i18n (reutiliza nav.* cuando coincide). +export const CLASICO_LABEL_KEYS: Record = { + 'Inicio': 'clasico.inicio', + 'Agenda': 'nav.agenda', + 'Visitas': 'clasico.visitas', + 'Consultas Médicas': 'clasico.consultasMedicas', + 'Expedientes': 'clasico.expedientes', + 'Médicos': 'clasico.medicos', + 'Cosmetología': 'clasico.cosmetologia', + 'Paquetes': 'nav.packages', + 'Configuración': 'nav.config', + 'Clínica': 'clasico.clinica', + 'Usuarios': 'clasico.usuarios', + 'Recetas': 'clasico.recetas', + 'Diagnósticos': 'clasico.diagnosticos', + 'Procedimientos médicos': 'clasico.procedimientos', + 'Reportes': 'nav.reports', + 'Mov. diario': 'clasico.movDiario', + 'Cortes de caja': 'clasico.cortesCaja', + 'Ingresos': 'clasico.ingresos', + 'Inventario': 'nav.inventory', + 'Comisiones': 'nav.commissions', + 'Exportar correos': 'clasico.exportarCorreos', + 'Administrativo': 'clasico.administrativo', + 'Adeudos clientes': 'clasico.adeudosClientes', + 'Pagos | Servicios': 'clasico.pagosServicios', + 'Pagos | Clientes': 'clasico.pagosClientes', + 'Devoluciones': 'clasico.devoluciones', + 'Vendedores': 'clasico.vendedores', + 'Recomendaciones': 'clasico.recomendaciones', + 'Concentrado': 'clasico.concentrado', + 'Top ventas': 'clasico.topVentas', + 'Horas agenda': 'nav.agendaHours', + 'Monedero': 'clasico.monedero', + 'Monederos': 'clasico.monederos', + 'Reporte puntos': 'clasico.reportePuntos', + 'Comunicación': 'clasico.comunicacion', + 'Chat': 'nav.chat', + 'Cumpleañeros': 'nav.birthdays', + 'Mensajes': 'nav.messages', + 'CRM': 'clasico.crm', + 'Comprobantes del día': 'clasico.comprobantes', + 'Consumibles': 'clasico.consumibles', + 'Inventario actual': 'clasico.inventarioActual', + 'Compras': 'nav.purchases', + 'Bajas': 'clasico.bajas', + 'Órdenes de compra': 'clasico.ordenesCompra', + 'Ventas del día': 'clasico.ventasDia', + 'Tickets': 'nav.tickets', + 'Punto de Venta': 'nav.pos', + 'Clientes': 'clasico.clientes', + 'Mis ventas': 'nav.mySales', + 'Membresías': 'nav.memberships', + 'Liquidaciones': 'nav.liquidations', +}; + +// ------------------------------------------------------------ +// Motor de traducción +// ------------------------------------------------------------ +export type TFunc = (key: string, vars?: Record) => string; + +const interpolar = (texto: string, vars?: Record): string => { + if (!vars) return texto; + return texto.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)); +}; + +export function translate(locale: Locale, key: string, vars?: Record): string { + const dict = DICT[locale] || es; + let raw: string | undefined; + // Pluralización básica: clave_one / clave_other. + if (vars && typeof vars.count === 'number' && dict[`${key}_one`] !== undefined) { + raw = vars.count === 1 ? dict[`${key}_one`] : (dict[`${key}_other`] ?? es[`${key}_one`]); + } + if (raw === undefined) raw = dict[key]; + if (raw === undefined) raw = es[key]; + if (raw === undefined) { + if (import.meta.env.DEV && locale === 'en') { + // eslint-disable-next-line no-console + console.warn(`[i18n] falta la clave '${key}' en el diccionario 'en'`); + } + return key; + } + return interpolar(raw, vars); +} + +// ------------------------------------------------------------ +// Provider / hook +// ------------------------------------------------------------ +interface I18nContextValue { + locale: Locale; + setLocale: (l: Locale) => void; + t: TFunc; + idiomas: IdiomaOpcion[]; +} + +const I18nContext = createContext(undefined); + +const esLocaleValido = (v: unknown): v is Locale => v === 'es' || v === 'en'; + +const leerIdiomaInicial = (): { locale: Locale; explicito: boolean } => { + try { + const raw = localStorage.getItem(IDIOMA_STORAGE_KEY); + if (esLocaleValido(raw)) return { locale: raw, explicito: true }; + } catch { + /* localStorage bloqueado */ + } + return { locale: 'es', explicito: false }; +}; + +export const I18nProvider: FC<{ children: ReactNode }> = ({ children }) => { + const inicial = useMemo(leerIdiomaInicial, []); + const [locale, setLocaleState] = useState(inicial.locale); + const clinic = useClinicSettings(); + + const setLocale = useCallback((l: Locale) => { + setLocaleState(l); + try { + localStorage.setItem(IDIOMA_STORAGE_KEY, l); + } catch { + /* localStorage bloqueado: se mantiene en memoria */ + } + }, []); + + // Default por clínica: solo aplica si el usuario/visitante no eligió aún. + useEffect(() => { + if (inicial.explicito) return; + const def = (clinic as { idioma_default?: string }).idioma_default; + if (esLocaleValido(def) && def !== locale) setLocaleState(def); + }, [clinic, inicial.explicito, locale]); + + // refleja el idioma activo. + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.lang = locale; + } + }, [locale]); + + const t = useCallback((key, vars) => translate(locale, key, vars), [locale]); + + const value = useMemo( + () => ({ locale, setLocale, t, idiomas: IDIOMAS }), + [locale, setLocale, t], + ); + + return {children}; +}; + +export const useI18n = (): I18nContextValue => { + const ctx = useContext(I18nContext); + if (!ctx) throw new Error('useI18n debe usarse dentro de I18nProvider'); + return ctx; +}; + +/** + * Etiqueta del menú lateral traducida: los términos de negocio los resuelve el + * glosario (español) o el término base en inglés; el resto, el diccionario. + */ +export const useNavLabel = (): ((label: string) => string) => { + const { t, locale } = useI18n(); + const { term } = useGlosario(); + return useCallback( + (label: string): string => { + // Un término de negocio ya traducido por el glosario vuelve a caer aquí + // (p. ej. 'Pacientes'); es idempotente y en inglés da el término base. + const termKey = MENU_LABEL_TERMS[label]; + if (termKey) return locale === 'en' ? GLOSARIO_EN[termKey] : term(termKey); + const key = MENU_LABEL_KEYS[label]; + return key ? t(key) : label; + }, + [t, locale, term], + ); +}; + +/** Etiqueta del menú del tema "clásico" traducida al idioma activo. */ +export const useClasicoLabel = (): ((label: string) => string) => { + const { t } = useI18n(); + return useCallback( + (label: string): string => { + const key = CLASICO_LABEL_KEYS[label]; + return key ? t(key) : label; + }, + [t], + ); +}; + +/** + * Término de negocio (glosario) resuelto según el idioma: en español manda el + * pack configurado; en inglés se usa el término base en inglés. + */ +export const useBusinessTerm = (): ((key: GlossaryKey) => string) => { + const { locale } = useI18n(); + const { term } = useGlosario(); + return useCallback( + (key: GlossaryKey): string => (locale === 'en' ? GLOSARIO_EN[key] : term(key)), + [locale, term], + ); +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 43a2cfe..c6b9ec2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { ThemeProvider } from './lib/theme.tsx' +import { I18nProvider } from './lib/i18n.tsx' // PWA: registra el service worker solo en producción (en dev rompería el HMR) // y nunca deja que un fallo del SW rompa el arranque de la app. @@ -19,7 +20,9 @@ if (import.meta.env.PROD && 'serviceWorker' in navigator) { createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index f05d8dd..4ef2113 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -29,7 +29,7 @@ import { import { useSearchParams, useNavigate } from 'react-router-dom'; import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, type GlossaryKey, type Notificacion, AGENDA_PERM_ITEMS } from '../services/odoo'; import { useAuth } from '../lib/auth'; -import { useGlosario } from '../lib/glosario'; +import { useI18n, useBusinessTerm } from '../lib/i18n'; import { useClinicSettings } from '../lib/clinic-settings'; import { useSucursales } from '../lib/sucursales'; import { useRecursos } from '../lib/recursos'; @@ -72,8 +72,19 @@ export const APT_STATE_LABELS: Record = { }; /** Etiqueta de estado de cita traducida ('En cabina' sigue el término de sala). */ -export const aptStateLabel = (state: string, term: (key: GlossaryKey) => string): string => - state === 'arrived' ? `En ${term('room_singular').toLowerCase()}` : (APT_STATE_LABELS[state] || state); +export const aptStateLabel = ( + state: string, + term: (key: GlossaryKey) => string, + t?: (key: string, vars?: Record) => string, +): string => { + const room = term('room_singular').toLowerCase(); + const key = + state === 'no_show' ? 'agenda.state.noShow' + : state === 'in_progress' ? 'agenda.state.inProgress' + : `agenda.state.${state}`; + if (t) return state === 'arrived' ? t('agenda.state.arrived', { room }) : t(key); + return state === 'arrived' ? `En ${room}` : (APT_STATE_LABELS[state] || state); +}; const stateOptions = [ { value: '', label: 'Todos los estados' }, @@ -161,7 +172,8 @@ const Agenda: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const { user, canAgenda, can } = useAuth(); - const { term } = useGlosario(); + const term = useBusinessTerm(); + const { t } = useI18n(); const { sucursales, defaultCode } = useSucursales(); const { recursos } = useRecursos(); const clinicSettings = useClinicSettings(); @@ -1365,7 +1377,7 @@ const Agenda: FC = () => { } }; - const aptStateLabels = (state: string) => aptStateLabel(state, term); + const aptStateLabels = (state: string) => aptStateLabel(state, term, t); const handlePhoneConfirm = async (apt: Appointment, confirmed: boolean) => { try { @@ -1885,12 +1897,12 @@ const Agenda: FC = () => { ); return ( - - + + {puedeAgendar && ( )} @@ -1966,7 +1978,7 @@ const Agenda: FC = () => { setQuickForm({ ...quickForm, name: e.target.value })} /> @@ -3252,7 +3264,7 @@ const Agenda: FC = () => { setExpressOpen(false)} - title="Valoración express" + title={t('agenda.expressTitle')} maxWidth="md" footer={ <> @@ -3333,7 +3345,7 @@ const Agenda: FC = () => { setDetailApt(null)} - title={editMode ? 'Reagendar cita' : 'Detalle de la cita'} + title={editMode ? t('agenda.rescheduleTitle') : t('agenda.detailTitle')} maxWidth="md" footer={ editMode ? ( @@ -3613,7 +3625,7 @@ const Agenda: FC = () => { setConflict(null)} - title="Conflicto de horario" + title={t('agenda.conflictTitle')} maxWidth="sm" footer={ <> diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx index 4937330..42011d6 100644 --- a/frontend/src/pages/Configuracion.tsx +++ b/frontend/src/pages/Configuracion.tsx @@ -28,6 +28,8 @@ import { Card, Button, Input, PageHeader, toast } from '../components/ui'; import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo'; import { useAuth } from '../lib/auth'; import { publicarClinicSettings } from '../lib/clinic-settings'; +import { useI18n } from '../lib/i18n'; +import { LanguageSelect } from '../components/LanguageSelect'; const TABS = [ { key: 'clinica', label: 'Clínica' }, @@ -67,6 +69,9 @@ const LEGACY_STORAGE_KEY = 'skeen_clinic_settings'; const Configuracion: FC = () => { const { user, brand, setBrand, can } = useAuth(); + const { t } = useI18n(); + // Clave i18n de una pestaña: 'notif_enviadas' -> 'config.tabs.notifEnviadas'. + const tabKey = (k: string) => 'config.tabs.' + k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); // Gerencia gestiona catálogos, usuarios y roles; la sección "Clínica" (datos // de contacto, tipo de cambio y comisiones) queda para administración del sistema. const esAdmin = user?.role === 'admin'; @@ -321,21 +326,21 @@ const Configuracion: FC = () => { }; return ( - - + + {/* Tabs de secciones */}
    - {tabsVisibles.map((t) => ( + {tabsVisibles.map((tabItem) => ( ))}
    @@ -343,7 +348,7 @@ const Configuracion: FC = () => { {tabsVisibles.length === 0 ? ( -

    No tienes permiso para ninguna sección de Configuración.

    +

    {t('config.noPermission')}

    ) : tab === 'usuarios' ? ( @@ -381,11 +386,11 @@ const Configuracion: FC = () => { subcatalogo === s.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg' }`} > - {s.label} + {t(`config.tabs.${s.key}`)} ))} - + ) : tab === 'sucursales' ? ( @@ -461,10 +466,20 @@ const Configuracion: FC = () => { ) : tab === 'webhooks' ? ( ) : ( + <> + + +

    {t('config.idiomaTitle')}

    +

    {t('config.idiomaBody')}

    +
    + +
    +
    +
    -

    Información de la clínica

    +

    {t('config.clinicInfo')}

    {

    - Ajustes + {t('config.settings')}

    {loading ? (

    Cargando...

    @@ -693,7 +708,7 @@ const Configuracion: FC = () => { {esAdmin && ( -

    Identidad de la clínica

    +

    {t('config.clinicIdentity')}

    Nombre, pie de página y logos que aparecen en el inicio de sesión, el menú y los documentos impresos. Dejar un campo vacío vuelve al valor por omisión. @@ -760,7 +775,7 @@ const Configuracion: FC = () => { placeholder="Vacío = color del tema" /> {brandForm[key] && ( - )} @@ -797,7 +812,7 @@ const Configuracion: FC = () => {

    - Tipo de cambio USD / MXN + {t('config.exchangeRate')}

    Se usa para convertir ventas en dólares. Vigente: @@ -821,7 +836,7 @@ const Configuracion: FC = () => {

    @@ -831,7 +846,7 @@ const Configuracion: FC = () => {

    - Comisiones + {t('config.commissions')}

    Porcentaje de comisión sobre las ventas atribuidas a cada médico (visible en su página "Mis ventas"). @@ -864,7 +879,7 @@ const Configuracion: FC = () => {

    @@ -910,7 +925,7 @@ const Configuracion: FC = () => { onClick={() => saveDoctorCommission(d.id)} disabled={commissionSavingId === d.id} > - {commissionSavingId === d.id ? 'Guardando...' : 'Guardar'} + {commissionSavingId === d.id ? t('common.saving') : t('common.save')} )} @@ -927,6 +942,7 @@ const Configuracion: FC = () => {
    + )} = ({ label, icon, onClick, children, classNa const Dashboard: FC = () => { const { theme } = useTheme(); const { user, brand } = useAuth(); - const { term } = useGlosario(); + const term = useBusinessTerm(); + const { t, locale } = useI18n(); const navigate = useNavigate(); const isHomeNest = theme === 'homenest'; const [stats, setStats] = useState(null); @@ -322,49 +323,49 @@ const Dashboard: FC = () => { const statsConfig = useMemo( () => [ { - title: 'Citas Hoy', + title: t('dashboard.statAppointments'), value: stats?.appointments_today ?? 0, icon: , - trend: stats ? `${stats.appointments_month} este mes` : undefined, + trend: stats ? t('dashboard.thisMonth', { n: stats.appointments_month }) : undefined, to: '/agenda', }, { - title: 'Pacientes', + title: t('dashboard.statPatients'), value: stats?.total_patients ?? 0, icon: , - trend: stats ? `+${stats.new_patients_month} nuevos` : undefined, + trend: stats ? t('dashboard.newPatients', { n: stats.new_patients_month }) : undefined, to: '/pacientes', }, { - title: 'Ingresos Mes', + title: t('dashboard.statRevenue'), value: stats ? formatCurrency(stats.revenue_month) : '$0', icon: , to: '/reportes?tab=ingresos', delta: buildDelta(stats?.revenue_month, stats?.prev_revenue_month), - deltaLabel: 'vs mes anterior', + deltaLabel: t('dashboard.vsPrevMonth'), }, { - title: 'Ventas Hoy', + title: t('dashboard.statSales'), value: stats?.sales_today ?? 0, icon: , to: '/ventas', delta: buildDelta(stats?.sales_today, stats?.prev_sales_today), - deltaLabel: 'vs ayer', + deltaLabel: t('dashboard.vsYesterday'), }, { - title: 'Pendientes', + title: t('dashboard.statPending'), value: stats?.pending_payments ?? 0, icon: , to: '/reportes?tab=adeudos', }, { - title: 'Puntos Monedero', + title: t('dashboard.statWallet'), value: stats?.total_wallet_points ?? 0, icon: , to: '/monedero', }, ], - [stats] + [stats, t] ); // Próximas citas de hoy (hora >= ahora); si ya no queda ninguna, las @@ -381,19 +382,19 @@ const Dashboard: FC = () => { return (upcoming.length > 0 ? upcoming : sorted).slice(0, 3); }, [appointments]); - const todayLabel = new Date().toLocaleDateString('es-MX', { + const todayLabel = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'es-MX', { weekday: 'long', day: 'numeric', month: 'long', }); const cajaStatus = useMemo(() => { - if (!todayInfo) return { label: '—', ref: 'Consultando…', tone: 'text-theme-muted' }; - if (!todayInfo.caja) return { label: 'Sin abrir', ref: 'Sin corte de caja hoy', tone: 'text-theme-muted' }; + if (!todayInfo) return { label: '—', ref: t('dashboard.checking'), tone: 'text-theme-muted' }; + if (!todayInfo.caja) return { label: t('dashboard.cashNone'), ref: t('dashboard.noCashCut'), tone: 'text-theme-muted' }; if (todayInfo.caja.state === 'open') - return { label: 'Abierta', ref: todayInfo.caja.name, tone: 'text-emerald-600' }; - return { label: 'Cerrada', ref: todayInfo.caja.name, tone: 'text-theme-heading' }; - }, [todayInfo]); + return { label: t('dashboard.cashOpen'), ref: todayInfo.caja.name, tone: 'text-emerald-600' }; + return { label: t('dashboard.cashClosed'), ref: todayInfo.caja.name, tone: 'text-theme-heading' }; + }, [todayInfo, t]); const activityItems = useMemo(() => { return appointments.slice(0, 4).map((apt) => ({ @@ -409,13 +410,13 @@ const Dashboard: FC = () => { const s = aptReport.by_state || {}; const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0); return [ - { name: 'Completadas', value: pick('done'), color: '#CAE2C1' }, - { name: 'Confirmadas', value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' }, - { name: 'Pendientes', value: pick('pending'), color: '#EDA588' }, + { name: t('dashboard.stateDone'), value: pick('done'), color: '#CAE2C1' }, + { name: t('dashboard.stateConfirmed'), value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' }, + { name: t('dashboard.statPending'), value: pick('pending'), color: '#EDA588' }, { name: 'No show', value: pick('no_show'), color: '#E57373' }, - { name: 'Canceladas', value: pick('cancelled'), color: '#E5E0DC' }, + { name: t('dashboard.stateCancelled'), value: pick('cancelled'), color: '#E5E0DC' }, ].filter((d) => d.value > 0); - }, [aptReport]); + }, [aptReport, t]); const noShowRate = useMemo(() => { if (!aptReport || !aptReport.total) return 0; @@ -423,25 +424,25 @@ const Dashboard: FC = () => { }, [aptReport]); return ( - + {isHomeNest ? (
    - Bienvenido de vuelta + {t('dashboard.welcomeBack')}

    {brand?.nombre || 'Panel Clínico'}

    - Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con calidez y confianza. + {t('dashboard.heroTextNest')}

    @@ -457,10 +458,10 @@ const Dashboard: FC = () => {
    ) : ( <> - + - Bienvenido{user?.name ? `, ${user.name.split(' ')[0]}` : ''} + {user?.name ? t('dashboard.welcomeUser', { name: user.name.split(' ')[0] }) : t('dashboard.welcome')} @@ -469,13 +470,13 @@ const Dashboard: FC = () => {

    {brand?.nombre || 'Panel Clínico'}

    - Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza. + {t('dashboard.heroText')}

    @@ -487,21 +488,21 @@ const Dashboard: FC = () => {
    -

    Hoy en la clínica

    +

    {t('dashboard.todayTitle')}

    {todayLabel}

    } onClick={() => navigate('/agenda')} - ariaLabel="Ver próximas citas en la agenda" + ariaLabel={t('dashboard.ariaUpcoming')} className="md:col-span-2" > {upcomingToday.length === 0 ? ( -

    Sin citas activas para hoy.

    +

    {t('dashboard.noUpcoming')}

    ) : (
    {upcomingToday.map((apt) => ( @@ -509,7 +510,7 @@ const Dashboard: FC = () => { {apt.time} {apt.patient} - {apt.doctor || 'Sin médico'} + {apt.doctor || t('dashboard.noDoctor')}
    ))} @@ -518,28 +519,26 @@ const Dashboard: FC = () => {
    } onClick={() => navigate('/agenda')} - ariaLabel="Ver pacientes en sala de espera" + ariaLabel={t('dashboard.ariaWaiting')} >

    {todayInfo ? (todayInfo.waiting ?? '—') : '—'}

    {todayInfo?.waiting != null - ? todayInfo.waiting === 1 - ? 'paciente en espera' - : 'pacientes en espera' - : 'no disponible'} + ? (todayInfo.waiting === 1 ? t('dashboard.waiting_one') : t('dashboard.waiting_other')) + : t('dashboard.notAvailable')}

    } onClick={() => navigate('/cortes')} - ariaLabel="Ir a cortes de caja" + ariaLabel={t('dashboard.ariaCash')} >

    {cajaStatus.label}

    @@ -548,15 +547,15 @@ const Dashboard: FC = () => { } onClick={() => navigate('/ventas')} - ariaLabel="Ver cobros del día en ventas" + ariaLabel={t('dashboard.ariaCharges')} >

    {todayInfo ? (todayInfo.cobros != null ? formatCurrency(todayInfo.cobros) : '—') : '—'}

    -

    pagos completados hoy

    +

    {t('dashboard.completedPayments')}

    @@ -584,12 +583,12 @@ const Dashboard: FC = () => {

    - Cumpleaños de hoy + {t('dashboard.birthdaysTitle')}

    -

    Pacientes que cumplen años hoy — contáctalos

    +

    {t('dashboard.birthdaysSub')}

    @@ -597,7 +596,7 @@ const Dashboard: FC = () => { {loadingBirthdays ? ( ) : birthdays.length === 0 ? ( -

    Ningún paciente cumple años hoy.

    +

    {t('dashboard.noBirthdays')}

    ) : (
    {birthdays.map((b) => ( @@ -609,12 +608,12 @@ const Dashboard: FC = () => { type="button" onClick={() => openPatient(b.id)} className="text-sm font-medium text-theme-heading hover:underline hover:text-theme-accent text-left" - title="Abrir ficha del paciente" + title={t('dashboard.openPatient')} > {b.name} - cumple {b.turning_age}{b.is_vip ? ' · VIP' : ''} + {t('dashboard.turning', { age: b.turning_age })}{b.is_vip ? ` · ${t('dashboard.vip')}` : ''} {(b.whatsapp || b.phone) && ( {
    -

    Actividad semanal

    -

    Citas e ingresos de los últimos 7 días

    +

    {t('dashboard.chartWeeklyTitle')}

    +

    {t('dashboard.chartWeeklySub')}

    {loadingChart ? ( ) : chartData.length === 0 ? ( - } /> + } /> ) : (
    @@ -680,8 +679,8 @@ const Dashboard: FC = () => {
    -

    Próximas citas

    -

    Citas programadas para hoy

    +

    {t('dashboard.upcoming')}

    +

    {t('dashboard.upcomingTodaySub')}

    ) : appointments.length === 0 ? ( - } /> + } /> ) : (
    {appointments.map((apt) => ( @@ -708,7 +707,7 @@ const Dashboard: FC = () => {

    {apt.patient}

    {apt.service} • {apt.time}

    - {aptStateLabel(apt.state, term)} + {aptStateLabel(apt.state, term, t)}
    ))} @@ -722,15 +721,15 @@ const Dashboard: FC = () => {
    -

    Top servicios del mes

    -

    Por ingresos generados

    +

    {t('dashboard.topServicesTitle')}

    +

    {t('dashboard.topServicesSub')}

    {loadingExtras ? ( ) : topServices.length === 0 ? ( - } /> + } /> ) : (
    @@ -761,7 +760,7 @@ const Dashboard: FC = () => {
    -

    Asistencia del mes

    +

    {t('dashboard.attendanceTitle')}

    {aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}

    @@ -771,7 +770,7 @@ const Dashboard: FC = () => { {loadingExtras ? ( ) : attendanceData.length === 0 ? ( - } /> + } /> ) : (
    @@ -808,8 +807,8 @@ const Dashboard: FC = () => {
    -

    Servicios destacados

    -

    Catálogo activo de tratamientos

    +

    {t('dashboard.featuredTitle')}

    +

    {t('dashboard.featuredSub')}

    @@ -820,7 +819,7 @@ const Dashboard: FC = () => { ))}
    ) : services.length === 0 ? ( - } /> + } /> ) : (
    {services.map((s) => ( @@ -846,8 +845,8 @@ const Dashboard: FC = () => {
    -

    Actividad reciente

    -

    Últimos movimientos en la clínica

    +

    {t('dashboard.recentTitle')}

    +

    {t('dashboard.recentSub')}

    @@ -858,7 +857,7 @@ const Dashboard: FC = () => { ))}
    ) : activityItems.length === 0 ? ( - } /> + } /> ) : (
    {activityItems.map((item) => ( diff --git a/frontend/src/pages/FormularioPublico.tsx b/frontend/src/pages/FormularioPublico.tsx index 2d0601b..8db8c52 100644 --- a/frontend/src/pages/FormularioPublico.tsx +++ b/frontend/src/pages/FormularioPublico.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, CheckCircle2, Loader2, Send } from 'lucide-react'; import { odooApi, type FormularioPublicoConfig } from '../services/odoo'; import FormularioCampos, { type FotoConCampo } from '../components/formulario/FormularioCampos'; import { necesitaFirma, necesitaFoto, validarLocal } from '../components/formulario/tipos'; +import { useI18n, GLOSARIO_EN } from '../lib/i18n'; // Portal público para que el paciente llene un formulario desde su celular, // con firma y fotos. Fuera del gate de autenticación: el token HMAC del enlace @@ -12,6 +13,7 @@ import { necesitaFirma, necesitaFoto, validarLocal } from '../components/formula const FormularioPublico: FC = () => { const { token = '' } = useParams(); + const { t, locale } = useI18n(); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [noDisponible, setNoDisponible] = useState(null); @@ -55,12 +57,14 @@ const FormularioPublico: FC = () => { const pideFirma = necesitaFirma(campos, config?.plantilla.requiere_firma); const pideFoto = necesitaFoto(campos, config?.plantilla.requiere_foto); const term = (k: string, fallback: string) => - (config?.terms as unknown as Record)?.[k] || fallback; + locale === 'en' + ? (GLOSARIO_EN[k as keyof typeof GLOSARIO_EN] || fallback) + : ((config?.terms as unknown as Record)?.[k] || fallback); const enviarLlenado = async () => { if (!config) return; const errs = validarLocal(campos, valores, firma, fotos, config.plantilla.requiere_firma, config.plantilla.requiere_foto); - if (pideFirma && !firmaNombre.trim()) errs.firma_nombre = 'Escribe el nombre de quien firma'; + if (pideFirma && !firmaNombre.trim()) errs.firma_nombre = t('public.form.errSignature'); setErrores(errs); if (Object.keys(errs).length) { const primero = document.querySelector('[data-error="1"]'); @@ -91,11 +95,11 @@ const FormularioPublico: FC = () => { hp, }); if (res.status === 'success') setEnviado(true); - else setErrorEnvio(res.message || 'No se pudo enviar. Intenta de nuevo.'); + else setErrorEnvio(res.message || t('public.form.errSend')); } catch (err) { const r = err as { response?: { status?: number; data?: { message?: string } } }; if (r?.response?.status === 409) { setYaLlenado(true); return; } - setErrorEnvio(r?.response?.data?.message || 'No se pudo enviar. Revisa tu conexión e intenta de nuevo.'); + setErrorEnvio(r?.response?.data?.message || t('public.form.errSendNet')); } finally { setEnviando(false); } @@ -114,9 +118,9 @@ const FormularioPublico: FC = () => {
    -

    Formulario no disponible

    +

    {t('public.form.unavailableTitle')}

    - El enlace no es válido o ya expiró. Pide uno nuevo a la clínica. + {t('public.form.unavailableBody')}

    @@ -129,12 +133,12 @@ const FormularioPublico: FC = () => {

    - ¡Gracias{config.paciente?.nombre ? `, ${config.paciente.nombre}` : ''}! + {config.paciente?.nombre ? t('public.form.thanksName', { name: config.paciente.nombre }) : t('public.form.thanks')}

    - {enviado ? 'Tu formulario quedó registrado.' : 'Este formulario ya había sido llenado.'} + {enviado ? t('public.form.registered') : t('public.form.alreadyFilled')}

    -

    Ya puedes cerrar esta página.

    +

    {t('public.form.canClose')}

    ); @@ -155,7 +159,7 @@ const FormularioPublico: FC = () => { )}

    {brand.nombre}

    -

    {config.plantilla.tipo_label || 'Formulario'}

    +

    {config.plantilla.tipo_label || t('public.form.form')}

    @@ -174,7 +178,7 @@ const FormularioPublico: FC = () => {

    )} {config.plantilla.duracion_estimada_min > 0 && ( -

    Tarda unos {config.plantilla.duracion_estimada_min} minutos.

    +

    {t('public.form.takesMinutes', { n: config.plantilla.duracion_estimada_min })}

    )}
    @@ -211,8 +215,7 @@ const FormularioPublico: FC = () => { )} {pideFoto && pideFirma && (

    - Al enviar aceptas que tus datos y tu firma se guarden en el{' '} - {term('record_singular', 'Expediente').toLowerCase()} de la clínica. + {t('public.form.legalRecord', { record: term('record_singular', 'Expediente').toLowerCase() })}

    )} @@ -226,7 +229,7 @@ const FormularioPublico: FC = () => { className="flex w-full items-center justify-center gap-2 rounded-2xl bg-theme-accent px-4 py-4 text-base font-semibold text-theme-inverse disabled:opacity-60" > {enviando ? : } - {enviando ? 'Enviando…' : 'Enviar formulario'} + {enviando ? t('public.form.sending') : t('public.form.submit')}
    diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index f142a69..febed93 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -6,10 +6,12 @@ import { Button, Input } from '../components/ui'; import { odooApi } from '../services/odoo'; import { useAuth } from '../lib/auth'; import { useTheme } from '../lib/theme'; +import { useI18n } from '../lib/i18n'; import { useBookingConfig } from '../lib/publicBooking'; const Login: FC = () => { const { login, brand, setBrand } = useAuth(); + const { t } = useI18n(); const { theme } = useTheme(); const navigate = useNavigate(); // Enlace público de reservas: solo se muestra si el portal está activo @@ -38,7 +40,7 @@ const Login: FC = () => { e.preventDefault(); setError(null); if (!username.trim() || !password) { - setError('Escribe usuario y contraseña'); + setError(t('login.errRequired')); return; } try { @@ -49,9 +51,9 @@ const Login: FC = () => { // Mensajes amigables: nunca mostrar errores técnicos crudos. const msg = err instanceof Error ? err.message : ''; if (msg.includes('Credenciales inválidas') || msg.includes('401')) { - setError('Usuario o contraseña incorrectos'); + setError(t('login.errInvalid')); } else { - setError('No se pudo iniciar sesión. Intenta de nuevo.'); + setError(t('login.errGeneric')); } } finally { setSubmitting(false); @@ -84,8 +86,8 @@ const Login: FC = () => { alt={brand.nombre} className="h-10 w-auto object-contain mx-auto mb-6" /> -

    Bienvenido

    -

    Inicia sesión para continuar

    +

    {t('login.welcome')}

    +

    {t('login.subtitle')}

    { }} > setUsername(e.target.value)} autoComplete="username" @@ -111,7 +113,7 @@ const Login: FC = () => { />
    setPassword(e.target.value)} @@ -125,8 +127,8 @@ const Login: FC = () => { @@ -147,7 +149,7 @@ const Login: FC = () => { className="inline-flex items-center gap-1 underline underline-offset-2 hover:opacity-80" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.6)' }} > - Reservar en línea + {t('login.bookOnline')}

    )} diff --git a/frontend/src/pages/Pacientes.tsx b/frontend/src/pages/Pacientes.tsx index 829fb0d..db7b9ae 100644 --- a/frontend/src/pages/Pacientes.tsx +++ b/frontend/src/pages/Pacientes.tsx @@ -35,7 +35,7 @@ import { useSearchParams } from 'react-router-dom'; import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta, type PatientWallet, type ReceiptData } from '../services/odoo'; import { exportToExcel } from '../lib/exporter'; import { useAuth } from '../lib/auth'; -import { useGlosario } from '../lib/glosario'; +import { useI18n, useBusinessTerm } from '../lib/i18n'; import { useSucursales } from '../lib/sucursales'; const fmtMoney = (n: number) => @@ -126,7 +126,8 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [ const Pacientes: FC = () => { const { brand, can } = useAuth(); - const { term } = useGlosario(); + const term = useBusinessTerm(); + const { t } = useI18n(); const { labelFor: branchLabel } = useSucursales(); const [patients, setPatients] = useState([]); const [loading, setLoading] = useState(true); @@ -432,14 +433,14 @@ const Pacientes: FC = () => { const TableHeader = () => ( - Nombre - Expediente - Teléfono - Edad - Última visita + {t('patients.colName')} + {t('patients.colRecord')} + {t('patients.colPhone')} + {t('patients.colAge')} + {t('patients.colLastVisit')} {term('visit_plural')} - Puntos - Acciones + {t('patients.colPoints')} + {t('patients.colActions')} ); @@ -528,16 +529,16 @@ const Pacientes: FC = () => { }; return ( - - + + @@ -547,7 +548,7 @@ const Pacientes: FC = () => {
    setSearchInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput.trim()); if (e.key === 'Escape') { setSearchInput(''); setSearch(''); } }} @@ -558,8 +559,8 @@ const Pacientes: FC = () => {
    setAltaForm({ ...altaForm, name: e.target.value })} /> - setAltaForm({ ...altaForm, phone: e.target.value })} /> + setAltaForm({ ...altaForm, name: e.target.value })} /> + setAltaForm({ ...altaForm, phone: e.target.value })} />
    - +
    @@ -813,7 +814,7 @@ const Pos: FC = () => {
    setPacienteQuery(e.target.value)} className="pl-10" @@ -934,10 +935,10 @@ const Pos: FC = () => { const totalsBlock = ( <>
    - Subtotal + {t('common.subtotal')} {fmtMoney(subtotal)} - + {toUsd(subtotal) !== null ? `≈ ${fmtUsd(toUsd(subtotal)!)}` : '—'} @@ -945,7 +946,7 @@ const Pos: FC = () => { {impuestos.activo && (
    - {impuestos.nombre}{impuestos.precios_incluyen_impuesto ? ' (incluido)' : ''} + {impuestos.nombre}{impuestos.precios_incluyen_impuesto ? t('pos.taxIncluded') : ''} {fmtMoney(impuesto)} @@ -956,7 +957,7 @@ const Pos: FC = () => {
    )}
    - Descuento + {t('pos.discount')} {discountNum > 0 && toUsd(discountNum) !== null && ( ≈ {fmtUsd(toUsd(discountNum)!)} @@ -969,18 +970,18 @@ const Pos: FC = () => { onChange={(e) => setDiscount(e.target.value)} placeholder="0" disabled={!can('ventas.descuentos')} - title={can('ventas.descuentos') ? undefined : 'Sin permiso para aplicar descuentos'} + title={can('ventas.descuentos') ? undefined : t('pos.noDiscountPerm')} className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right disabled:opacity-50 disabled:cursor-not-allowed" />
    - Total + {t('common.total')} {fmtMoney(total)} {toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'} @@ -993,27 +994,27 @@ const Pos: FC = () => { className="w-full mt-1 !py-3 text-base" onClick={abrirCobro} disabled={lines.length === 0 || !paciente || !can('ventas.vender')} - title={can('ventas.vender') ? 'Cobrar (F9)' : 'Sin permiso para vender'} + title={can('ventas.vender') ? t('pos.chargeTitle') : t('pos.noSellPerm')} > - Cobrar {fmtMoney(total)} + {t('pos.chargeAmount', { amount: fmtMoney(total) })}

    ); return ( - + {!cajaAbierta ? (
    @@ -1042,7 +1043,7 @@ const Pos: FC = () => {
    setSearch(e.target.value)} className="pl-10" @@ -1252,8 +1253,8 @@ const Pos: FC = () => { {sv.duration_min} min {fmtMoney(sv.price)} - @@ -1274,8 +1275,8 @@ const Pos: FC = () => { {(it.price ?? 0) > 0 ? fmtMoney(it.price!) : sin precio} - @@ -1346,7 +1347,7 @@ const Pos: FC = () => {

    Ticket

    {lines.length > 0 && ( - )} @@ -1419,7 +1420,7 @@ const Pos: FC = () => { maxWidth="sm" footer={ <> - + } @@ -1430,7 +1431,7 @@ const Pos: FC = () => { se hace en Cortes de caja.

    { setCobroOpen(false)} - title={`Cobrar ${fmtMoney(total)}`} + title={t('pos.chargeAmount', { amount: fmtMoney(total) })} maxWidth="md" footer={ <> - + } @@ -1460,7 +1461,7 @@ const Pos: FC = () => { Se asigna la venta completa a este doctor para sus comisiones.

    - setMetodo(e.target.value)} /> {metodo === 'cash' && !conPuntos && ( <>
    @@ -1492,7 +1493,7 @@ const Pos: FC = () => { )}
    { )} {recibido && (

    - Cambio: + {t('pos.change')} 0 ? 'text-theme-heading' : 'text-theme-muted'}`}> {fmtMoney(cambio)} {moneda === 'USD' && cambio > 0 && ( @@ -1558,7 +1559,7 @@ const Pos: FC = () => { tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0, tax_name: impuestos.nombre, total: resultado.sale.total, - metodo: resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos', + metodo: resultado.payment ? metodoLabelL(resultado.payment.payment_method) : t('pos.points'), puntos_usados: resultado.puntos_usados, puntos_ganados: resultado.puntos_ganados, wallet_points: resultado.wallet_points, diff --git a/frontend/src/pages/Reportes.tsx b/frontend/src/pages/Reportes.tsx index 73b7ef4..b3985eb 100644 --- a/frontend/src/pages/Reportes.tsx +++ b/frontend/src/pages/Reportes.tsx @@ -50,6 +50,7 @@ import { useReportFavorites, } from '../lib/reportRange'; import { useClinicSettings } from '../lib/clinic-settings'; +import { useI18n } from '../lib/i18n'; const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981']; @@ -155,6 +156,7 @@ const ReportesContent: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const { hasRole, canSee, can, brand } = useAuth(); const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings(); + const { t } = useI18n(); const esAdmin = hasRole('admin'); // PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves // por pestana y, cuando no existe la del tab, canSee cae al prefijo @@ -303,22 +305,22 @@ const ReportesContent: FC = () => { }; return ( - - + + {tab === 'general' && can('reportes.exportar') && ( )} {puedeTab('exportar') && can('reportes.exportar') && ( )} @@ -327,7 +329,7 @@ const ReportesContent: FC = () => {

    - Periodo + {t('reports.period')} {REPORT_PRESETS.map((p) => ( ))}
    setRange('custom', e.target.value, end)} className="sm:max-w-[170px]" /> setRange('custom', start, e.target.value)} @@ -412,7 +414,7 @@ const ReportesContent: FC = () => { key={f.key} type="button" onClick={() => setTab(f.tabs[0].key)} - title={f.hint} + title={t(`reports.family.${f.key}.hint`)} className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold rounded-xl transition ${ activa ? 'bg-theme-accent text-theme-inverse shadow-card' @@ -420,7 +422,7 @@ const ReportesContent: FC = () => { }`} > - {f.label} + {t(`reports.family.${f.key}`)} ); })} @@ -429,30 +431,30 @@ const ReportesContent: FC = () => { {/* Pestañas de la familia activa */}
    - {tabsFamilia.map((t) => { - const activo = tab === t.key; + {tabsFamilia.map((tabItem) => { + const activo = tab === tabItem.key; return ( - ); @@ -510,7 +512,7 @@ const ReportesContent: FC = () => {
    setToday(e.target.value)} diff --git a/frontend/src/pages/Reservar.tsx b/frontend/src/pages/Reservar.tsx index e4a3615..02aaccb 100644 --- a/frontend/src/pages/Reservar.tsx +++ b/frontend/src/pages/Reservar.tsx @@ -10,6 +10,7 @@ import { useBookingConfig, proximosDias, fechaISO, etiquetaDiaCorto, fechaLargaISO, construirIcs, descargarIcs, normalizarTelefono, telefonoValido, } from '../lib/publicBooking'; +import { useI18n, GLOSARIO_EN } from '../lib/i18n'; // Portal público de reservas en línea (sin sesión). Mobile-first: la mayoría // de las clientas reservan del celular. Respeta brand y glosario del backend. @@ -20,6 +21,7 @@ type GrupoServicios = { code: string; nombre: string; servicios: ServicioPortal[ const Reservar: FC = () => { const navigate = useNavigate(); const { config, loading } = useBookingConfig(); + const { t, locale } = useI18n(); const [paso, setPaso] = useState(0); const [servicioId, setServicioId] = useState(null); @@ -60,9 +62,9 @@ const Reservar: FC = () => { if (svcs.length) { out.push({ code: c.code, nombre: c.name, servicios: svcs }); usados.add(c.code); } } const resto = config.servicios.filter((s) => !usados.has(s.categoria)); - if (resto.length) out.push({ code: 'otros', nombre: 'Otros', servicios: resto }); + if (resto.length) out.push({ code: 'otros', nombre: t('public.others'), servicios: resto }); return out; - }, [config]); + }, [config, t]); useEffect(() => { if (!config || pasoActual !== 'fecha' || !servicioId) return; @@ -81,8 +83,8 @@ const Reservar: FC = () => { const enviar = async () => { if (!servicioId || !hora) return; - if (nombre.trim().length < 2) { setError('Escribe tu nombre'); return; } - if (!telefonoValido(telefono)) { setError('Escribe un teléfono válido (10 dígitos)'); return; } + if (nombre.trim().length < 2) { setError(t('public.errName')); return; } + if (!telefonoValido(telefono)) { setError(t('public.errPhone')); return; } setEnviando(true); setError(null); try { @@ -102,13 +104,16 @@ const Reservar: FC = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; - setError(msg || 'No se pudo completar la reserva. Intenta de nuevo.'); + setError(msg || t('public.errGeneric')); } finally { setEnviando(false); } }; - const term = (k: string, fallback: string) => config?.terms?.[k as keyof typeof config.terms] || fallback; + const term = (k: string, fallback: string) => + locale === 'en' + ? (GLOSARIO_EN[k as keyof typeof GLOSARIO_EN] || fallback) + : (config?.terms?.[k as keyof typeof config.terms] || fallback); if (loading) { return ( @@ -123,9 +128,9 @@ const Reservar: FC = () => {
    -

    Reservas en línea no disponibles

    +

    {t('public.unavailableTitle')}

    - Por el momento no puedes agendar desde aquí. Comunícate directamente con la clínica. + {t('public.unavailableBody')}

    @@ -157,9 +162,9 @@ const Reservar: FC = () => {

    - {resultado.requiere_confirmacion ? 'Solicitud recibida' : '¡Reserva confirmada!'} + {resultado.requiere_confirmacion ? t('public.requestReceived') : t('public.confirmed')}

    -

    Folio {resultado.folio}

    +

    {t('public.folioWord')} {resultado.folio}

    } texto={`${fechaLargaISO(resultado.fecha)} · ${resultado.hora}`} /> } texto={resultado.servicio} /> @@ -171,21 +176,21 @@ const Reservar: FC = () => { )} {resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (

    - Tu reserva quedó registrada y la clínica la confirmará en breve. + {t('public.willConfirm')}

    )}
    -

    Guarda este enlace para gestionar tu reserva

    +

    {t('public.saveLink')}

    { try { await navigator.clipboard.writeText(enlaceGestion); setCopiado(true); } catch { /* ignore */ } }} > - {copiado ? 'Copiado' : 'Copiar'} + {copiado ? t('public.copied') : t('public.copy')}
    @@ -209,11 +214,11 @@ const Reservar: FC = () => { const tituloPaso = (): string => { switch (pasoActual) { - case 'servicio': return `Elige tu ${term('service_singular', 'servicio').toLowerCase()}`; - case 'sucursal': return `Elige ${term('branch_singular', 'sucursal').toLowerCase()}`; - case 'especialista': return `Elige ${term('doctor_singular', 'especialista').toLowerCase()}`; - case 'fecha': return 'Elige fecha y hora'; - case 'datos': return 'Tus datos'; + case 'servicio': return t('public.stepService', { term: term('service_singular', 'servicio').toLowerCase() }); + case 'sucursal': return t('public.stepBranch', { term: term('branch_singular', 'sucursal').toLowerCase() }); + case 'especialista': return t('public.stepDoctor', { term: term('doctor_singular', 'especialista').toLowerCase() }); + case 'fecha': return t('public.stepDateTime'); + case 'datos': return t('public.stepData'); default: return ''; } }; @@ -242,7 +247,7 @@ const Reservar: FC = () => {
    {paso > 0 && ( - @@ -315,7 +320,7 @@ const Reservar: FC = () => { medicoId === null ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border hover:border-theme-border-strong' }`} > - Cualquiera disponible + {t('public.anyAvailable')} {config.medicos.map((m) => ( @@ -362,7 +367,7 @@ const Reservar: FC = () => {
    ) : slots.length === 0 ? (

    - No hay horarios disponibles ese día. Prueba con otra fecha. + {t('public.noSlots')}

    ) : (
    @@ -381,7 +386,7 @@ const Reservar: FC = () => {
    )} - +
    )} @@ -392,13 +397,13 @@ const Reservar: FC = () => { {servicioSel?.nombre} · {fechaLargaISO(fecha)} · {hora} {medicoSel ? ` · ${medicoSel.nombre}` : ''}
    - setNombre(e.target.value)} autoComplete="name" /> - setTelefono(e.target.value)} inputMode="tel" autoComplete="tel" /> - setEmail(e.target.value)} inputMode="email" autoComplete="email" /> -