1571 lines
67 KiB
TypeScript
1571 lines
67 KiB
TypeScript
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],
|
||
);
|
||
};
|