Files
FRASA-CAS/apps/cobranza/src/components/ModalAbono.tsx
Consult AS Technology ecd4edac02 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
2026-08-21 22:27:36 +00:00

142 lines
5.2 KiB
TypeScript

import { useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { Pago } from '../types'
import { fechaISOHoy, formatoMoneda } from '../lib/formatters'
import { round2 } from '../lib/cobranza'
function esquemaAbono(restante: number) {
return z.object({
monto: z
.number({ invalid_type_error: 'Captura un monto válido' })
.positive('El monto debe ser mayor a 0')
.max(restante, `No puede exceder el restante (${formatoMoneda(restante)})`),
fechaPago: z.string().min(1, 'La fecha es requerida'),
referencia: z.string().trim().min(3, 'Referencia requerida (mín. 3 caracteres)'),
})
}
type AbonoForm = z.infer<ReturnType<typeof esquemaAbono>>
interface Props {
pago: Pago
onCancelar: () => void
onGuardar: (datos: AbonoForm) => void
}
const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutral-500'
/** Modal para registrar un abono (total o parcial) a un pago del plan */
export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
const restante = round2(pago.monto - pago.montoPagado)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<AbonoForm>({
resolver: zodResolver(esquemaAbono(restante)),
defaultValues: { monto: restante, fechaPago: fechaISOHoy(), referencia: '' },
})
// Foco al primer input al abrir
const refMonto = useRef<HTMLInputElement | null>(null)
useEffect(() => {
refMonto.current?.focus()
}, [])
// Escape cierra el modal
useEffect(() => {
const alTeclar = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancelar()
}
window.addEventListener('keydown', alTeclar)
return () => window.removeEventListener('keydown', alTeclar)
}, [onCancelar])
const regMonto = register('monto', { valueAsNumber: true })
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="titulo-abono"
className="w-full max-w-md border border-neutral-900 bg-white p-8"
>
<h2 id="titulo-abono" className="text-xl font-light text-neutral-900">
Registrar abono — Pago #{pago.numPago}
</h2>
<p className="mt-2 text-sm text-neutral-500">
Restante: <span className="font-bold text-neutral-900">{formatoMoneda(restante)}</span>
</p>
<form
className="mt-8 space-y-6"
onSubmit={(e) => {
void handleSubmit(onGuardar)(e)
}}
>
<div>
<label htmlFor="abono-monto" className={claseLabel}>
Monto (MXN)
</label>
<input
id="abono-monto"
type="number"
step="0.01"
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.monto ? 'border-red-400' : 'border-neutral-300'}`}
{...regMonto}
ref={(el) => {
regMonto.ref(el)
refMonto.current = el
}}
/>
{errors.monto && <p className="mt-1 text-xs text-red-700">{errors.monto.message}</p>}
</div>
<div>
<label htmlFor="abono-fecha" className={claseLabel}>
Fecha de pago
</label>
<input
id="abono-fecha"
type="date"
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.fechaPago ? 'border-red-400' : 'border-neutral-300'}`}
{...register('fechaPago')}
/>
{errors.fechaPago && <p className="mt-1 text-xs text-red-700">{errors.fechaPago.message}</p>}
</div>
<div>
<label htmlFor="abono-referencia" className={claseLabel}>
Referencia bancaria / folio
</label>
<input
id="abono-referencia"
type="text"
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.referencia ? 'border-red-400' : 'border-neutral-300'}`}
placeholder="SPEI-000123"
{...register('referencia')}
/>
{errors.referencia && <p className="mt-1 text-xs text-red-700">{errors.referencia.message}</p>}
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onCancelar}
aria-label="Cancelar y cerrar el registro de abono"
className="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"
>
Cancelar
</button>
<button
type="submit"
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
>
Guardar abono
</button>
</div>
</form>
</div>
</div>
)
}