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).
142 lines
5.4 KiB
TypeScript
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>
|
|
)
|
|
}
|