feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing
- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador, combobox de unidades, vigencia, PDF ANEXO A - Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE, checklist de expediente, firma digital, 5 PDFs legales - Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF, bitacora, dashboard con filtros - Landing (/): entrada al ecosistema - Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps - 123 tests vitest
This commit is contained in:
145
apps/alta-clientes/src/components/BotonOcrIne.tsx
Normal file
145
apps/alta-clientes/src/components/BotonOcrIne.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
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="border border-neutral-200 bg-white px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">Lectura automática (OCR)</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void leer()}
|
||||
disabled={estado.fase === 'procesando'}
|
||||
className="shrink-0 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white disabled:opacity-40"
|
||||
>
|
||||
{estado.fase === 'procesando' ? `Leyendo… ${estado.progreso}%` : 'Leer INE con OCR'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{estado.fase === 'procesando' && (
|
||||
<p className="mt-3 text-xs font-light text-neutral-500" role="status">
|
||||
Reconociendo texto de la credencial… {estado.progreso}%
|
||||
</p>
|
||||
)}
|
||||
|
||||
{estado.fase === 'error' && (
|
||||
<p className="mt-3 border border-red-400 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 font-light text-neutral-500">
|
||||
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="font-light text-neutral-900">
|
||||
<span className="mr-2 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
{s.etiqueta}
|
||||
</span>
|
||||
{s.valor}
|
||||
</span>
|
||||
{s.aplicable &&
|
||||
(aplicados.includes(s.clave) ? (
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-accent">✓</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => aplicar(s)}
|
||||
className="shrink-0 border border-neutral-900 bg-transparent px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Aplicar
|
||||
</button>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user