Files
SKEEN-Proyecto/frontend/src/lib/i18n.tsx

1571 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string>;
// ------------------------------------------------------------
// 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.cobros': 'Cobros',
'reports.tab.cobros.desc': 'Anticipos y membresías por cobrar (pendientes y expirados)',
'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.',
// Anticipo al reservar en línea
'public.awaitingDeposit': 'Reserva retenida: falta tu anticipo',
'public.depositAmount': 'Anticipo a pagar: {amount}',
'public.depositHelp': 'Tienes {h} horas para pagar el anticipo; si no se paga, la reserva se libera.',
'public.payDeposit': 'Pagar anticipo',
'public.depositExpires': 'El enlace vence en {h} horas',
// --- Página pública de pago de un enlace de cobro ---
'pay.title': 'Pago en línea',
'pay.folioWord': 'Folio',
'pay.concept': 'Concepto',
'pay.amount': 'Monto',
'pay.forPatient': 'A nombre de {name}',
'pay.payNow': 'Pagar ahora',
'pay.paySimulated': 'Confirmar pago (modo prueba)',
'pay.paidOk': '¡Pago registrado!',
'pay.receipt': 'Comprobante',
'pay.date': 'Fecha',
'pay.notAvailable': 'El pago en línea no está disponible en este momento. Comunícate con la clínica.',
'pay.expiresAt': 'Este enlace vence el {date}',
'pay.expiredBody': 'Este enlace ya expiró. Pide uno nuevo a la clínica.',
'pay.cancelledBody': 'Este enlace fue cancelado.',
'pay.unavailableTitle': 'Enlace no disponible',
'pay.invalidLink': 'Enlace no válido',
'pay.notFound': 'No encontramos este enlace de pago.',
'pay.payError': 'No se pudo completar el pago. Intenta de nuevo.',
'pay.simulatedNote': 'Modo de prueba: no se cobra dinero real.',
'pay.secureNote': 'Pago protegido por la pasarela de la clínica.',
'pay.state.pagado': 'Pagado',
// --- 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.',
'agenda.depositPending': 'Esperando anticipo',
'config.idiomaTitle': 'Idioma de la interfaz',
'config.idiomaBody': 'Elige el idioma con el que se muestra el sistema en este navegador.',
// Anticipo de reservas en línea
'config.anticipo.title': 'Anticipo al reservar',
'config.anticipo.help': 'Pide un depósito al reservar en línea. La reserva queda retenida sin confirmar hasta que se pague; si no se paga a tiempo, se libera el horario.',
'config.anticipo.enable': 'Requerir anticipo',
'config.anticipo.enableHelp': 'Apagado, el portal reserva sin pedir dinero (como hasta hoy).',
'config.anticipo.type': 'Tipo de anticipo',
'config.anticipo.typePercent': 'Porcentaje del servicio',
'config.anticipo.typeFixed': 'Monto fijo',
'config.anticipo.value': 'Valor (porcentaje o monto)',
'config.anticipo.currency': 'Moneda',
'config.anticipo.hours': 'Horas para pagar',
'config.anticipo.refundable': 'Reembolsable',
'config.anticipo.refundableHelp': 'Si se cancela la cita, el anticipo se devuelve.',
'config.anticipo.simulated': 'Permitir pago simulado',
'config.anticipo.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.',
'config.anticipo.notice': 'Aviso para el cliente',
'config.anticipo.noticePlaceholder': 'Se requiere un anticipo para confirmar tu cita.',
// Cobro automático de membresías
'config.cobroAuto.title': 'Cobro automático de membresías',
'config.cobroAuto.help': 'Genera el enlace de pago del periodo y lo envía al paciente por notificaciones, con reintentos. El periodo queda pendiente hasta que se pague.',
'config.cobroAuto.enable': 'Activar cobro automático',
'config.cobroAuto.enableHelp': 'Apagado, el proceso solo genera el adeudo (no cobra ni avisa).',
'config.cobroAuto.method': 'Método',
'config.cobroAuto.methodLink': 'Enlace de pago',
'config.cobroAuto.methodCard': 'Tarjeta guardada (Stripe)',
'config.cobroAuto.daysBefore': 'Días antes del vencimiento',
'config.cobroAuto.grace': 'Días de gracia',
'config.cobroAuto.retries': 'Días de reintento del aviso',
'config.cobroAuto.retriesHelp': 'Días (respecto al vencimiento) en que se reintenta avisar, separados por coma.',
'config.cobroAuto.cancel': 'Vencer si no paga',
'config.cobroAuto.cancelHelp': 'Al agotar los reintentos, marca la membresía vencida. Apagado, sigue activa hasta el proceso normal de vencimiento.',
'config.cobroAuto.simulated': 'Permitir pago simulado',
'config.cobroAuto.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.',
};
// ------------------------------------------------------------
// 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.cobros': 'Charges',
'reports.tab.cobros.desc': 'Deposits and memberships pending collection (pending and expired)',
'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.',
'public.awaitingDeposit': 'Booking held: deposit pending',
'public.depositAmount': 'Deposit due: {amount}',
'public.depositHelp': 'You have {h} hours to pay the deposit; otherwise the booking is released.',
'public.payDeposit': 'Pay deposit',
'public.depositExpires': 'The link expires in {h} hours',
'pay.title': 'Online payment',
'pay.folioWord': 'Reference',
'pay.concept': 'Concept',
'pay.amount': 'Amount',
'pay.forPatient': 'For {name}',
'pay.payNow': 'Pay now',
'pay.paySimulated': 'Confirm payment (test mode)',
'pay.paidOk': 'Payment recorded!',
'pay.receipt': 'Receipt',
'pay.date': 'Date',
'pay.notAvailable': 'Online payment is not available right now. Please contact the clinic.',
'pay.expiresAt': 'This link expires on {date}',
'pay.expiredBody': 'This link has expired. Please ask the clinic for a new one.',
'pay.cancelledBody': 'This link was cancelled.',
'pay.unavailableTitle': 'Link unavailable',
'pay.invalidLink': 'Invalid link',
'pay.notFound': 'We could not find this payment link.',
'pay.payError': 'The payment could not be completed. Please try again.',
'pay.simulatedNote': 'Test mode: no real money is charged.',
'pay.secureNote': 'Payment protected by the clinic payment gateway.',
'pay.state.pagado': 'Paid',
// --- 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.',
'agenda.depositPending': 'Deposit pending',
'config.idiomaTitle': 'Interface language',
'config.idiomaBody': 'Choose the language the system displays in this browser.',
'config.anticipo.title': 'Deposit at booking',
'config.anticipo.help': 'Ask for a deposit when booking online. The booking is held without confirming until paid; if unpaid in time, the slot is released.',
'config.anticipo.enable': 'Require deposit',
'config.anticipo.enableHelp': 'Off, the portal books without asking for money (as today).',
'config.anticipo.type': 'Deposit type',
'config.anticipo.typePercent': 'Percentage of the service',
'config.anticipo.typeFixed': 'Fixed amount',
'config.anticipo.value': 'Value (percentage or amount)',
'config.anticipo.currency': 'Currency',
'config.anticipo.hours': 'Hours to pay',
'config.anticipo.refundable': 'Refundable',
'config.anticipo.refundableHelp': 'If the appointment is cancelled, the deposit is refunded.',
'config.anticipo.simulated': 'Allow simulated payment',
'config.anticipo.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.',
'config.anticipo.notice': 'Customer notice',
'config.anticipo.noticePlaceholder': 'A deposit is required to confirm your appointment.',
'config.cobroAuto.title': 'Automatic membership charging',
'config.cobroAuto.help': 'Generates the payment link for the period and sends it to the patient via notifications, with retries. The period stays pending until paid.',
'config.cobroAuto.enable': 'Enable automatic charging',
'config.cobroAuto.enableHelp': 'Off, the process only creates the charge (no charge or notice).',
'config.cobroAuto.method': 'Method',
'config.cobroAuto.methodLink': 'Payment link',
'config.cobroAuto.methodCard': 'Saved card (Stripe)',
'config.cobroAuto.daysBefore': 'Days before due date',
'config.cobroAuto.grace': 'Grace days',
'config.cobroAuto.retries': 'Notice retry days',
'config.cobroAuto.retriesHelp': 'Days (relative to due date) on which the notice is retried, comma separated.',
'config.cobroAuto.cancel': 'Expire if unpaid',
'config.cobroAuto.cancelHelp': 'When retries are exhausted, marks the membership as expired. Off, it stays active until the normal expiration process.',
'config.cobroAuto.simulated': 'Allow simulated payment',
'config.cobroAuto.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.',
};
export const DICT: Record<Locale, Dict> = { 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<GlossaryKey, string> = {
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<string, string> = { // 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<string, string> = {
'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, string | number>) => string;
const interpolar = (texto: string, vars?: Record<string, string | number>): 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, string | number>): 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<I18nContextValue | undefined>(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<Locale>(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]);
// <html lang> refleja el idioma activo.
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
}
}, [locale]);
const t = useCallback<TFunc>((key, vars) => translate(locale, key, vars), [locale]);
const value = useMemo<I18nContextValue>(
() => ({ locale, setLocale, t, idiomas: IDIOMAS }),
[locale, setLocale, t],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
};
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],
);
};