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:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}