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:
18
apps/cobranza/src/components/BadgeStatus.tsx
Normal file
18
apps/cobranza/src/components/BadgeStatus.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { StatusPago } from '../types'
|
||||
|
||||
const CLASES: Record<StatusPago, string> = {
|
||||
PENDIENTE: 'border-neutral-300 text-neutral-500',
|
||||
PAGADO: 'border-green-300 text-green-700',
|
||||
VENCIDO: 'border-red-300 text-red-700',
|
||||
PARCIAL: 'border-amber-300 text-amber-700',
|
||||
}
|
||||
|
||||
export default function BadgeStatus({ status }: { status: StatusPago }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block border bg-white px-2 py-0.5 text-xs font-bold uppercase tracking-wider ${CLASES[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
11
apps/cobranza/src/components/KpiCard.tsx
Normal file
11
apps/cobranza/src/components/KpiCard.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
|
||||
return (
|
||||
<div className="border border-neutral-200 bg-white p-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-neutral-900">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-neutral-500">{detalle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
apps/cobranza/src/components/Layout.tsx
Normal file
38
apps/cobranza/src/components/Layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-neutral-900">
|
||||
<header className="border-b border-neutral-200">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
||||
<Link to="/" className="text-sm tracking-widest">
|
||||
<span className="font-bold">FRASA</span>{' '}
|
||||
<span className="font-light text-neutral-500">COBRANZA</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-neutral-500 transition-colors duration-150 hover:text-neutral-900"
|
||||
>
|
||||
Cobranza
|
||||
</Link>
|
||||
<Link
|
||||
to="/hoy"
|
||||
className="border border-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||
>
|
||||
Cobranza del día
|
||||
</Link>
|
||||
<Link
|
||||
to="/nuevo"
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
>
|
||||
Nuevo plan
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-16">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
45
apps/cobranza/src/components/Semaforo.tsx
Normal file
45
apps/cobranza/src/components/Semaforo.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { diasParaProximoVencimiento, statusPago, statusPlan } from '../lib/cobranza'
|
||||
|
||||
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS'
|
||||
|
||||
const ETIQUETA: Record<Semaforo, string> = {
|
||||
VERDE: 'Al corriente',
|
||||
AMBAR: 'Por vencer / parcial',
|
||||
ROJO: 'Vencido',
|
||||
GRIS: 'Cerrado',
|
||||
}
|
||||
|
||||
const CLASE_DOT: Record<Semaforo, string> = {
|
||||
VERDE: 'border-green-300 bg-white text-green-700',
|
||||
AMBAR: 'border-amber-300 bg-white text-amber-700',
|
||||
ROJO: 'border-red-300 bg-white text-red-700',
|
||||
GRIS: 'border-neutral-300 bg-white text-neutral-500',
|
||||
}
|
||||
|
||||
/**
|
||||
* Semáforo del plan:
|
||||
* - gris: LIQUIDADO o CANCELADO
|
||||
* - rojo: tiene pagos VENCIDOS
|
||||
* - ámbar: tiene un pago PARCIAL o el próximo vencimiento es en 7 días o menos
|
||||
* - verde: al corriente
|
||||
*/
|
||||
export function semaforoPlan(plan: PlanPago, hoy: Date = new Date()): Semaforo {
|
||||
if (statusPlan(plan) !== 'ACTIVO') return 'GRIS'
|
||||
const estados = plan.pagos.map((p) => statusPago(p, hoy))
|
||||
if (estados.includes('VENCIDO')) return 'ROJO'
|
||||
if (estados.includes('PARCIAL')) return 'AMBAR'
|
||||
const dias = diasParaProximoVencimiento(plan, hoy)
|
||||
if (dias !== null && dias <= 7) return 'AMBAR'
|
||||
return 'VERDE'
|
||||
}
|
||||
|
||||
export default function SemaforoDot({ valor }: { valor: Semaforo }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block border px-2 py-0.5 text-xs font-bold uppercase tracking-wider ${CLASE_DOT[valor]}`}
|
||||
>
|
||||
{ETIQUETA[valor]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user