Files
FRASA-CAS/apps/alta-clientes/src/components/BotonOcrIne.tsx
Consult AS Technology 8b65f903d3 fix(dark-mode): dark mode completo + gráficas adaptadas
Dark mode fixes:
- Cards secundarias: Mis capturas, Mis clientes, Actividades, Apartados por vencer, Equipo
- Botones: Exportar, Descartar, Afluencia, Agendo, Forzar reconexión, Subir archivo
- Config: ModoTrabajo, Usuarios, Sistema, RolesPermisos, ConfigPage
- Cotizador: inputs, tabs, placeholder, botón compartir
- Alta clientes: BotonOcrIne, FileUploader
- Cobranza: toggle dashboard, modal abono
- Dashboards: tabs

Gráficas Recharts:
- CSS variables para colores chart (grid, text, tooltip)
- Helper isDark() + axisTextColor() + gridStroke() + tooltipProps()
- DashboardVendedor: grid, axes, tooltips adaptados
- TableroGeneral: grid, axes, tooltips (BarChart, Funnel, PieChart)
- MiTablero: tooltip adaptado
- Perfilamiento: grid, axes, tooltip

7 apps construidas limpias.
2026-09-07 06:44:08 +00:00

146 lines
5.9 KiB
TypeScript

import { useState } from 'react'
import type { DatosGenerales } from '../types'
import { parsearTextoIne } from '../lib/ocrIne'
import type { SugerenciasIne } from '../lib/ocrIne'
type CamposAplicables = Partial<Pick<DatosGenerales, 'curp' | 'nombreCompleto' | 'fechaNacimiento'>>
interface Props {
/** Imagen (JPG/PNG) de la credencial INE ya cargada */
archivo: File
/** Aplica un campo sugerido al formulario de datos generales */
onAplicar: (campos: CamposAplicables) => void
}
type Estado =
| { fase: 'inactivo' }
| { fase: 'procesando'; progreso: number }
| { fase: 'listo'; sugerencias: SugerenciasIne }
| { fase: 'error'; mensaje: string }
interface SugerenciaVisible {
clave: keyof CamposAplicables | 'sexo'
etiqueta: string
valor: string
/** Solo estos campos tienen destino en el formulario */
aplicable: boolean
}
function aVisibles(s: SugerenciasIne): SugerenciaVisible[] {
const v: SugerenciaVisible[] = []
if (s.curp) v.push({ clave: 'curp', etiqueta: 'CURP', valor: s.curp, aplicable: true })
if (s.nombre) v.push({ clave: 'nombreCompleto', etiqueta: 'Nombre', valor: s.nombre, aplicable: true })
if (s.fechaNacimiento)
v.push({ clave: 'fechaNacimiento', etiqueta: 'Fecha de nacimiento', valor: s.fechaNacimiento, aplicable: true })
if (s.sexo) v.push({ clave: 'sexo', etiqueta: 'Sexo', valor: s.sexo === 'H' ? 'Hombre' : 'Mujer', aplicable: false })
return v
}
/**
* OCR del INE con tesseract.js (import dinámico para no inflar el bundle).
* El idioma español se sirve local desde /client/tessdata (base path /client/).
*/
export default function BotonOcrIne({ archivo, onAplicar }: Props) {
const [estado, setEstado] = useState<Estado>({ fase: 'inactivo' })
const [aplicados, setAplicados] = useState<string[]>([])
const leer = async () => {
setEstado({ fase: 'procesando', progreso: 0 })
setAplicados([])
try {
const { createWorker } = await import('tesseract.js')
const worker = await createWorker('spa', undefined, {
langPath: `${import.meta.env.BASE_URL}tessdata`,
gzip: false,
logger: (m: { status: string; progress: number }) => {
if (m.status === 'recognizing text') {
setEstado({ fase: 'procesando', progreso: Math.round(m.progress * 100) })
}
},
})
try {
const { data } = await worker.recognize(archivo)
const sugerencias = parsearTextoIne(data.text)
if (aVisibles(sugerencias).length === 0) {
setEstado({
fase: 'error',
mensaje: 'No se detectaron datos legibles en la imagen. Intenta con una foto más nítida.',
})
} else {
setEstado({ fase: 'listo', sugerencias })
}
} finally {
await worker.terminate()
}
} catch {
setEstado({ fase: 'error', mensaje: 'No fue posible ejecutar el OCR. Verifica tu conexión e inténtalo de nuevo.' })
}
}
const aplicar = (s: SugerenciaVisible) => {
if (!s.aplicable) return
onAplicar({ [s.clave]: s.valor } as CamposAplicables)
setAplicados((prev) => [...prev, s.clave])
}
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex items-center justify-between gap-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Lectura automática (OCR)</p>
<button
type="button"
onClick={() => void leer()}
disabled={estado.fase === 'procesando'}
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-40"
>
{estado.fase === 'procesando' ? `Leyendo… ${estado.progreso}%` : 'Leer INE con OCR'}
</button>
</div>
{estado.fase === 'procesando' && (
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]" role="status">
Reconociendo texto de la credencial… {estado.progreso}%
</p>
)}
{estado.fase === 'error' && (
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" role="alert">
{estado.mensaje}
</p>
)}
{estado.fase === 'listo' && (
<div className="mt-3">
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Sugerencias detectadas — nada se sobreescribe sin tu confirmación:
</p>
<ul className="mt-2 space-y-2">
{aVisibles(estado.sugerencias).map((s) => (
<li key={s.clave} className="flex items-baseline justify-between gap-4 text-sm">
<span className="text-[#111827] dark:text-[#E2E8F0]">
<span className="mr-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
{s.etiqueta}
</span>
{s.valor}
</span>
{s.aplicable &&
(aplicados.includes(s.clave) ? (
<span className="rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700">✓</span>
) : (
<button
type="button"
onClick={() => aplicar(s)}
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Aplicar
</button>
))}
</li>
))}
</ul>
</div>
)}
</div>
)
}