feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing

- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador,
  combobox de unidades, vigencia, PDF ANEXO A
- Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE,
  checklist de expediente, firma digital, 5 PDFs legales
- Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF,
  bitacora, dashboard con filtros
- Landing (/): entrada al ecosistema
- Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps
- 123 tests vitest
This commit is contained in:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

View File

@@ -0,0 +1,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)
}