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,359 @@
import { useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { PlanPago, StatusPlan } from '../types'
import { cargarPlanes } from '../services/repository'
import {
alCorriente,
avancePlan,
diasAtraso,
parseFechaLocal,
proximoVencimiento,
round2,
saldoPlan,
statusPlan,
sumarMeses,
} from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
import KpiCard from '../components/KpiCard'
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']
const COLORES_PIE = ['#1B4332', '#d4d4d4']
type ColumnaOrden = 'saldo' | 'vencimiento' | 'atraso' | 'avance'
const COLUMNAS_ORDEN: ColumnaOrden[] = ['saldo', 'vencimiento', 'atraso', 'avance']
function esColumnaOrden(valor: string | null): valor is ColumnaOrden {
return valor !== null && (COLUMNAS_ORDEN as string[]).includes(valor)
}
function claveMes(fecha: Date): string {
return `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`
}
export default function DashboardPage() {
const [planes] = useState<PlanPago[]>(() => cargarPlanes())
// Filtros y ordenamiento persistidos en la URL para que sean compartibles
const [searchParams, setSearchParams] = useSearchParams()
const busqueda = searchParams.get('q') ?? ''
const filtroStatus = (searchParams.get('status') ?? 'TODOS') as StatusPlan | 'TODOS'
const filtroProyecto = searchParams.get('proyecto') ?? 'TODOS'
const paramOrden = searchParams.get('sort')
const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
const hoy = useMemo(() => new Date(), [])
const actualizarParams = (cambios: Record<string, string | null>) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
for (const [clave, valor] of Object.entries(cambios)) {
if (valor === null || valor === '') next.delete(clave)
else next.set(clave, valor)
}
return next
},
{ replace: true },
)
}
const alternarOrden = (columna: ColumnaOrden) => {
// Click sobre la columna activa alterna asc/desc; sobre otra, empieza en asc
if (orden === columna) {
actualizarParams({ sort: columna, dir: dir === 'asc' ? 'desc' : 'asc' })
} else {
actualizarParams({ sort: columna, dir: 'asc' })
}
}
const proyectos = useMemo(
() => [...new Set(planes.map((p) => p.proyecto))].sort((a, b) => a.localeCompare(b)),
[planes],
)
const derivados = useMemo(
() =>
planes.map((plan) => ({
plan,
status: statusPlan(plan),
saldo: saldoPlan(plan),
avance: avancePlan(plan),
corriente: alCorriente(plan, hoy),
proximo: proximoVencimiento(plan, hoy),
atraso: diasAtraso(plan, hoy),
semaforo: semaforoPlan(plan, hoy),
})),
[planes, hoy],
)
const activos = derivados.filter((d) => d.status === 'ACTIVO')
const alCorrienteCount = activos.filter((d) => d.corriente).length
const conAtraso = activos.length - alCorrienteCount
const saldoTotal = round2(
derivados.filter((d) => d.status !== 'CANCELADO').reduce((acc, d) => acc + d.saldo, 0),
)
const mesActual = claveMes(hoy)
const cobradoEsteMes = round2(
planes
.flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones))
.filter((a) => a.fechaPago.startsWith(mesActual))
.reduce((acc, a) => acc + a.monto, 0),
)
const datosPie = [
{ name: 'Al corriente', value: alCorrienteCount },
{ name: 'Con atraso', value: conAtraso },
]
const datosBarras = useMemo(() => {
const aplicaciones = planes.flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones))
const meses: { clave: string; etiqueta: string }[] = []
for (let i = 5; i >= 0; i--) {
const f = sumarMeses(new Date(hoy.getFullYear(), hoy.getMonth(), 1), -i)
meses.push({ clave: claveMes(f), etiqueta: MESES_CORTOS[f.getMonth()] })
}
return meses.map(({ clave, etiqueta }) => ({
mes: etiqueta,
cobrado: round2(
aplicaciones.filter((a) => a.fechaPago.startsWith(clave)).reduce((acc, a) => acc + a.monto, 0),
),
}))
}, [planes, hoy])
const filtrados = derivados.filter((d) => {
if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false
if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false
const texto = busqueda.trim().toLowerCase()
if (!texto) return true
return (
d.plan.clienteNombre.toLowerCase().includes(texto) ||
d.plan.unidadCodigo.toLowerCase().includes(texto)
)
})
const valorOrden = (d: (typeof filtrados)[number]): number => {
switch (orden) {
case 'saldo':
return d.saldo
case 'vencimiento':
// Planes cubiertos (sin próximo vencimiento) siempre al final
return d.proximo ? parseFechaLocal(d.proximo.fechaVencimiento).getTime() : Number.MAX_SAFE_INTEGER
case 'atraso':
return d.atraso
case 'avance':
return d.avance
default:
return 0
}
}
const ordenados = (() => {
if (!orden) return filtrados
const factor = dir === 'desc' ? -1 : 1
return [...filtrados].sort((a, b) => (valorOrden(a) - valorOrden(b)) * factor)
})()
const encabezadoOrdenable = (columna: ColumnaOrden, titulo: string, alineadoDerecha = false) => (
<th scope="col" className={`py-3 pr-4 ${alineadoDerecha ? 'text-right' : ''}`}>
<button
type="button"
onClick={() => alternarOrden(columna)}
aria-label={`Ordenar por ${titulo}`}
className="inline-flex items-center gap-1 font-bold uppercase tracking-widest transition-colors duration-150 hover:text-neutral-900"
>
{titulo}
{orden === columna && <span aria-hidden="true">{dir === 'asc' ? '↑' : '↓'}</span>}
</button>
</th>
)
return (
<div className="space-y-16">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-light text-neutral-900">Módulo Cobranza — Planes de enganche</h1>
<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>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
<KpiCard titulo="Planes activos" valor={activos.length} />
<KpiCard
titulo="Al corriente / con atraso"
valor={`${alCorrienteCount} / ${conAtraso}`}
detalle="Solo planes activos"
/>
<KpiCard titulo="Saldo por cobrar" valor={formatoMoneda(saldoTotal)} />
<KpiCard titulo="Cobrado este mes" valor={formatoMoneda(cobradoEsteMes)} />
</div>
{/* Gráficas */}
<div className="grid gap-6 lg:grid-cols-2">
<div className="border border-neutral-200 bg-white p-6">
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
Al corriente vs vencidos
</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={datosPie}
dataKey="value"
nameKey="name"
innerRadius={50}
outerRadius={80}
strokeWidth={0}
label
>
{datosPie.map((_, i) => (
<Cell key={i} fill={COLORES_PIE[i % COLORES_PIE.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
<div className="border border-neutral-200 bg-white p-6">
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
Cobrado por mes (últimos 6 meses)
</h2>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={datosBarras}>
<CartesianGrid stroke="#e5e5e5" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="mes" tickLine={false} axisLine={{ stroke: '#e5e5e5' }} />
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${(v / 1000).toLocaleString('es-MX')}k`}
/>
<Tooltip formatter={(v) => formatoMoneda(Number(v))} cursor={{ fill: '#fafafa' }} />
<Bar dataKey="cobrado" fill="#1B4332" name="Cobrado" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Filtros */}
<div className="flex flex-wrap items-center gap-4">
<label htmlFor="filtro-busqueda" className="sr-only">
Buscar por cliente o unidad
</label>
<input
id="filtro-busqueda"
type="search"
value={busqueda}
onChange={(e) => actualizarParams({ q: e.target.value })}
placeholder="Buscar por cliente o unidad…"
className="w-64 border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
/>
<label htmlFor="filtro-status" className="sr-only">
Filtrar por status
</label>
<select
id="filtro-status"
value={filtroStatus}
onChange={(e) => actualizarParams({ status: e.target.value === 'TODOS' ? null : e.target.value })}
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
>
<option value="TODOS">Todos los status</option>
<option value="ACTIVO">ACTIVO</option>
<option value="LIQUIDADO">LIQUIDADO</option>
<option value="CANCELADO">CANCELADO</option>
</select>
<label htmlFor="filtro-proyecto" className="sr-only">
Filtrar por proyecto
</label>
<select
id="filtro-proyecto"
value={filtroProyecto}
onChange={(e) => actualizarParams({ proyecto: e.target.value === 'TODOS' ? null : e.target.value })}
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
>
<option value="TODOS">Todos los proyectos</option>
{proyectos.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
{/* Tabla de planes */}
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
<th scope="col" className="py-3 pr-4">Cliente</th>
<th scope="col" className="py-3 pr-4">Unidad</th>
<th scope="col" className="py-3 pr-4">Proyecto</th>
{encabezadoOrdenable('avance', '% pagado')}
{encabezadoOrdenable('vencimiento', 'Próx. vencimiento')}
{encabezadoOrdenable('saldo', 'Saldo', true)}
{encabezadoOrdenable('atraso', 'Días atraso', true)}
<th scope="col" className="py-3">Estado</th>
</tr>
</thead>
<tbody>
{ordenados.map((d) => (
<tr key={d.plan.id} className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
<td className="py-3 pr-4">
<Link to={`/planes/${d.plan.id}`} className="font-medium text-accent hover:underline">
{d.plan.clienteNombre}
</Link>
</td>
<td className="py-3 pr-4">{d.plan.unidadCodigo}</td>
<td className="py-3 pr-4">{d.plan.proyecto}</td>
<td className="py-3 pr-4">
<div className="flex items-center gap-2">
<div className="h-1 w-24 bg-neutral-200">
<div
className="h-full bg-accent"
style={{ width: `${Math.min(100, d.avance * 100)}%` }}
/>
</div>
<span className="text-xs text-neutral-500">{formatoPorcentaje(d.avance)}</span>
</div>
</td>
<td className="py-3 pr-4">
{d.proximo ? formatoFechaCorta(d.proximo.fechaVencimiento) : '—'}
</td>
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(d.saldo)}</td>
<td className="py-3 pr-4 text-right">
{d.atraso > 0 ? <span className="text-red-700">{d.atraso}</span> : '0'}
</td>
<td className="py-3">
<SemaforoDot valor={d.semaforo} />
</td>
</tr>
))}
{ordenados.length === 0 && (
<tr>
<td colSpan={8} className="py-8 text-center text-neutral-500">
No hay planes que coincidan con el filtro.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}