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:
9
apps/cobranza/.dockerignore
Normal file
9
apps/cobranza/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
coverage
|
||||
.vscode
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
5
apps/cobranza/.gitignore
vendored
Normal file
5
apps/cobranza/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
14
apps/cobranza/Dockerfile
Normal file
14
apps/cobranza/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# Etapa 1: build
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Etapa 2: servidor estático
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
apps/cobranza/index.html
Normal file
12
apps/cobranza/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FRASA Desarrollos — Cobranza</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
apps/cobranza/nginx.conf
Normal file
18
apps/cobranza/nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback: cualquier ruta no existente sirve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Assets con hash: cache agresivo
|
||||
location /pay/assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
4525
apps/cobranza/package-lock.json
generated
Normal file
4525
apps/cobranza/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
apps/cobranza/package.json
Normal file
34
apps/cobranza/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "cobranza",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
124
apps/cobranza/src/data/seed.ts
Normal file
124
apps/cobranza/src/data/seed.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { generarCalendario, registrarAbono, sumarMeses } from '../lib/cobranza'
|
||||
import { uuid } from '../lib/uuid'
|
||||
|
||||
function aISO(fecha: Date): string {
|
||||
const y = fecha.getFullYear()
|
||||
const m = String(fecha.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(fecha.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
interface DatosPlan {
|
||||
clienteNombre: string
|
||||
clienteTelefono?: string
|
||||
unidadCodigo: string
|
||||
proyecto: string
|
||||
ventaRef: string
|
||||
fechaInicio: string
|
||||
engancheTotal: number
|
||||
numPagos: number
|
||||
tasaMoraMensual?: number
|
||||
}
|
||||
|
||||
function crearPlanBase(datos: DatosPlan): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario(datos.fechaInicio, datos.engancheTotal, datos.numPagos)
|
||||
return {
|
||||
id: uuid(),
|
||||
ventaRef: datos.ventaRef,
|
||||
clienteNombre: datos.clienteNombre,
|
||||
clienteTelefono: datos.clienteTelefono,
|
||||
unidadCodigo: datos.unidadCodigo,
|
||||
proyecto: datos.proyecto,
|
||||
fechaInicio: datos.fechaInicio,
|
||||
engancheTotal: datos.engancheTotal,
|
||||
numPagos: datos.numPagos,
|
||||
mensualidad,
|
||||
tasaMoraMensual: datos.tasaMoraMensual,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Abona por número de pago (1..N); `monto` por defecto cubre el pago completo */
|
||||
function abonar(plan: PlanPago, numPago: number, fechaPago: string, referencia: string, monto?: number): PlanPago {
|
||||
const pago = plan.pagos[numPago - 1]
|
||||
return registrarAbono(plan, {
|
||||
pagoId: pago.id,
|
||||
fechaPago,
|
||||
monto: monto ?? pago.monto - pago.montoPagado,
|
||||
referencia,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 4 planes de ejemplo con fechas relativas a `hoy`, para que el demo siempre
|
||||
* muestre todos los estados: al corriente, con vencidos, con parcial y liquidado.
|
||||
*/
|
||||
export function generarSeed(hoy: Date = new Date()): PlanPago[] {
|
||||
// 1. Al corriente: 6 pagos desde hace 3 meses, los primeros 3 pagados
|
||||
let alCorriente = crearPlanBase({
|
||||
clienteNombre: 'María Fernanda López Ríos',
|
||||
clienteTelefono: '55 1234 8890',
|
||||
unidadCodigo: 'MB-204',
|
||||
proyecto: 'RESIDENCIAL MONTEBELLO',
|
||||
ventaRef: 'VTA-2026-014',
|
||||
fechaInicio: aISO(sumarMeses(hoy, -3)),
|
||||
engancheTotal: 180000,
|
||||
numPagos: 6,
|
||||
})
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
alCorriente = abonar(alCorriente, i, alCorriente.pagos[i - 1].fechaVencimiento, `SPEI-MB204-0${i}`)
|
||||
}
|
||||
|
||||
// 2. Con 2 pagos vencidos: 6 pagos desde hace 5 meses, solo 3 pagados
|
||||
let conVencidos = crearPlanBase({
|
||||
clienteNombre: 'Jorge Alberto Camacho Vega',
|
||||
clienteTelefono: '55 8842 1177',
|
||||
unidadCodigo: 'U-101',
|
||||
proyecto: 'SENDA',
|
||||
ventaRef: 'VTA-2025-089',
|
||||
fechaInicio: aISO(sumarMeses(hoy, -5)),
|
||||
engancheTotal: 150000,
|
||||
numPagos: 6,
|
||||
tasaMoraMensual: 2,
|
||||
})
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
conVencidos = abonar(conVencidos, i, conVencidos.pagos[i - 1].fechaVencimiento, `SPEI-U101-0${i}`)
|
||||
}
|
||||
|
||||
// 3. Con un pago PARCIAL: 4 pagos desde hace 2 meses; pago 1 completo, pago 2 con abono parcial ya vencido
|
||||
let conParcial = crearPlanBase({
|
||||
clienteNombre: 'Ana Sofía Guillén Torres',
|
||||
clienteTelefono: '55 6021 3345',
|
||||
unidadCodigo: 'DG-302',
|
||||
proyecto: 'DIAGONAL',
|
||||
ventaRef: 'VTA-2026-021',
|
||||
fechaInicio: aISO(sumarMeses(hoy, -2)),
|
||||
engancheTotal: 120000,
|
||||
numPagos: 4,
|
||||
tasaMoraMensual: 1.5,
|
||||
})
|
||||
conParcial = abonar(conParcial, 1, conParcial.pagos[0].fechaVencimiento, 'SPEI-DG302-01')
|
||||
conParcial = abonar(conParcial, 2, aISO(sumarMeses(hoy, -1)), 'EFECTIVO-DG302-02', 10000)
|
||||
|
||||
// 4. Liquidado: 6 pagos desde hace 6 meses, todos pagados
|
||||
let liquidado = crearPlanBase({
|
||||
clienteNombre: 'Roberto Mendoza Salas',
|
||||
clienteTelefono: '55 9987 4456',
|
||||
unidadCodigo: 'MB-118',
|
||||
proyecto: 'RESIDENCIAL MONTEBELLO',
|
||||
ventaRef: 'VTA-2025-041',
|
||||
fechaInicio: aISO(sumarMeses(hoy, -6)),
|
||||
engancheTotal: 90000,
|
||||
numPagos: 6,
|
||||
})
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
liquidado = abonar(liquidado, i, liquidado.pagos[i - 1].fechaVencimiento, `SPEI-MB118-0${i}`)
|
||||
}
|
||||
liquidado = { ...liquidado, status: 'LIQUIDADO' }
|
||||
|
||||
return [alCorriente, conVencidos, conParcial, liquidado]
|
||||
}
|
||||
23
apps/cobranza/src/index.css
Normal file
23
apps/cobranza/src/index.css
Normal file
@@ -0,0 +1,23 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--color-accent: #1B4332;
|
||||
--radius-sm: 0;
|
||||
--radius-md: 0;
|
||||
--radius-lg: 0;
|
||||
--radius-xl: 0;
|
||||
--radius-2xl: 0;
|
||||
--radius-3xl: 0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-white text-neutral-900 antialiased;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid #1B4332;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
326
apps/cobranza/src/lib/cobranza.test.ts
Normal file
326
apps/cobranza/src/lib/cobranza.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Pago, PlanPago } from '../types'
|
||||
import {
|
||||
agregarNotaAPlan,
|
||||
alCorriente,
|
||||
avancePlan,
|
||||
cobranzaDelDia,
|
||||
diasAtraso,
|
||||
generarCalendario,
|
||||
moraDePago,
|
||||
pagadoTotal,
|
||||
proximoVencimiento,
|
||||
registrarAbono,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPago,
|
||||
statusPlan,
|
||||
sumarMeses,
|
||||
totalMora,
|
||||
} from './cobranza'
|
||||
|
||||
const pagoBase = (override: Partial<Pago> = {}): Pago => ({
|
||||
id: 'p1',
|
||||
numPago: 1,
|
||||
fechaVencimiento: '2026-02-15',
|
||||
monto: 10000,
|
||||
montoPagado: 0,
|
||||
aplicaciones: [],
|
||||
...override,
|
||||
})
|
||||
|
||||
describe('generarCalendario', () => {
|
||||
it('genera 12 pagos cuya suma es exactamente el enganche', () => {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-15', 180000, 12)
|
||||
expect(pagos).toHaveLength(12)
|
||||
expect(mensualidad).toBe(15000)
|
||||
const suma = pagos.reduce((acc, p) => acc + p.monto, 0)
|
||||
expect(round2(suma)).toBe(180000)
|
||||
expect(pagos[0].numPago).toBe(1)
|
||||
expect(pagos[11].numPago).toBe(12)
|
||||
expect(pagos[0].fechaVencimiento).toBe('2026-01-15')
|
||||
expect(pagos[11].fechaVencimiento).toBe('2026-12-15')
|
||||
})
|
||||
|
||||
it('el último pago absorbe el residuo del redondeo (100000 / 3)', () => {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-03-01', 100000, 3)
|
||||
expect(mensualidad).toBe(33333.33)
|
||||
expect(pagos.map((p) => p.monto)).toEqual([33333.33, 33333.33, 33333.34])
|
||||
expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(100000)
|
||||
})
|
||||
|
||||
it('ajusta el día cuando el mes no lo tiene (31 de enero → 28 de febrero)', () => {
|
||||
const { pagos } = generarCalendario('2025-01-31', 30000, 3)
|
||||
expect(pagos[0].fechaVencimiento).toBe('2025-01-31')
|
||||
expect(pagos[1].fechaVencimiento).toBe('2025-02-28') // 2025 no es bisiesto
|
||||
expect(pagos[2].fechaVencimiento).toBe('2025-03-31')
|
||||
})
|
||||
|
||||
it('respeta año bisiesto (31 de enero 2024 → 29 de febrero)', () => {
|
||||
expect(sumarMeses(new Date(2024, 0, 31), 1)).toEqual(new Date(2024, 1, 29))
|
||||
})
|
||||
|
||||
it('rechaza parámetros inválidos', () => {
|
||||
expect(() => generarCalendario('2026-01-01', 0, 6)).toThrow()
|
||||
expect(() => generarCalendario('2026-01-01', 1000, 0)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusPago', () => {
|
||||
const hoy = new Date('2026-02-16T00:00:00')
|
||||
|
||||
it('PAGADO cuando montoPagado cubre el monto', () => {
|
||||
expect(statusPago(pagoBase({ montoPagado: 10000 }), hoy)).toBe('PAGADO')
|
||||
})
|
||||
|
||||
it('PARCIAL con abono menor al monto, aunque ya venció', () => {
|
||||
expect(statusPago(pagoBase({ montoPagado: 4000 }), hoy)).toBe('PARCIAL')
|
||||
})
|
||||
|
||||
it('VENCIDO sin abono y hoy posterior al vencimiento', () => {
|
||||
expect(statusPago(pagoBase(), hoy)).toBe('VENCIDO')
|
||||
})
|
||||
|
||||
it('PENDIENTE sin abono y hoy en o antes del vencimiento', () => {
|
||||
expect(statusPago(pagoBase(), new Date('2026-02-15T00:00:00'))).toBe('PENDIENTE')
|
||||
expect(statusPago(pagoBase(), new Date('2026-02-01T00:00:00'))).toBe('PENDIENTE')
|
||||
})
|
||||
})
|
||||
|
||||
function planDePrueba(): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-10', 30000, 3)
|
||||
return {
|
||||
id: 'plan-1',
|
||||
ventaRef: 'VTA-001',
|
||||
clienteNombre: 'Cliente Prueba',
|
||||
unidadCodigo: 'U-1',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-01-10',
|
||||
engancheTotal: 30000,
|
||||
numPagos: 3,
|
||||
mensualidad,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('registrarAbono', () => {
|
||||
it('aplica un abono parcial y lo agrega al historial', () => {
|
||||
const plan = planDePrueba()
|
||||
const actualizado = registrarAbono(plan, {
|
||||
pagoId: plan.pagos[0].id,
|
||||
fechaPago: '2026-01-10',
|
||||
monto: 4000,
|
||||
referencia: 'SPEI-123',
|
||||
})
|
||||
expect(actualizado.pagos[0].montoPagado).toBe(4000)
|
||||
expect(actualizado.pagos[0].aplicaciones).toHaveLength(1)
|
||||
expect(actualizado.pagos[0].aplicaciones[0].referencia).toBe('SPEI-123')
|
||||
// no muta el plan original
|
||||
expect(plan.pagos[0].montoPagado).toBe(0)
|
||||
})
|
||||
|
||||
it('permite completar un pago con varios abonos', () => {
|
||||
let plan = planDePrueba()
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A1' })
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-11', monto: 6000, referencia: 'A2' })
|
||||
expect(plan.pagos[0].montoPagado).toBe(10000)
|
||||
expect(plan.pagos[0].aplicaciones).toHaveLength(2)
|
||||
expect(statusPago(plan.pagos[0], new Date('2026-01-12T00:00:00'))).toBe('PAGADO')
|
||||
})
|
||||
|
||||
it('rechaza abonos que exceden el restante', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() =>
|
||||
registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 10000.01, referencia: 'X1' }),
|
||||
).toThrow(/excede el restante/)
|
||||
})
|
||||
|
||||
it('rechaza abonos menores o iguales a 0', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() =>
|
||||
registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 0, referencia: 'X1' }),
|
||||
).toThrow(/mayor a 0/)
|
||||
expect(() =>
|
||||
registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: -500, referencia: 'X1' }),
|
||||
).toThrow(/mayor a 0/)
|
||||
})
|
||||
|
||||
it('rechaza un pagoId inexistente', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() =>
|
||||
registrarAbono(plan, { pagoId: 'no-existe', fechaPago: '2026-01-10', monto: 100, referencia: 'X1' }),
|
||||
).toThrow(/no encontrado/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saldo, avance y status del plan', () => {
|
||||
it('saldoPlan y pagadoTotal reflejan los abonos', () => {
|
||||
let plan = planDePrueba()
|
||||
expect(saldoPlan(plan)).toBe(30000)
|
||||
expect(pagadoTotal(plan)).toBe(0)
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A1' })
|
||||
expect(saldoPlan(plan)).toBe(20000)
|
||||
expect(pagadoTotal(plan)).toBe(10000)
|
||||
expect(avancePlan(plan)).toBeCloseTo(1 / 3, 5)
|
||||
})
|
||||
|
||||
it('el plan queda LIQUIDADO al cubrir todo el enganche', () => {
|
||||
let plan = planDePrueba()
|
||||
for (const pago of plan.pagos) {
|
||||
plan = registrarAbono(plan, {
|
||||
pagoId: pago.id,
|
||||
fechaPago: '2026-03-10',
|
||||
monto: pago.monto - pago.montoPagado,
|
||||
referencia: `LIQ-${pago.numPago}`,
|
||||
})
|
||||
}
|
||||
expect(saldoPlan(plan)).toBe(0)
|
||||
expect(statusPlan(plan)).toBe('LIQUIDADO')
|
||||
})
|
||||
|
||||
it('CANCELADO prevalece aunque tenga saldo', () => {
|
||||
const plan = { ...planDePrueba(), status: 'CANCELADO' as const }
|
||||
expect(statusPlan(plan)).toBe('CANCELADO')
|
||||
})
|
||||
|
||||
it('alCorriente es falso cuando hay pagos vencidos', () => {
|
||||
const hoy = new Date('2026-02-15T00:00:00') // pago 1 (10-ene) ya venció
|
||||
expect(alCorriente(planDePrueba(), hoy)).toBe(false)
|
||||
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('proximoVencimiento y diasAtraso', () => {
|
||||
it('devuelve el primer pago no cubierto con fecha >= hoy', () => {
|
||||
const plan = planDePrueba() // vencimientos 10-ene, 10-feb, 10-mar
|
||||
const prox = proximoVencimiento(plan, new Date('2026-01-20T00:00:00'))
|
||||
expect(prox?.numPago).toBe(2)
|
||||
})
|
||||
|
||||
it('si no hay futuros, devuelve el vencido más antiguo', () => {
|
||||
const plan = planDePrueba()
|
||||
const prox = proximoVencimiento(plan, new Date('2026-04-01T00:00:00'))
|
||||
expect(prox?.numPago).toBe(1)
|
||||
})
|
||||
|
||||
it('devuelve null cuando todo está cubierto', () => {
|
||||
let plan = planDePrueba()
|
||||
for (const pago of plan.pagos) {
|
||||
plan = registrarAbono(plan, {
|
||||
pagoId: pago.id,
|
||||
fechaPago: '2026-01-10',
|
||||
monto: pago.monto,
|
||||
referencia: 'X',
|
||||
})
|
||||
}
|
||||
expect(proximoVencimiento(plan, new Date('2026-01-15T00:00:00'))).toBeNull()
|
||||
})
|
||||
|
||||
it('diasAtraso cuenta desde el vencido más antiguo', () => {
|
||||
const plan = planDePrueba() // pago 1 vence 10-ene
|
||||
expect(diasAtraso(plan, new Date('2026-01-25T00:00:00'))).toBe(15)
|
||||
expect(diasAtraso(plan, new Date('2026-01-10T00:00:00'))).toBe(0)
|
||||
})
|
||||
|
||||
it('un pago PARCIAL vencido no cuenta como atraso', () => {
|
||||
let plan = planDePrueba()
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
|
||||
// pago 1 es PARCIAL (vencido pero con abono); pago 2 vence 10-feb
|
||||
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moraDePago y totalMora', () => {
|
||||
// pago de 10000 que venció el 15-feb; hoy = 17-mar → 30 días vencidos
|
||||
const hoy = new Date('2026-03-17T00:00:00')
|
||||
|
||||
it('es 0 cuando la tasa es 0', () => {
|
||||
expect(moraDePago(pagoBase(), 0, hoy)).toBe(0)
|
||||
})
|
||||
|
||||
it('es 0 cuando el pago no está vencido', () => {
|
||||
expect(moraDePago(pagoBase(), 2, new Date('2026-02-15T00:00:00'))).toBe(0)
|
||||
expect(moraDePago(pagoBase(), 2, new Date('2026-02-10T00:00:00'))).toBe(0)
|
||||
})
|
||||
|
||||
it('es 0 cuando el pago ya está cubierto', () => {
|
||||
expect(moraDePago(pagoBase({ montoPagado: 10000 }), 2, hoy)).toBe(0)
|
||||
})
|
||||
|
||||
it('cálculo exacto: restante × (tasa/100) / 30 × días vencidos', () => {
|
||||
// 10000 × 0.02 / 30 × 30 = 200
|
||||
expect(moraDePago(pagoBase(), 2, hoy)).toBe(200)
|
||||
// 10000 × 0.03 / 30 × 10 = 100 (10 días de atraso)
|
||||
expect(moraDePago(pagoBase(), 3, new Date('2026-02-25T00:00:00'))).toBe(100)
|
||||
})
|
||||
|
||||
it('un pago PARCIAL vencido genera mora sobre su restante', () => {
|
||||
// restante 6000 × 0.02 / 30 × 30 = 120
|
||||
expect(moraDePago(pagoBase({ montoPagado: 4000 }), 2, hoy)).toBe(120)
|
||||
})
|
||||
|
||||
it('totalMora suma la mora de todos los pagos y usa tasa 0 por defecto', () => {
|
||||
const plan = planDePrueba() // vence 10-ene, 10-feb, 10-mar; hoy 20-mar
|
||||
const hoyPlan = new Date('2026-03-20T00:00:00')
|
||||
expect(totalMora(plan, hoyPlan)).toBe(0) // sin tasa configurada
|
||||
const conTasa: PlanPago = { ...plan, tasaMoraMensual: 3 }
|
||||
// pago 1: 10000 × 0.001 × 69 = 690; pago 2: 10000 × 0.001 × 38 = 380; pago 3: 10000 × 0.001 × 10 = 100
|
||||
expect(totalMora(conTasa, hoyPlan)).toBe(1170)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cobranzaDelDia', () => {
|
||||
it('incluye vencidos y los que vencen en los próximos 7 días, ordenados por urgencia', () => {
|
||||
// plan: vencimientos 10-ene, 10-feb, 10-mar; hoy 05-mar
|
||||
const hoy = new Date('2026-03-05T00:00:00')
|
||||
const plan = planDePrueba()
|
||||
const items = cobranzaDelDia([plan], hoy)
|
||||
// pago 1 (54 días atraso), pago 2 (23 días), pago 3 (vence en 5 días)
|
||||
expect(items.map((i) => i.pago.numPago)).toEqual([1, 2, 3])
|
||||
expect(items.map((i) => i.atraso)).toEqual([54, 23, 0])
|
||||
})
|
||||
|
||||
it('excluye pagos cubiertos, planes no activos y vencimientos lejanos', () => {
|
||||
const hoy = new Date('2026-01-05T00:00:00')
|
||||
let plan = planDePrueba()
|
||||
// cubre el pago 1 (vence 10-ene, dentro de la ventana)
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-05', monto: 10000, referencia: 'A' })
|
||||
const cancelado: PlanPago = { ...planDePrueba(), status: 'CANCELADO' }
|
||||
const items = cobranzaDelDia([plan, cancelado], hoy)
|
||||
// pago 2 (10-feb) y pago 3 (10-mar) están a más de 7 días → lista vacía
|
||||
expect(items).toHaveLength(0)
|
||||
// con la ventana amplia solo aparecen los del plan activo
|
||||
const items40 = cobranzaDelDia([plan, cancelado], hoy, 40)
|
||||
expect(items40.map((i) => i.pago.numPago)).toEqual([2])
|
||||
})
|
||||
|
||||
it('un PARCIAL vencido aparece con sus días de atraso', () => {
|
||||
const hoy = new Date('2026-02-01T00:00:00')
|
||||
let plan = planDePrueba()
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A' })
|
||||
const items = cobranzaDelDia([plan], hoy)
|
||||
expect(items[0].pago.numPago).toBe(1)
|
||||
expect(items[0].atraso).toBe(22)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agregarNotaAPlan', () => {
|
||||
it('agrega la nota con texto limpio sin mutar el plan', () => {
|
||||
const plan = planDePrueba()
|
||||
const actualizado = agregarNotaAPlan(plan, ' Cliente promete pago el viernes ')
|
||||
expect(actualizado.notas).toHaveLength(1)
|
||||
expect(actualizado.notas[0].texto).toBe('Cliente promete pago el viernes')
|
||||
expect(actualizado.notas[0].id).toBeTruthy()
|
||||
expect(actualizado.notas[0].fecha).toBeTruthy()
|
||||
expect(plan.notas).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rechaza notas de menos de 3 caracteres', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() => agregarNotaAPlan(plan, ' ok ')).toThrow(/al menos 3/)
|
||||
expect(() => agregarNotaAPlan(plan, '')).toThrow(/al menos 3/)
|
||||
})
|
||||
})
|
||||
267
apps/cobranza/src/lib/cobranza.ts
Normal file
267
apps/cobranza/src/lib/cobranza.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { AplicacionPago, NotaPlan, Pago, PlanPago, StatusPago, StatusPlan } from '../types'
|
||||
import { uuid } from './uuid'
|
||||
|
||||
/** Redondeo monetario a 2 decimales */
|
||||
export const round2 = (x: number): number => Math.round(x * 100) / 100
|
||||
|
||||
/** Tolerancia para comparaciones de dinero (centavos) */
|
||||
export const EPS = 0.01
|
||||
|
||||
const gte = (a: number, b: number): boolean => a >= b - EPS
|
||||
|
||||
/** Parsea 'YYYY-MM-DD' como fecha LOCAL (nunca `new Date(iso)` a secas, que interpreta UTC) */
|
||||
export function parseFechaLocal(iso: string): Date {
|
||||
return new Date(`${iso}T00:00:00`)
|
||||
}
|
||||
|
||||
function aISO(fecha: Date): string {
|
||||
const y = fecha.getFullYear()
|
||||
const m = String(fecha.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(fecha.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
/** Último día del mes (mes 0-based) */
|
||||
function ultimoDiaDelMes(anio: number, mes: number): number {
|
||||
return new Date(anio, mes + 1, 0).getDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Suma `meses` conservando el día del mes; si el mes destino no tiene ese día,
|
||||
* usa su último día (p. ej. 31 de enero + 1 mes → 28/29 de febrero).
|
||||
*/
|
||||
export function sumarMeses(fecha: Date, meses: number): Date {
|
||||
const totalMeses = fecha.getMonth() + meses
|
||||
const anio = fecha.getFullYear() + Math.floor(totalMeses / 12)
|
||||
const mes = ((totalMeses % 12) + 12) % 12
|
||||
const dia = Math.min(fecha.getDate(), ultimoDiaDelMes(anio, mes))
|
||||
return new Date(anio, mes, dia)
|
||||
}
|
||||
|
||||
/** Compara solo la parte de fecha (ignora horas) */
|
||||
function compararFechas(a: Date, b: Date): number {
|
||||
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()
|
||||
const db = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
|
||||
return da - db
|
||||
}
|
||||
|
||||
export interface CalendarioGenerado {
|
||||
mensualidad: number
|
||||
pagos: Pago[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera N pagos con vencimiento mensual a partir de `fechaInicio`.
|
||||
* mensualidad = round2(engancheTotal / numPagos); el ÚLTIMO pago absorbe
|
||||
* el residuo del redondeo para que la suma sea exactamente `engancheTotal`.
|
||||
*/
|
||||
export function generarCalendario(
|
||||
fechaInicio: string,
|
||||
engancheTotal: number,
|
||||
numPagos: number,
|
||||
): CalendarioGenerado {
|
||||
if (numPagos < 1) throw new Error('numPagos debe ser al menos 1')
|
||||
if (engancheTotal <= 0) throw new Error('engancheTotal debe ser mayor a 0')
|
||||
const inicio = parseFechaLocal(fechaInicio)
|
||||
const mensualidad = round2(engancheTotal / numPagos)
|
||||
const pagos: Pago[] = []
|
||||
for (let i = 0; i < numPagos; i++) {
|
||||
const monto =
|
||||
i < numPagos - 1 ? mensualidad : round2(engancheTotal - mensualidad * (numPagos - 1))
|
||||
pagos.push({
|
||||
id: uuid(),
|
||||
numPago: i + 1,
|
||||
fechaVencimiento: aISO(sumarMeses(inicio, i)),
|
||||
monto,
|
||||
montoPagado: 0,
|
||||
aplicaciones: [],
|
||||
})
|
||||
}
|
||||
return { mensualidad, pagos }
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado de un pago a la fecha `hoy`.
|
||||
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
|
||||
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
|
||||
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
|
||||
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`.
|
||||
*/
|
||||
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
|
||||
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
|
||||
if (pago.montoPagado > EPS) return 'PARCIAL'
|
||||
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0 ? 'VENCIDO' : 'PENDIENTE'
|
||||
}
|
||||
|
||||
/** Saldo pendiente del plan: suma(monto - montoPagado) */
|
||||
export function saldoPlan(plan: PlanPago): number {
|
||||
return round2(plan.pagos.reduce((acc, p) => acc + (p.monto - p.montoPagado), 0))
|
||||
}
|
||||
|
||||
/** Total cobrado (suma de lo aplicado a todos los pagos) */
|
||||
export function pagadoTotal(plan: PlanPago): number {
|
||||
return round2(plan.pagos.reduce((acc, p) => acc + p.montoPagado, 0))
|
||||
}
|
||||
|
||||
/** Fracción de avance 0..1 (pagado / engancheTotal) */
|
||||
export function avancePlan(plan: PlanPago): number {
|
||||
return plan.engancheTotal > 0 ? pagadoTotal(plan) / plan.engancheTotal : 0
|
||||
}
|
||||
|
||||
const restante = (p: Pago): number => round2(p.monto - p.montoPagado)
|
||||
const estaCubierto = (p: Pago): boolean => gte(p.montoPagado, p.monto)
|
||||
|
||||
/**
|
||||
* Primer pago no cubierto con fecha >= hoy; si no hay, el vencido más antiguo.
|
||||
* Devuelve null si el plan está cubierto por completo.
|
||||
*/
|
||||
export function proximoVencimiento(plan: PlanPago, hoy: Date = new Date()): Pago | null {
|
||||
const noCubiertos = plan.pagos.filter((p) => !estaCubierto(p))
|
||||
const futuro = noCubiertos.find((p) => compararFechas(parseFechaLocal(p.fechaVencimiento), hoy) >= 0)
|
||||
if (futuro) return futuro
|
||||
const vencidos = noCubiertos.sort(
|
||||
(a, b) => parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
|
||||
)
|
||||
return vencidos[0] ?? null
|
||||
}
|
||||
|
||||
/** Días naturales hasta el próximo vencimiento; null si el plan está cubierto */
|
||||
export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date()): number | null {
|
||||
const prox = proximoVencimiento(plan, hoy)
|
||||
if (!prox) return null
|
||||
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
|
||||
}
|
||||
|
||||
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */
|
||||
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
const vencidos = plan.pagos
|
||||
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
|
||||
)
|
||||
const primero = vencidos[0]
|
||||
if (!primero) return 0
|
||||
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
|
||||
}
|
||||
|
||||
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
|
||||
export function statusPlan(plan: PlanPago): StatusPlan {
|
||||
if (plan.status === 'CANCELADO') return 'CANCELADO'
|
||||
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
|
||||
}
|
||||
|
||||
/** Al corriente = sin pagos VENCIDOS */
|
||||
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
|
||||
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mora simple de un pago: restante × (tasa% / 100) / 30 × días vencidos.
|
||||
* Solo aplica a pagos vencidos con saldo; un PARCIAL vencido también genera
|
||||
* mora sobre su restante. Tasa 0 (o negativa) = sin mora.
|
||||
*/
|
||||
export function moraDePago(pago: Pago, tasaMoraMensual: number, hoy: Date = new Date()): number {
|
||||
if (tasaMoraMensual <= 0) return 0
|
||||
const falta = restante(pago)
|
||||
if (falta <= EPS) return 0
|
||||
const diasVencido = Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000)
|
||||
if (diasVencido <= 0) return 0
|
||||
return round2((falta * (tasaMoraMensual / 100) / 30) * diasVencido)
|
||||
}
|
||||
|
||||
/** Mora acumulada del plan a la fecha `hoy` (usa plan.tasaMoraMensual ?? 0) */
|
||||
export function totalMora(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
const tasa = plan.tasaMoraMensual ?? 0
|
||||
return round2(plan.pagos.reduce((acc, p) => acc + moraDePago(p, tasa, hoy), 0))
|
||||
}
|
||||
|
||||
export interface CobranzaItem {
|
||||
plan: PlanPago
|
||||
pago: Pago
|
||||
/** Días de atraso (0 si aún no vence) */
|
||||
atraso: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
|
||||
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
|
||||
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual
|
||||
* atraso, el vencimiento más próximo primero.
|
||||
*/
|
||||
export function cobranzaDelDia(
|
||||
planes: PlanPago[],
|
||||
hoy: Date = new Date(),
|
||||
diasAnticipacion = 7,
|
||||
): CobranzaItem[] {
|
||||
const limite = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + diasAnticipacion)
|
||||
const items: CobranzaItem[] = []
|
||||
for (const plan of planes) {
|
||||
if (statusPlan(plan) !== 'ACTIVO') continue
|
||||
for (const pago of plan.pagos) {
|
||||
if (estaCubierto(pago)) continue
|
||||
if (compararFechas(parseFechaLocal(pago.fechaVencimiento), limite) > 0) continue
|
||||
const atraso = Math.max(
|
||||
0,
|
||||
Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000),
|
||||
)
|
||||
items.push({ plan, pago, atraso })
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
if (a.atraso !== b.atraso) return b.atraso - a.atraso
|
||||
return compararFechas(
|
||||
parseFechaLocal(a.pago.fechaVencimiento),
|
||||
parseFechaLocal(b.pago.fechaVencimiento),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega una nota a la bitácora del plan (copia inmutable).
|
||||
* El texto es requerido (mín. 3 caracteres).
|
||||
*/
|
||||
export function agregarNotaAPlan(plan: PlanPago, texto: string): PlanPago {
|
||||
const limpio = texto.trim()
|
||||
if (limpio.length < 3) {
|
||||
throw new Error('La nota debe tener al menos 3 caracteres')
|
||||
}
|
||||
const nota: NotaPlan = { id: uuid(), fecha: new Date().toISOString(), texto: limpio }
|
||||
return { ...plan, notas: [...(plan.notas ?? []), nota] }
|
||||
}
|
||||
|
||||
export interface AbonoInput {
|
||||
pagoId: string
|
||||
fechaPago: string // ISO
|
||||
monto: number
|
||||
referencia: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica un abono al pago indicado y devuelve una copia del plan actualizada.
|
||||
* Rechaza montos <= 0 y abonos que excedan el restante del pago.
|
||||
*/
|
||||
export function registrarAbono(plan: PlanPago, abono: AbonoInput): PlanPago {
|
||||
if (round2(abono.monto) <= 0) {
|
||||
throw new Error('El abono debe ser mayor a 0')
|
||||
}
|
||||
const pago = plan.pagos.find((p) => p.id === abono.pagoId)
|
||||
if (!pago) throw new Error('Pago no encontrado en el plan')
|
||||
const falta = restante(pago)
|
||||
// La validación es estricta al centavo: un abono nunca puede exceder el restante
|
||||
if (round2(abono.monto - falta) > 0) {
|
||||
throw new Error(`El abono excede el restante del pago (${falta.toFixed(2)})`)
|
||||
}
|
||||
const aplicacion: AplicacionPago = {
|
||||
id: uuid(),
|
||||
fechaPago: abono.fechaPago,
|
||||
monto: round2(abono.monto),
|
||||
referencia: abono.referencia,
|
||||
}
|
||||
const pagos = plan.pagos.map((p) =>
|
||||
p.id === abono.pagoId
|
||||
? { ...p, montoPagado: round2(p.montoPagado + abono.monto), aplicaciones: [...p.aplicaciones, aplicacion] }
|
||||
: p,
|
||||
)
|
||||
return { ...plan, pagos }
|
||||
}
|
||||
36
apps/cobranza/src/lib/formatters.ts
Normal file
36
apps/cobranza/src/lib/formatters.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const formatoMXN = new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
})
|
||||
|
||||
/** Formato monetario MXN, p. ej. $4,579,000.00 */
|
||||
export function formatoMoneda(valor: number): string {
|
||||
return formatoMXN.format(valor)
|
||||
}
|
||||
|
||||
/** Fracción (0.75) a texto porcentaje (75%) */
|
||||
export function formatoPorcentaje(fraccion: number): string {
|
||||
return `${(fraccion * 100).toLocaleString('es-MX', { maximumFractionDigits: 2 })}%`
|
||||
}
|
||||
|
||||
/** Fecha en formato largo es-MX: "20 de agosto de 2026" */
|
||||
export function formatoFechaLarga(fecha: Date): string {
|
||||
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
/** ISO 'YYYY-MM-DD' a fecha legible corta: "08/10/2027" */
|
||||
export function formatoFechaCorta(iso: string): string {
|
||||
return new Date(`${iso}T00:00:00`).toLocaleDateString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
/** Fecha ISO del día (para nombre de archivo): 'YYYY-MM-DD' */
|
||||
export function fechaISOHoy(fecha: Date = new Date()): string {
|
||||
const y = fecha.getFullYear()
|
||||
const m = String(fecha.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(fecha.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
49
apps/cobranza/src/lib/pdf/estadoCuenta.test.ts
Normal file
49
apps/cobranza/src/lib/pdf/estadoCuenta.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PlanPago } from '../../types'
|
||||
import { generarCalendario, registrarAbono } from '../cobranza'
|
||||
import { crearDocumento } from './estadoCuenta'
|
||||
|
||||
function planFixture(): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3)
|
||||
let plan: PlanPago = {
|
||||
id: 'plan-fixture',
|
||||
ventaRef: 'VTA-2026-001',
|
||||
clienteNombre: 'Cliente Fixture',
|
||||
unidadCodigo: 'U-101',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-01-15',
|
||||
engancheTotal: 100000,
|
||||
numPagos: 3,
|
||||
mensualidad,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
plan = registrarAbono(plan, {
|
||||
pagoId: plan.pagos[0].id,
|
||||
fechaPago: '2026-01-15',
|
||||
monto: plan.pagos[0].monto,
|
||||
referencia: 'SPEI-001',
|
||||
})
|
||||
return plan
|
||||
}
|
||||
|
||||
describe('estadoCuenta PDF', () => {
|
||||
it('genera el documento con nombre de archivo correcto', () => {
|
||||
const hoy = new Date('2026-02-20T12:00:00')
|
||||
const { doc, nombreArchivo } = crearDocumento(planFixture(), hoy)
|
||||
expect(nombreArchivo).toBe('estado_cuenta_U-101_2026-02-20.pdf')
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||
// smoke: el contenido interno es un PDF válido
|
||||
expect(doc.output('datauristring')).toContain('application/pdf')
|
||||
})
|
||||
|
||||
it('smoke: genera el documento con fila de mora cuando hay tasa y pagos vencidos', () => {
|
||||
const hoy = new Date('2026-04-20T12:00:00') // pagos 2 y 3 vencidos
|
||||
const plan: PlanPago = { ...planFixture(), tasaMoraMensual: 2 }
|
||||
const { doc } = crearDocumento(plan, hoy)
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||
expect(doc.output('datauristring')).toContain('application/pdf')
|
||||
})
|
||||
})
|
||||
112
apps/cobranza/src/lib/pdf/estadoCuenta.ts
Normal file
112
apps/cobranza/src/lib/pdf/estadoCuenta.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { PlanPago } from '../../types'
|
||||
import { avancePlan, pagadoTotal, round2, saldoPlan, statusPago, totalMora } from '../cobranza'
|
||||
import { fechaISOHoy, formatoFechaCorta, formatoFechaLarga, formatoMoneda, formatoPorcentaje } from '../formatters'
|
||||
|
||||
const MARGEN = 14
|
||||
|
||||
// jspdf-autotable expone finalY en doc.lastAutoTable en runtime; sus tipos no lo declaran
|
||||
type DocConTabla = jsPDF & { lastAutoTable: { finalY: number } }
|
||||
const finalY = (doc: jsPDF): number => (doc as DocConTabla).lastAutoTable.finalY
|
||||
|
||||
export interface DocumentoPdf {
|
||||
doc: jsPDF
|
||||
nombreArchivo: string
|
||||
}
|
||||
|
||||
/** Construye el estado de cuenta del plan de enganche (sin descargarlo) */
|
||||
export function crearDocumento(plan: PlanPago, hoy: Date = new Date()): DocumentoPdf {
|
||||
const doc = new jsPDF()
|
||||
let y = MARGEN
|
||||
|
||||
// 1. Encabezado
|
||||
doc.setFontSize(14)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text('FRASA DESARROLLOS', 105, y, { align: 'center' })
|
||||
y += 7
|
||||
doc.setFontSize(11)
|
||||
doc.text('ESTADO DE CUENTA — PLAN DE ENGANCHE', 105, y, { align: 'center' })
|
||||
y += 6
|
||||
doc.setFontSize(9)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.text(`Fecha de emisión: ${formatoFechaLarga(hoy)}`, 105, y, { align: 'center' })
|
||||
y += 8
|
||||
|
||||
// 2. Datos del plan
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 8 },
|
||||
head: [['CLIENTE', 'UNIDAD', 'PROYECTO', 'VENTA', 'ENGANCHE', 'MENSUALIDAD', 'PAGOS']],
|
||||
body: [[
|
||||
plan.clienteNombre,
|
||||
plan.unidadCodigo,
|
||||
plan.proyecto,
|
||||
plan.ventaRef || '—',
|
||||
formatoMoneda(plan.engancheTotal),
|
||||
formatoMoneda(plan.mensualidad),
|
||||
String(plan.numPagos),
|
||||
]],
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
// 3. Tabla de pagos
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 8 },
|
||||
head: [['#', 'VENCIMIENTO', 'MONTO', 'PAGADO', 'RESTANTE', 'STATUS', 'REFERENCIAS']],
|
||||
body: plan.pagos.map((p) => [
|
||||
String(p.numPago),
|
||||
formatoFechaCorta(p.fechaVencimiento),
|
||||
formatoMoneda(p.monto),
|
||||
formatoMoneda(p.montoPagado),
|
||||
formatoMoneda(round2(p.monto - p.montoPagado)),
|
||||
statusPago(p, hoy),
|
||||
p.aplicaciones
|
||||
.map((a) => `${a.referencia} (${formatoFechaCorta(a.fechaPago)})`)
|
||||
.join('\n') || '—',
|
||||
]),
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
// 4. Totales
|
||||
const pagado = pagadoTotal(plan)
|
||||
const saldo = saldoPlan(plan)
|
||||
const mora = totalMora(plan, hoy)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 9 },
|
||||
body: [
|
||||
['TOTAL PAGADO', formatoMoneda(pagado)],
|
||||
['SALDO PENDIENTE', formatoMoneda(saldo)],
|
||||
...(mora > 0
|
||||
? [
|
||||
['MORA ACUMULADA', formatoMoneda(mora)],
|
||||
['TOTAL A PAGAR', formatoMoneda(round2(saldo + mora))],
|
||||
]
|
||||
: []),
|
||||
['% AVANCE', formatoPorcentaje(avancePlan(plan))],
|
||||
],
|
||||
theme: 'plain',
|
||||
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 60 }, 1: { halign: 'left' } },
|
||||
})
|
||||
y = finalY(doc) + 10
|
||||
|
||||
// 5. Pie
|
||||
doc.setFontSize(8)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.text('Documento informativo. Para aclaraciones contacta a tu asesor de ventas.', MARGEN, y)
|
||||
|
||||
return { doc, nombreArchivo: `estado_cuenta_${plan.unidadCodigo}_${fechaISOHoy(hoy)}.pdf` }
|
||||
}
|
||||
|
||||
/** Genera y descarga el estado de cuenta en PDF */
|
||||
export function descargar(plan: PlanPago): void {
|
||||
const { doc, nombreArchivo } = crearDocumento(plan)
|
||||
doc.save(nombreArchivo)
|
||||
}
|
||||
50
apps/cobranza/src/lib/pdf/reciboAbono.test.ts
Normal file
50
apps/cobranza/src/lib/pdf/reciboAbono.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PlanPago } from '../../types'
|
||||
import { generarCalendario, registrarAbono } from '../cobranza'
|
||||
import { crearDocumento, folioRecibo } from './reciboAbono'
|
||||
|
||||
function planConAbono(): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3)
|
||||
const plan: PlanPago = {
|
||||
id: 'plan-fixture',
|
||||
ventaRef: 'VTA-2026-001',
|
||||
clienteNombre: 'Cliente Fixture',
|
||||
clienteTelefono: '55 0000 0000',
|
||||
unidadCodigo: 'U-101',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-01-15',
|
||||
engancheTotal: 100000,
|
||||
numPagos: 3,
|
||||
mensualidad,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
return registrarAbono(plan, {
|
||||
pagoId: plan.pagos[0].id,
|
||||
fechaPago: '2026-01-15',
|
||||
monto: 5000,
|
||||
referencia: 'SPEI-001',
|
||||
})
|
||||
}
|
||||
|
||||
describe('reciboAbono PDF', () => {
|
||||
it('el folio son los primeros 8 chars del id de la aplicación en mayúsculas', () => {
|
||||
const plan = planConAbono()
|
||||
const aplicacion = plan.pagos[0].aplicaciones[0]
|
||||
expect(folioRecibo(aplicacion)).toBe(aplicacion.id.slice(0, 8).toUpperCase())
|
||||
expect(folioRecibo({ ...aplicacion, id: 'abcdef12-3456-7890' })).toBe('ABCDEF12')
|
||||
})
|
||||
|
||||
it('genera el documento con nombre de archivo correcto', () => {
|
||||
const plan = planConAbono()
|
||||
const pago = plan.pagos[0]
|
||||
const aplicacion = pago.aplicaciones[0]
|
||||
const { doc, nombreArchivo } = crearDocumento(plan, pago, aplicacion, new Date('2026-02-20T12:00:00'))
|
||||
expect(nombreArchivo).toBe(`recibo_U-101_${folioRecibo(aplicacion)}.pdf`)
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||
// smoke: el contenido interno es un PDF válido
|
||||
expect(doc.output('datauristring')).toContain('application/pdf')
|
||||
})
|
||||
})
|
||||
106
apps/cobranza/src/lib/pdf/reciboAbono.ts
Normal file
106
apps/cobranza/src/lib/pdf/reciboAbono.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { AplicacionPago, Pago, PlanPago } from '../../types'
|
||||
import { round2 } from '../cobranza'
|
||||
import { formatoFechaCorta, formatoFechaLarga, formatoMoneda } from '../formatters'
|
||||
import type { DocumentoPdf } from './estadoCuenta'
|
||||
|
||||
const MARGEN = 14
|
||||
|
||||
/** Folio corto del recibo: primeros 8 caracteres del id de la aplicación, en mayúsculas */
|
||||
export function folioRecibo(aplicacion: AplicacionPago): string {
|
||||
return aplicacion.id.slice(0, 8).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el recibo de pago de una aplicación (abono) ya registrada.
|
||||
* El restante del pago y el saldo del plan se calculan acumulando las
|
||||
* aplicaciones registradas hasta esa aplicación (inclusive), de modo que el
|
||||
* recibo refleja los montos vigentes justo tras el abono.
|
||||
*/
|
||||
export function crearDocumento(
|
||||
plan: PlanPago,
|
||||
pago: Pago,
|
||||
aplicacion: AplicacionPago,
|
||||
hoy: Date = new Date(),
|
||||
): DocumentoPdf {
|
||||
const folio = folioRecibo(aplicacion)
|
||||
const doc = new jsPDF()
|
||||
let y = MARGEN
|
||||
|
||||
// Aplicado acumulado (plan y pago) hasta la aplicación indicada, en orden de registro
|
||||
let aplicadoPlan = 0
|
||||
let aplicadoPago = 0
|
||||
let encontrada = false
|
||||
for (const p of plan.pagos) {
|
||||
for (const a of p.aplicaciones) {
|
||||
aplicadoPlan += a.monto
|
||||
if (p.id === pago.id) aplicadoPago += a.monto
|
||||
if (a.id === aplicacion.id) {
|
||||
encontrada = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (encontrada) break
|
||||
}
|
||||
const saldoTrasAbono = round2(plan.engancheTotal - aplicadoPlan)
|
||||
const restanteTrasAbono = round2(pago.monto - aplicadoPago)
|
||||
|
||||
// 1. Encabezado
|
||||
doc.setFontSize(14)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text('FRASA DESARROLLOS', 105, y, { align: 'center' })
|
||||
y += 7
|
||||
doc.setFontSize(11)
|
||||
doc.text('RECIBO DE PAGO', 105, y, { align: 'center' })
|
||||
y += 6
|
||||
doc.setFontSize(9)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.text(`Folio: ${folio} · Fecha de emisión: ${formatoFechaLarga(hoy)}`, 105, y, { align: 'center' })
|
||||
y += 8
|
||||
|
||||
// 2. Datos del recibo
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 9 },
|
||||
body: [
|
||||
['CLIENTE', plan.clienteNombre],
|
||||
['UNIDAD', plan.unidadCodigo],
|
||||
['PROYECTO', plan.proyecto],
|
||||
['PAGO', `#${pago.numPago} (vencimiento ${formatoFechaCorta(pago.fechaVencimiento)})`],
|
||||
['FECHA DE PAGO', formatoFechaCorta(aplicacion.fechaPago)],
|
||||
['MONTO DEL ABONO', formatoMoneda(aplicacion.monto)],
|
||||
['REFERENCIA', aplicacion.referencia || '—'],
|
||||
['RESTANTE DEL PAGO', formatoMoneda(restanteTrasAbono)],
|
||||
['SALDO DEL PLAN TRAS EL ABONO', formatoMoneda(saldoTrasAbono)],
|
||||
],
|
||||
theme: 'plain',
|
||||
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { halign: 'left' } },
|
||||
})
|
||||
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 10
|
||||
|
||||
// 3. Leyenda informativa
|
||||
doc.setFontSize(8)
|
||||
doc.text(
|
||||
'Recibo informativo del abono aplicado al plan de enganche. No sustituye la factura ni el comprobante fiscal correspondiente.',
|
||||
MARGEN,
|
||||
y,
|
||||
{ maxWidth: 180 },
|
||||
)
|
||||
y += 24
|
||||
|
||||
// 4. Línea de firma
|
||||
doc.setFontSize(9)
|
||||
doc.text('_______________________________', 105, y, { align: 'center' })
|
||||
y += 5
|
||||
doc.text(`Recibió — ${plan.clienteNombre}`, 105, y, { align: 'center' })
|
||||
|
||||
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
|
||||
}
|
||||
|
||||
/** Genera y descarga el recibo de pago en PDF */
|
||||
export function descargarRecibo(plan: PlanPago, pago: Pago, aplicacion: AplicacionPago): void {
|
||||
const { doc, nombreArchivo } = crearDocumento(plan, pago, aplicacion)
|
||||
doc.save(nombreArchivo)
|
||||
}
|
||||
14
apps/cobranza/src/lib/uuid.ts
Normal file
14
apps/cobranza/src/lib/uuid.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Genera un UUID v4. crypto.randomUUID solo existe en contextos seguros
|
||||
* (HTTPS o localhost); al servir por IP en HTTP hay que usar el fallback.
|
||||
*/
|
||||
export function uuid(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
29
apps/cobranza/src/main.tsx
Normal file
29
apps/cobranza/src/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
import '@fontsource/inter/700.css'
|
||||
import './index.css'
|
||||
import Layout from './components/Layout'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import HoyPage from './pages/HoyPage'
|
||||
import PlanDetallePage from './pages/PlanDetallePage'
|
||||
import NuevoPlanPage from './pages/NuevoPlanPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename="/pay">
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/hoy" element={<HoyPage />} />
|
||||
<Route path="/planes/:id" element={<PlanDetallePage />} />
|
||||
<Route path="/nuevo" element={<NuevoPlanPage />} />
|
||||
<Route path="*" element={<DashboardPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
359
apps/cobranza/src/pages/DashboardPage.tsx
Normal file
359
apps/cobranza/src/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
apps/cobranza/src/pages/HoyPage.tsx
Normal file
102
apps/cobranza/src/pages/HoyPage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { PlanPago } from '../types'
|
||||
import { cargarPlanes } from '../services/repository'
|
||||
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
|
||||
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
|
||||
export default function HoyPage() {
|
||||
const [planes] = useState<PlanPago[]>(() => cargarPlanes())
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy])
|
||||
|
||||
const vencidos = items.filter((i) => i.atraso > 0)
|
||||
const porVencer = items.length - vencidos.length
|
||||
const totalPorCobrar = round2(
|
||||
items.reduce((acc, i) => acc + (i.pago.monto - i.pago.montoPagado), 0),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div>
|
||||
<h1 className="text-3xl font-light text-neutral-900">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-3">
|
||||
<KpiCard titulo="Pagos vencidos" valor={vencidos.length} />
|
||||
<KpiCard titulo="Por vencer (7 días)" valor={porVencer} />
|
||||
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
|
||||
</div>
|
||||
|
||||
<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">Teléfono</th>
|
||||
<th scope="col" className="py-3 pr-4">Unidad</th>
|
||||
<th scope="col" className="py-3 pr-4"># pago</th>
|
||||
<th scope="col" className="py-3 pr-4">Vencimiento</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Días atraso</th>
|
||||
<th scope="col" className="py-3">Plan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(({ plan, pago, atraso }) => (
|
||||
<tr
|
||||
key={`${plan.id}-${pago.id}`}
|
||||
className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50"
|
||||
>
|
||||
<td className="py-3 pr-4 font-medium">{plan.clienteNombre}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{plan.clienteTelefono ? (
|
||||
<a href={`tel:${plan.clienteTelefono.replace(/\s/g, '')}`} className="text-accent hover:underline">
|
||||
{plan.clienteTelefono}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{plan.unidadCodigo}</td>
|
||||
<td className="py-3 pr-4">{pago.numPago}</td>
|
||||
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">
|
||||
{formatoMoneda(round2(pago.monto - pago.montoPagado))}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
{atraso > 0 ? (
|
||||
<span className="font-medium text-red-700">{atraso}</span>
|
||||
) : (
|
||||
<span className="text-amber-700">
|
||||
{statusPago(pago, hoy) === 'PARCIAL' ? 'Parcial' : 'Por vencer'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Link to={`/planes/${plan.id}`} className="text-accent hover:underline">
|
||||
Ver plan
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-8 text-center text-neutral-500">
|
||||
No hay pagos vencidos ni vencimientos en los próximos 7 días.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
apps/cobranza/src/pages/NuevoPlanPage.tsx
Normal file
213
apps/cobranza/src/pages/NuevoPlanPage.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { crearPlan } from '../services/repository'
|
||||
import { generarCalendario } from '../lib/cobranza'
|
||||
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
|
||||
const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL'] as const
|
||||
|
||||
const esquema = z.object({
|
||||
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
|
||||
clienteTelefono: z.string().trim().optional(),
|
||||
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
|
||||
proyecto: z.enum(PROYECTOS),
|
||||
ventaRef: z.string().trim(),
|
||||
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
|
||||
engancheTotal: z
|
||||
.number({ invalid_type_error: 'Captura un monto válido' })
|
||||
.positive('El enganche debe ser mayor a 0'),
|
||||
numPagos: z
|
||||
.number({ invalid_type_error: 'Captura un número válido' })
|
||||
.int('Debe ser un número entero')
|
||||
.min(1, 'Mínimo 1 pago')
|
||||
.max(36, 'Máximo 36 pagos'),
|
||||
tasaMoraMensual: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : Number(v)),
|
||||
z
|
||||
.number({ invalid_type_error: 'Captura una tasa válida' })
|
||||
.min(0, 'La tasa no puede ser negativa')
|
||||
.max(30, 'Tasa máxima 30%')
|
||||
.optional(),
|
||||
),
|
||||
})
|
||||
|
||||
type NuevoPlanForm = z.infer<typeof esquema>
|
||||
|
||||
const claseInput =
|
||||
'mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
|
||||
const claseError = 'mt-1 text-xs text-red-700'
|
||||
const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
|
||||
export default function NuevoPlanPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<NuevoPlanForm>({
|
||||
resolver: zodResolver(esquema),
|
||||
defaultValues: {
|
||||
clienteNombre: '',
|
||||
unidadCodigo: '',
|
||||
proyecto: 'SENDA',
|
||||
ventaRef: '',
|
||||
fechaInicio: fechaISOHoy(),
|
||||
numPagos: 6,
|
||||
},
|
||||
})
|
||||
|
||||
const [fechaInicio, engancheTotal, numPagos] = watch(['fechaInicio', 'engancheTotal', 'numPagos'])
|
||||
|
||||
// Vista previa en vivo del calendario; solo si los valores son válidos
|
||||
let vistaPrevia: ReturnType<typeof generarCalendario> | null = null
|
||||
try {
|
||||
if (fechaInicio && engancheTotal > 0 && Number.isInteger(numPagos) && numPagos >= 1 && numPagos <= 36) {
|
||||
vistaPrevia = generarCalendario(fechaInicio, engancheTotal, numPagos)
|
||||
}
|
||||
} catch {
|
||||
vistaPrevia = null
|
||||
}
|
||||
|
||||
const guardar = (datos: NuevoPlanForm) => {
|
||||
const plan = crearPlan(datos)
|
||||
void navigate(`/planes/${plan.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-16">
|
||||
<h1 className="text-3xl font-light text-neutral-900">Nuevo plan de enganche</h1>
|
||||
|
||||
<form
|
||||
className="border border-neutral-200 bg-white p-8"
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(guardar)(e)
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="clienteNombre" className={claseLabel}>Cliente *</label>
|
||||
<input id="clienteNombre" type="text" className={claseInput} {...register('clienteNombre')} />
|
||||
{errors.clienteNombre && <p className={claseError}>{errors.clienteNombre.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="clienteTelefono" className={claseLabel}>Teléfono de contacto</label>
|
||||
<input
|
||||
id="clienteTelefono"
|
||||
type="tel"
|
||||
className={claseInput}
|
||||
placeholder="55 1234 5678"
|
||||
{...register('clienteTelefono')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="unidadCodigo" className={claseLabel}>Unidad *</label>
|
||||
<input id="unidadCodigo" type="text" className={claseInput} placeholder="U-101" {...register('unidadCodigo')} />
|
||||
{errors.unidadCodigo && <p className={claseError}>{errors.unidadCodigo.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
|
||||
<select id="proyecto" className={claseInput} {...register('proyecto')}>
|
||||
{PROYECTOS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>
|
||||
<input id="ventaRef" type="text" className={claseInput} placeholder="VTA-2026-001" {...register('ventaRef')} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fechaInicio" className={claseLabel}>Primer vencimiento *</label>
|
||||
<input id="fechaInicio" type="date" className={claseInput} {...register('fechaInicio')} />
|
||||
{errors.fechaInicio && <p className={claseError}>{errors.fechaInicio.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="engancheTotal" className={claseLabel}>Enganche total (MXN) *</label>
|
||||
<input
|
||||
id="engancheTotal"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className={claseInput}
|
||||
{...register('engancheTotal', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.engancheTotal && <p className={claseError}>{errors.engancheTotal.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="numPagos" className={claseLabel}>Número de pagos *</label>
|
||||
<input
|
||||
id="numPagos"
|
||||
type="number"
|
||||
step="1"
|
||||
className={claseInput}
|
||||
{...register('numPagos', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.numPagos && <p className={claseError}>{errors.numPagos.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tasaMoraMensual" className={claseLabel}>Tasa de mora mensual (%)</label>
|
||||
<input
|
||||
id="tasaMoraMensual"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className={claseInput}
|
||||
placeholder="0 = sin mora"
|
||||
{...register('tasaMoraMensual')}
|
||||
/>
|
||||
{errors.tasaMoraMensual && <p className={claseError}>{errors.tasaMoraMensual.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vista previa del calendario */}
|
||||
{vistaPrevia && (
|
||||
<div className="mt-8 border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Vista previa — {vistaPrevia.pagos.length} pagos de {formatoMoneda(vistaPrevia.mensualidad)}
|
||||
</h2>
|
||||
<div className="mt-4 max-h-48 overflow-y-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-neutral-500">
|
||||
<tr className="border-b border-neutral-900">
|
||||
<th scope="col" className="py-1">#</th>
|
||||
<th scope="col" className="py-1">Vencimiento</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vistaPrevia.pagos.map((p) => (
|
||||
<tr key={p.numPago} className="border-b border-neutral-200">
|
||||
<td className="py-1">{p.numPago}</td>
|
||||
<td className="py-1">{formatoFechaCorta(p.fechaVencimiento)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(p.monto)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate('/')}
|
||||
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 plan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
324
apps/cobranza/src/pages/PlanDetallePage.tsx
Normal file
324
apps/cobranza/src/pages/PlanDetallePage.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import type { Pago, PlanPago } from '../types'
|
||||
import { agregarNota, cancelarPlan, obtenerPlan, registrarAbonoEnPlan } from '../services/repository'
|
||||
import {
|
||||
avancePlan,
|
||||
diasAtraso,
|
||||
moraDePago,
|
||||
pagadoTotal,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPago,
|
||||
statusPlan,
|
||||
totalMora,
|
||||
} from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||
import { descargar } from '../lib/pdf/estadoCuenta'
|
||||
import { descargarRecibo } from '../lib/pdf/reciboAbono'
|
||||
import BadgeStatus from '../components/BadgeStatus'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import ModalAbono from '../components/ModalAbono'
|
||||
|
||||
export default function PlanDetallePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [plan, setPlan] = useState<PlanPago | undefined>(() => (id ? obtenerPlan(id) : undefined))
|
||||
const [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
|
||||
const [expandidos, setExpandidos] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notaTexto, setNotaTexto] = useState('')
|
||||
const [errorNota, setErrorNota] = useState<string | null>(null)
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="border border-neutral-200 bg-white p-16 text-center">
|
||||
<p className="text-neutral-500">Plan no encontrado.</p>
|
||||
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
|
||||
Volver al dashboard
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = statusPlan(plan)
|
||||
const saldo = saldoPlan(plan)
|
||||
const atraso = diasAtraso(plan, hoy)
|
||||
const tasaMora = plan.tasaMoraMensual ?? 0
|
||||
const mora = totalMora(plan, hoy)
|
||||
|
||||
const toggleExpandido = (pagoId: string) => {
|
||||
setExpandidos((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(pagoId)) next.delete(pagoId)
|
||||
else next.add(pagoId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const guardarAbono = (datos: { monto: number; fechaPago: string; referencia: string }) => {
|
||||
if (!pagoAbono) return
|
||||
try {
|
||||
const actualizado = registrarAbonoEnPlan(plan.id, datos, pagoAbono.id)
|
||||
setPlan(actualizado)
|
||||
setPagoAbono(null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error al registrar el abono')
|
||||
}
|
||||
}
|
||||
|
||||
const cancelar = () => {
|
||||
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
|
||||
setPlan(cancelarPlan(plan.id))
|
||||
}
|
||||
|
||||
const guardarNota = () => {
|
||||
try {
|
||||
setPlan(agregarNota(plan.id, notaTexto))
|
||||
setNotaTexto('')
|
||||
setErrorNota(null)
|
||||
} catch (e) {
|
||||
setErrorNota(e instanceof Error ? e.message : 'Error al guardar la nota')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="flex flex-wrap items-start justify-between gap-6">
|
||||
<div>
|
||||
<Link to="/" className="text-sm text-accent hover:underline">
|
||||
← Volver al dashboard
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-light text-neutral-900">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
||||
<span className="font-bold text-neutral-900">{status}</span>
|
||||
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Tel. {plan.clienteTelefono || '—'}
|
||||
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => descargar(plan)}
|
||||
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"
|
||||
>
|
||||
Estado de cuenta PDF
|
||||
</button>
|
||||
{status === 'ACTIVO' && saldo > 0 && (
|
||||
<button
|
||||
onClick={cancelar}
|
||||
className="border border-red-300 bg-white px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-700 hover:text-white"
|
||||
>
|
||||
Cancelar plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* KPIs del plan */}
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-5">
|
||||
<KpiCard titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
|
||||
<KpiCard titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
|
||||
<KpiCard titulo="Saldo" valor={formatoMoneda(saldo)} />
|
||||
<KpiCard titulo="% avance" valor={formatoPorcentaje(avancePlan(plan))} />
|
||||
<KpiCard titulo="Mensualidad" valor={formatoMoneda(plan.mensualidad)} detalle={`${plan.numPagos} pagos`} />
|
||||
{mora > 0 && (
|
||||
<KpiCard
|
||||
titulo="Mora acumulada"
|
||||
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
|
||||
detalle={`Tasa ${tasaMora}% mensual`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabla de pagos */}
|
||||
<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">#</th>
|
||||
<th scope="col" className="py-3 pr-4">Vencimiento</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Pagado</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
|
||||
{tasaMora > 0 && <th scope="col" className="py-3 pr-4 text-right">Mora</th>}
|
||||
<th scope="col" className="py-3 pr-4">Status</th>
|
||||
<th scope="col" className="py-3 pr-4">Abonos</th>
|
||||
<th scope="col" className="py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.pagos.map((pago) => {
|
||||
const st = statusPago(pago, hoy)
|
||||
const restante = round2(pago.monto - pago.montoPagado)
|
||||
const moraPago = tasaMora > 0 ? moraDePago(pago, tasaMora, hoy) : 0
|
||||
const expandido = expandidos.has(pago.id)
|
||||
return (
|
||||
<Fragment key={pago.id}>
|
||||
<tr className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
|
||||
<td className="py-3 pr-4 font-medium">{pago.numPago}</td>
|
||||
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.montoPagado)}</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(restante)}</td>
|
||||
{tasaMora > 0 && (
|
||||
<td className="py-3 pr-4 text-right">
|
||||
{moraPago > 0 ? (
|
||||
<span className="text-red-700">{formatoMoneda(moraPago)}</span>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-3 pr-4">
|
||||
<BadgeStatus status={st} />
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{pago.aplicaciones.length > 0 ? (
|
||||
<button
|
||||
onClick={() => toggleExpandido(pago.id)}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{expandido ? 'Ocultar' : `Ver (${pago.aplicaciones.length})`}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
{st !== 'PAGADO' && status === 'ACTIVO' && (
|
||||
<button
|
||||
onClick={() => setPagoAbono(pago)}
|
||||
className="bg-accent px-4 py-2 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
>
|
||||
Registrar abono
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandido && (
|
||||
<tr className="border-b border-neutral-200 bg-neutral-50">
|
||||
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-1">Fecha</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
<th scope="col" className="py-1">Referencia</th>
|
||||
<th scope="col" className="py-1 text-right">Recibo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pago.aplicaciones.map((a) => (
|
||||
<tr key={a.id} className="border-t border-neutral-200">
|
||||
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
|
||||
<td className="py-1">{a.referencia}</td>
|
||||
<td className="py-1 text-right">
|
||||
<button
|
||||
onClick={() => descargarRecibo(plan, pago, a)}
|
||||
aria-label={`Descargar recibo PDF del abono ${a.referencia}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Recibo PDF
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Bitácora del plan */}
|
||||
<section className="border border-neutral-200 bg-white p-8" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Bitácora
|
||||
</h2>
|
||||
{(plan.notas ?? []).length === 0 ? (
|
||||
<p className="mt-6 text-sm text-neutral-500">Sin notas registradas.</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-4">
|
||||
{[...(plan.notas ?? [])]
|
||||
.sort((a, b) => b.fecha.localeCompare(a.fecha))
|
||||
.map((nota) => (
|
||||
<li key={nota.id} className="border-l-2 border-neutral-200 pl-4">
|
||||
<p className="text-xs text-neutral-500">
|
||||
{new Date(nota.fecha).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-900">{nota.texto}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
className="mt-8"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
guardarNota()
|
||||
}}
|
||||
>
|
||||
<label
|
||||
htmlFor="nota-texto"
|
||||
className="block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||
>
|
||||
Nueva nota
|
||||
</label>
|
||||
<textarea
|
||||
id="nota-texto"
|
||||
value={notaTexto}
|
||||
onChange={(e) => setNotaTexto(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Gestión realizada, acuerdos con el cliente…"
|
||||
className="mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
/>
|
||||
{errorNota && <p className="mt-1 text-xs text-red-700">{errorNota}</p>}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={notaTexto.trim().length < 3}
|
||||
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"
|
||||
>
|
||||
Agregar nota
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{status === 'CANCELADO' && (
|
||||
<button
|
||||
onClick={() => void navigate('/')}
|
||||
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"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pagoAbono && (
|
||||
<ModalAbono pago={pagoAbono} onCancelar={() => setPagoAbono(null)} onGuardar={guardarAbono} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
apps/cobranza/src/services/api.ts
Normal file
11
apps/cobranza/src/services/api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { cargarPlanes } from './repository'
|
||||
|
||||
/**
|
||||
* Devuelve los planes de pago registrados.
|
||||
* TODO: reemplazar por fetch('/api/planes') cuando el API Gateway esté disponible
|
||||
* TODO: cron de vencidos y envío WhatsApp van en el API Gateway (fase posterior)
|
||||
*/
|
||||
export async function getPlanes(): Promise<PlanPago[]> {
|
||||
return cargarPlanes()
|
||||
}
|
||||
116
apps/cobranza/src/services/repository.test.ts
Normal file
116
apps/cobranza/src/services/repository.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CLAVE_STORAGE,
|
||||
actualizarPlan,
|
||||
agregarNota,
|
||||
cargarPlanes,
|
||||
cancelarPlan,
|
||||
crearPlan,
|
||||
guardarPlanes,
|
||||
obtenerPlan,
|
||||
registrarAbonoEnPlan,
|
||||
} from './repository'
|
||||
import type { PlanPago } from '../types'
|
||||
|
||||
/** Stub sencillo de localStorage para correr en entorno node */
|
||||
function crearStorageStub(): Pick<Storage, 'getItem' | 'setItem'> {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => mapa.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
mapa.set(k, v)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('repository (localStorage)', () => {
|
||||
it('siembra datos de ejemplo cuando el storage está vacío', () => {
|
||||
const storage = crearStorageStub()
|
||||
const planes = cargarPlanes(storage)
|
||||
expect(planes.length).toBe(4)
|
||||
expect(storage.getItem(CLAVE_STORAGE)).not.toBeNull()
|
||||
// segunda carga lee del storage, no vuelve a sembrar
|
||||
expect(cargarPlanes(storage)).toEqual(planes)
|
||||
})
|
||||
|
||||
it('round-trip: guardarPlanes → cargarPlanes devuelve lo mismo', () => {
|
||||
const storage = crearStorageStub()
|
||||
const planes = cargarPlanes(storage)
|
||||
const modificados = planes.map((p) => ({ ...p, clienteNombre: `${p.clienteNombre} X` }))
|
||||
guardarPlanes(modificados, storage)
|
||||
expect(cargarPlanes(storage)).toEqual(modificados)
|
||||
})
|
||||
|
||||
it('crearPlan persiste y obtiene el plan por id', () => {
|
||||
const storage = crearStorageStub()
|
||||
cargarPlanes(storage)
|
||||
const plan = crearPlan(
|
||||
{
|
||||
clienteNombre: 'Nuevo Cliente',
|
||||
unidadCodigo: 'U-999',
|
||||
proyecto: 'DIAGONAL',
|
||||
ventaRef: '',
|
||||
fechaInicio: '2026-09-01',
|
||||
engancheTotal: 60000,
|
||||
numPagos: 6,
|
||||
},
|
||||
storage,
|
||||
)
|
||||
expect(plan.mensualidad).toBe(10000)
|
||||
expect(plan.pagos).toHaveLength(6)
|
||||
expect(obtenerPlan(plan.id, storage)).toEqual(plan)
|
||||
expect(cargarPlanes(storage)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('registrarAbonoEnPlan aplica el abono y deriva LIQUIDADO al cubrir todo', () => {
|
||||
const storage = crearStorageStub()
|
||||
cargarPlanes(storage)
|
||||
let plan = crearPlan(
|
||||
{
|
||||
clienteNombre: 'Cliente Liquida',
|
||||
unidadCodigo: 'U-500',
|
||||
proyecto: 'SENDA',
|
||||
ventaRef: '',
|
||||
fechaInicio: '2026-09-01',
|
||||
engancheTotal: 20000,
|
||||
numPagos: 2,
|
||||
},
|
||||
storage,
|
||||
)
|
||||
plan = registrarAbonoEnPlan(plan.id, { fechaPago: '2026-09-01', monto: 10000, referencia: 'A1' }, plan.pagos[0].id, storage)
|
||||
expect(plan.status).toBe('ACTIVO')
|
||||
plan = registrarAbonoEnPlan(plan.id, { fechaPago: '2026-10-01', monto: 10000, referencia: 'A2' }, plan.pagos[1].id, storage)
|
||||
expect(plan.status).toBe('LIQUIDADO')
|
||||
expect(obtenerPlan(plan.id, storage)?.status).toBe('LIQUIDADO')
|
||||
})
|
||||
|
||||
it('actualizarPlan y cancelarPlan persisten los cambios', () => {
|
||||
const storage = crearStorageStub()
|
||||
const [primero] = cargarPlanes(storage)
|
||||
actualizarPlan({ ...primero, ventaRef: 'VTA-EDIT' }, storage)
|
||||
expect(obtenerPlan(primero.id, storage)?.ventaRef).toBe('VTA-EDIT')
|
||||
cancelarPlan(primero.id, storage)
|
||||
expect(obtenerPlan(primero.id, storage)?.status).toBe('CANCELADO')
|
||||
})
|
||||
|
||||
it('migra planes viejos sin notas agregando notas: []', () => {
|
||||
const storage = crearStorageStub()
|
||||
const [primero] = cargarPlanes(storage)
|
||||
// Simula un registro guardado con una versión anterior del modelo
|
||||
const viejo = { ...primero } as Partial<PlanPago>
|
||||
delete viejo.notas
|
||||
storage.setItem(CLAVE_STORAGE, JSON.stringify([viejo]))
|
||||
const [migrado] = cargarPlanes(storage)
|
||||
expect(migrado.notas).toEqual([])
|
||||
})
|
||||
|
||||
it('agregarNota persiste la nota en la bitácora del plan', () => {
|
||||
const storage = crearStorageStub()
|
||||
const [primero] = cargarPlanes(storage)
|
||||
const actualizado = agregarNota(primero.id, 'Se habló con el cliente', storage)
|
||||
expect(actualizado.notas).toHaveLength(1)
|
||||
expect(actualizado.notas[0].texto).toBe('Se habló con el cliente')
|
||||
expect(obtenerPlan(primero.id, storage)?.notas).toHaveLength(1)
|
||||
expect(() => agregarNota(primero.id, 'ok', storage)).toThrow(/al menos 3/)
|
||||
})
|
||||
})
|
||||
115
apps/cobranza/src/services/repository.ts
Normal file
115
apps/cobranza/src/services/repository.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { generarSeed } from '../data/seed'
|
||||
import { generarCalendario, registrarAbono, statusPlan, agregarNotaAPlan, type AbonoInput } from '../lib/cobranza'
|
||||
import { uuid } from '../lib/uuid'
|
||||
|
||||
export const CLAVE_STORAGE = 'frasa_cobranza_v1'
|
||||
|
||||
// Inyectable para pruebas (stub en vitest, donde no hay localStorage)
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
|
||||
|
||||
function storageDefault(): StorageLike {
|
||||
return globalThis.localStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga los planes desde localStorage; si está vacío siembra los datos de ejemplo.
|
||||
* Migra planes guardados con versiones anteriores del modelo (sin `notas`).
|
||||
* TODO: reemplazar por GET /api/planes cuando el API Gateway esté disponible.
|
||||
*/
|
||||
export function cargarPlanes(storage: StorageLike = storageDefault()): PlanPago[] {
|
||||
const crudo = storage.getItem(CLAVE_STORAGE)
|
||||
if (crudo) {
|
||||
const planes = JSON.parse(crudo) as PlanPago[]
|
||||
return planes.map((p) => (p.notas ? p : { ...p, notas: [] }))
|
||||
}
|
||||
const planes = generarSeed()
|
||||
guardarPlanes(planes, storage)
|
||||
return planes
|
||||
}
|
||||
|
||||
export function guardarPlanes(planes: PlanPago[], storage: StorageLike = storageDefault()): void {
|
||||
storage.setItem(CLAVE_STORAGE, JSON.stringify(planes))
|
||||
}
|
||||
|
||||
export function obtenerPlan(id: string, storage: StorageLike = storageDefault()): PlanPago | undefined {
|
||||
return cargarPlanes(storage).find((p) => p.id === id)
|
||||
}
|
||||
|
||||
export interface NuevoPlanInput {
|
||||
clienteNombre: string
|
||||
clienteTelefono?: string
|
||||
unidadCodigo: string
|
||||
proyecto: string
|
||||
ventaRef: string
|
||||
fechaInicio: string
|
||||
engancheTotal: number
|
||||
numPagos: number
|
||||
tasaMoraMensual?: number
|
||||
}
|
||||
|
||||
export function crearPlan(input: NuevoPlanInput, storage: StorageLike = storageDefault()): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario(input.fechaInicio, input.engancheTotal, input.numPagos)
|
||||
const plan: PlanPago = {
|
||||
id: uuid(),
|
||||
ventaRef: input.ventaRef,
|
||||
clienteNombre: input.clienteNombre,
|
||||
clienteTelefono: input.clienteTelefono || undefined,
|
||||
unidadCodigo: input.unidadCodigo,
|
||||
proyecto: input.proyecto,
|
||||
fechaInicio: input.fechaInicio,
|
||||
engancheTotal: input.engancheTotal,
|
||||
numPagos: input.numPagos,
|
||||
mensualidad,
|
||||
tasaMoraMensual: input.tasaMoraMensual,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
guardarPlanes([...cargarPlanes(storage), plan], storage)
|
||||
return plan
|
||||
}
|
||||
|
||||
export function actualizarPlan(plan: PlanPago, storage: StorageLike = storageDefault()): PlanPago {
|
||||
// El status operativo (ACTIVO/LIQUIDADO) se deriva del saldo; CANCELADO es manual
|
||||
const actualizado: PlanPago = { ...plan, status: statusPlan(plan) }
|
||||
guardarPlanes(
|
||||
cargarPlanes(storage).map((p) => (p.id === actualizado.id ? actualizado : p)),
|
||||
storage,
|
||||
)
|
||||
return actualizado
|
||||
}
|
||||
|
||||
export function cancelarPlan(id: string, storage: StorageLike = storageDefault()): PlanPago {
|
||||
const plan = obtenerPlan(id, storage)
|
||||
if (!plan) throw new Error('Plan no encontrado')
|
||||
const cancelado: PlanPago = { ...plan, status: 'CANCELADO' }
|
||||
guardarPlanes(
|
||||
cargarPlanes(storage).map((p) => (p.id === id ? cancelado : p)),
|
||||
storage,
|
||||
)
|
||||
return cancelado
|
||||
}
|
||||
|
||||
export function registrarAbonoEnPlan(
|
||||
planId: string,
|
||||
abono: Omit<AbonoInput, 'pagoId'>,
|
||||
pagoId: string,
|
||||
storage: StorageLike = storageDefault(),
|
||||
): PlanPago {
|
||||
const plan = obtenerPlan(planId, storage)
|
||||
if (!plan) throw new Error('Plan no encontrado')
|
||||
return actualizarPlan(registrarAbono(plan, { ...abono, pagoId }), storage)
|
||||
}
|
||||
|
||||
/** Agrega una nota a la bitácora del plan y persiste el cambio */
|
||||
export function agregarNota(
|
||||
planId: string,
|
||||
texto: string,
|
||||
storage: StorageLike = storageDefault(),
|
||||
): PlanPago {
|
||||
const plan = obtenerPlan(planId, storage)
|
||||
if (!plan) throw new Error('Plan no encontrado')
|
||||
return actualizarPlan(agregarNotaAPlan(plan, texto), storage)
|
||||
}
|
||||
42
apps/cobranza/src/types/index.ts
Normal file
42
apps/cobranza/src/types/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
|
||||
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
|
||||
|
||||
export interface PlanPago {
|
||||
id: string // uuid
|
||||
ventaRef: string // referencia de la venta (en el futuro: mondayItemId)
|
||||
clienteNombre: string
|
||||
clienteTelefono?: string // teléfono de contacto (opcional)
|
||||
unidadCodigo: string // p. ej. 'U-101'
|
||||
proyecto: string // p. ej. 'SENDA'
|
||||
fechaInicio: string // ISO YYYY-MM-DD (primer vencimiento)
|
||||
engancheTotal: number // MXN
|
||||
numPagos: number
|
||||
mensualidad: number // MXN (calculada)
|
||||
tasaMoraMensual?: number // % mensual sobre el restante vencido; 0 o ausente = sin mora
|
||||
status: StatusPlan
|
||||
pagos: Pago[]
|
||||
notas: NotaPlan[] // bitácora del plan (cronológica)
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface NotaPlan {
|
||||
id: string
|
||||
fecha: string // ISO con hora
|
||||
texto: string
|
||||
}
|
||||
|
||||
export interface Pago {
|
||||
id: string
|
||||
numPago: number // 1..N
|
||||
fechaVencimiento: string // ISO
|
||||
monto: number // mensualidad programada
|
||||
montoPagado: number // acumulado aplicado
|
||||
aplicaciones: AplicacionPago[] // historial de abonos
|
||||
}
|
||||
|
||||
export interface AplicacionPago {
|
||||
id: string
|
||||
fechaPago: string // ISO
|
||||
monto: number
|
||||
referencia: string // referencia bancaria / folio
|
||||
}
|
||||
22
apps/cobranza/tsconfig.app.json
Normal file
22
apps/cobranza/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/cobranza/tsconfig.json
Normal file
7
apps/cobranza/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
19
apps/cobranza/tsconfig.node.json
Normal file
19
apps/cobranza/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
9
apps/cobranza/vite.config.ts
Normal file
9
apps/cobranza/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// La app se sirve detrás de NGINX bajo el prefijo /pay
|
||||
export default defineConfig({
|
||||
base: '/pay/',
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
8
apps/cobranza/vitest.config.ts
Normal file
8
apps/cobranza/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Config de tests separada para no mezclar los tipos de Vite y Vitest
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user