fase 3.6: i18n espanol/ingles (530 claves, provider con fallback, interpolacion y pluralizacion, selector de idioma en menu de usuario y configuracion, convivencia con el glosario de negocio, superficies principales y paginas publicas traducidas sin alterar el espanol)

This commit is contained in:
2026-10-11 21:19:43 +00:00
parent 3ed0d327d2
commit e749a31d10
22 changed files with 2022 additions and 411 deletions

View File

@@ -35,7 +35,7 @@ import { useSearchParams } from 'react-router-dom';
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta, type PatientWallet, type ReceiptData } from '../services/odoo';
import { exportToExcel } from '../lib/exporter';
import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
import { useI18n, useBusinessTerm } from '../lib/i18n';
import { useSucursales } from '../lib/sucursales';
const fmtMoney = (n: number) =>
@@ -126,7 +126,8 @@ const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
const Pacientes: FC = () => {
const { brand, can } = useAuth();
const { term } = useGlosario();
const term = useBusinessTerm();
const { t } = useI18n();
const { labelFor: branchLabel } = useSucursales();
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
@@ -432,14 +433,14 @@ const Pacientes: FC = () => {
const TableHeader = () => (
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Expediente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Teléfono</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Edad</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Última visita</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colName')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colRecord')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">{t('patients.colPhone')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{t('patients.colAge')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{t('patients.colLastVisit')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{term('visit_plural')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colPoints')}</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colActions')}</th>
</tr>
</thead>
);
@@ -528,16 +529,16 @@ const Pacientes: FC = () => {
};
return (
<Layout title={term('patient_plural')} subtitle="Directorio de pacientes">
<PageHeader title={term('patient_plural')} subtitle="Busca, crea y gestiona pacientes">
<Layout title={term('patient_plural')} subtitle={t('patients.subtitle')}>
<PageHeader title={term('patient_plural')} subtitle={t('patients.pageSubtitle')}>
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0 || !can('pacientes.exportar')}
title={can('pacientes.exportar') ? undefined : 'Sin permiso para exportar'}>
title={can('pacientes.exportar') ? undefined : t('patients.noExportPerm')}>
<Download size={16} className="mr-2" />
Exportar CSV
{t('patients.exportCsv')}
</Button>
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo {term('patient_singular').toLowerCase()}
{t('patients.newWord')} {term('patient_singular').toLowerCase()}
</Button>
</PageHeader>
@@ -547,7 +548,7 @@ const Pacientes: FC = () => {
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted pointer-events-none" />
<Input
placeholder="Buscar por nombre, apellidos o teléfono..."
placeholder={t('patients.searchPlaceholder')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput.trim()); if (e.key === 'Escape') { setSearchInput(''); setSearch(''); } }}
@@ -558,8 +559,8 @@ const Pacientes: FC = () => {
<button
type="button"
onClick={() => { setSearchInput(''); setSearch(''); }}
title="Limpiar búsqueda"
aria-label="Limpiar búsqueda"
title={t('patients.clearSearch')}
aria-label={t('patients.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 rounded-lg text-theme-muted hover:text-theme-heading hover:bg-theme-bg"
>
<X size={14} />
@@ -568,7 +569,7 @@ const Pacientes: FC = () => {
</div>
<Select
options={[
{ value: '', label: 'Todos los médicos' },
{ value: '', label: t('patients.allDoctors') },
...doctors.map((d) => ({
value: String(d.id),
label: d.patient_count !== undefined ? `${d.name} (${d.patient_count})` : d.name,
@@ -584,9 +585,9 @@ const Pacientes: FC = () => {
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{([
{ key: 'vip', label: 'VIP', active: onlyVip, set: setOnlyVip },
{ key: 'recent', label: 'Visitas últimos 6 meses', active: onlyRecent, set: (v: boolean) => { setOnlyRecent(v); if (v) setOnlyStale(false); } },
{ key: 'stale', label: 'Sin visita +6 meses', active: onlyStale, set: (v: boolean) => { setOnlyStale(v); if (v) setOnlyRecent(false); } },
{ key: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
{ key: 'recent', label: t('patients.filterRecent'), active: onlyRecent, set: (v: boolean) => { setOnlyRecent(v); if (v) setOnlyStale(false); } },
{ key: 'stale', label: t('patients.filterStale'), active: onlyStale, set: (v: boolean) => { setOnlyStale(v); if (v) setOnlyRecent(false); } },
{ key: 'incomplete', label: t('patients.filterIncomplete'), active: onlyIncomplete, set: setOnlyIncomplete },
] as const).map((chip) => (
<button
key={chip.key}
@@ -607,7 +608,7 @@ const Pacientes: FC = () => {
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyStale(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
>
Quitar filtros
{t('patients.clearFilters')}
</button>
)}
</div>
@@ -618,9 +619,9 @@ const Pacientes: FC = () => {
<Skeleton count={6} className="h-12 w-full" />
) : patients.length === 0 ? (
<EmptyState
title="Sin pacientes"
subtitle="No se encontraron pacientes."
actionLabel={`Nuevo ${term('patient_singular').toLowerCase()}`}
title={t('patients.emptyTitle')}
subtitle={t('patients.emptySub')}
actionLabel={`${t('patients.newWord')} ${term('patient_singular').toLowerCase()}`}
onAction={openCreate}
/>
) : (
@@ -639,7 +640,7 @@ const Pacientes: FC = () => {
<td className="p-3 text-sm">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${completionBadgeClasses(p.expediente_completion)}`}
title={p.expediente_missing && p.expediente_missing.length > 0 ? `Falta: ${p.expediente_missing.join(', ')}` : 'Expediente completo'}
title={p.expediente_missing && p.expediente_missing.length > 0 ? t('patients.missingRecord', { list: p.expediente_missing.join(', ') }) : t('patients.complete')}
>
{p.expediente_completion ?? 0}%
</span>
@@ -1003,11 +1004,11 @@ const Pacientes: FC = () => {
<div className="flex gap-2 border-b border-theme-border pb-2 overflow-x-auto">
{([
{ key: 'info', label: 'Información general' },
{ key: 'clinical', label: 'Historia clínica' },
{ key: 'timeline', label: 'Historial' },
{ key: 'docs', label: 'Documentos' },
{ key: 'formularios', label: 'Formularios' },
{ key: 'info', label: t('patients.tabInfo') },
{ key: 'clinical', label: t('patients.tabClinical') },
{ key: 'timeline', label: t('patients.tabTimeline') },
{ key: 'docs', label: t('patients.tabDocs') },
{ key: 'formularios', label: t('patients.tabForms') },
] as const).map(({ key, label }) => (
<button
key={key}
@@ -1106,7 +1107,7 @@ const Pacientes: FC = () => {
<InfoRow label="Contacto de emergencia" value={selected?.emergency_contact} icon={AlertCircle} />
<InfoRow label="Tel. emergencia" value={selected?.emergency_phone} icon={Phone} />
<InfoRow label="Recomendado por" value={selected?.referred_by} icon={Users} />
<InfoRow label="Médico asignado" value={selected?.primary_doctor || 'Sin asignar'} icon={Stethoscope} />
<InfoRow label="Médico asignado" value={selected?.primary_doctor || t('patients.unassigned')} icon={Stethoscope} />
<InfoRow label="Puntos" value={`${selected?.wallet_points ?? 0} pts`} icon={Heart} />
</div>
{selected?.address_notes && (
@@ -1191,7 +1192,7 @@ const Pacientes: FC = () => {
<>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<FileText size={16} className="mr-2 text-theme-muted" /> Expediente escaneado
<FileText size={16} className="mr-2 text-theme-muted" /> {t('patients.scannedRecord')}
</h4>
<PatientAdjuntos patientId={selected.id} kind="expediente" />
</section>
@@ -1348,10 +1349,10 @@ const Pacientes: FC = () => {
<table className="w-full text-sm">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2.5 text-xs font-medium text-theme-muted">Servicio</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Cant.</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Precio</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Subtotal</th>
<th className="text-left p-2.5 text-xs font-medium text-theme-muted">{t('patients.colService')}</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('patients.colQty')}</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('patients.colPrice')}</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('common.subtotal')}</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">