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:
141
apps/cobranza/src/components/ModalAbono.tsx
Normal file
141
apps/cobranza/src/components/ModalAbono.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user