Files
FRASA-CAS/apps/cobranza/src/components/ModalAbono.tsx
Consult AS Technology 599dce2629 style: reskin NovaSpark en las 7 apps
Del look editorial (verde #1B4332, esquinas a 0, botones en mayusculas)
al sistema NovaSpark: primario azul #2563EB, detalle naranja #F97316,
fondo #F8FAFC con tarjetas blancas rounded-xl + shadow-sm, chips
rounded-full semanticos, focus ring azul, transiciones 100-200ms.
Incluye headers de tabla en los PDFs (cotizador y estado de cuenta).
2026-09-03 02:35:34 +00:00

142 lines
5.4 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-medium text-[#4B5563]'
/** 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 rounded-2xl border border-[#E5E7EB] bg-white p-8 shadow-xl"
>
<h2 id="titulo-abono" className="text-xl font-light text-[#111827]">
Registrar abono — Pago #{pago.numPago}
</h2>
<p className="mt-2 text-sm text-[#64748B]">
Restante: <span className="font-bold text-[#111827]">{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 rounded-lg border bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 ${errors.monto ? 'border-red-400' : 'border-[#D1D5DB]'}`}
{...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 rounded-lg border bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 ${errors.fechaPago ? 'border-red-400' : 'border-[#D1D5DB]'}`}
{...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 rounded-lg border bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 ${errors.referencia ? 'border-red-400' : 'border-[#D1D5DB]'}`}
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="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Cancelar
</button>
<button
type="submit"
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
>
Guardar abono
</button>
</div>
</form>
</div>
</div>
)
}