feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol

This commit is contained in:
2026-09-21 08:34:53 +00:00
parent b5a20aed4e
commit 2fc80e7f9e
66 changed files with 3792 additions and 436 deletions

View File

@@ -1,8 +1,12 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'
import DarkModeToggle from './DarkModeToggle'
export default function Layout({ children }: { children: ReactNode }) {
// El toggle "Mis planes / Todos" vive en ?vista=: se conserva al navegar
// entre Cobranza y Cobranza del día para que el alcance no se pierda.
const { search } = useLocation()
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
@@ -23,13 +27,13 @@ export default function Layout({ children }: { children: ReactNode }) {
<nav className="flex items-center gap-3 text-sm sm:gap-6">
<DarkModeToggle />
<Link
to="/"
to={`/${search}`}
className="text-[#64748B] dark:text-[#94A3B8] transition-colors duration-150 hover:text-[#111827] dark:text-[#E2E8F0]"
>
Cobranza
</Link>
<Link
to="/hoy"
to={`/hoy${search}`}
className="rounded-lg border border-accent px-3 py-2 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white sm:px-5 sm:py-2.5"
>
Cobranza del día

View File

@@ -1,13 +1,14 @@
import type { PlanPago } from '../types'
import { diasParaProximoVencimiento, statusPago, statusPlan } from '../lib/cobranza'
import { diasParaProximoVencimiento, pagoAtrasado, statusPago, statusPlan } from '../lib/cobranza'
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS'
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS' | 'SIN_PLAN'
const ETIQUETA: Record<Semaforo, string> = {
VERDE: 'Al corriente',
AMBAR: 'Por vencer / parcial',
ROJO: 'Vencido',
GRIS: 'Cerrado',
SIN_PLAN: 'Sin plan capturado',
}
const CLASE_DOT: Record<Semaforo, string> = {
@@ -15,20 +16,23 @@ const CLASE_DOT: Record<Semaforo, string> = {
AMBAR: 'bg-amber-50 text-amber-700',
ROJO: 'bg-red-50 text-red-700',
GRIS: 'bg-gray-100 text-gray-600',
SIN_PLAN: 'bg-orange-50 text-orange-700',
}
/**
* Semáforo del plan:
* - "sin plan capturado": no hay calendario de pagos (no es lo mismo que liquidado)
* - gris: LIQUIDADO o CANCELADO
* - rojo: tiene pagos VENCIDOS
* - rojo: tiene pagos VENCIDOS o PARCIALES 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 status = statusPlan(plan)
if (status === 'SIN_PLAN') return 'SIN_PLAN'
if (status !== 'ACTIVO') return 'GRIS'
if (plan.pagos.some((p) => pagoAtrasado(p, hoy))) return 'ROJO'
if (plan.pagos.some((p) => statusPago(p, hoy) === 'PARCIAL')) return 'AMBAR'
const dias = diasParaProximoVencimiento(plan, hoy)
if (dias !== null && dias <= 7) return 'AMBAR'
return 'VERDE'

View File

@@ -0,0 +1,46 @@
import type { VistaPlanes } from '../lib/usePlanesCobranza'
interface Props {
mios: boolean
/** null vuelve al valor por defecto del rol */
onCambiar: (vista: VistaPlanes | null) => void
}
/**
* Toggle "Mis planes / Todos". La elección se guarda en ?vista= de la URL, de
* modo que se comparte entre el dashboard y "Cobranza del día".
*/
export default function ToggleVista({ mios, onCambiar }: Props) {
const claseActiva = 'bg-accent text-white shadow-sm'
const claseInactiva =
'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:hover:text-[#E2E8F0] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
return (
<div
className="inline-flex rounded-lg border border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]"
role="group"
aria-label="Alcance de planes"
>
<button
type="button"
onClick={() => onCambiar(mios ? null : 'mios')}
aria-pressed={mios}
className={`rounded-l-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? claseActiva : claseInactiva
}`}
>
Mis planes
</button>
<button
type="button"
onClick={() => onCambiar(mios ? 'todos' : null)}
aria-pressed={!mios}
className={`rounded-r-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? claseInactiva : claseActiva
}`}
>
Todos
</button>
</div>
)
}

View File

@@ -186,6 +186,29 @@ describe('saldo, avance y status del plan', () => {
expect(statusPlan(plan)).toBe('CANCELADO')
})
it('sin calendario capturado → SIN_PLAN, no LIQUIDADO', () => {
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0, numPagos: 0 }
expect(statusPlan(sinPagos)).toBe('SIN_PLAN')
// con pagos pero en cero (plan capturado a medias): tampoco es liquidado
const enCeros: PlanPago = {
...planDePrueba(),
engancheTotal: 0,
pagos: planDePrueba().pagos.map((p) => ({ ...p, monto: 0 })),
}
expect(statusPlan(enCeros)).toBe('SIN_PLAN')
})
it('un plan SIN_PLAN no entra en la cobranza del día', () => {
const sinPagos: PlanPago = {
...planDePrueba(),
pagos: [],
engancheTotal: 0,
numPagos: 0,
fechaInicio: '2026-01-01',
}
expect(cobranzaDelDia([sinPagos], new Date('2026-02-01T00:00:00'))).toHaveLength(0)
})
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)
@@ -225,10 +248,19 @@ describe('proximoVencimiento y diasAtraso', () => {
expect(diasAtraso(plan, new Date('2026-01-10T00:00:00'))).toBe(0)
})
it('un pago PARCIAL vencido no cuenta como atraso', () => {
it('un pago PARCIAL vencido SÍ cuenta como atraso (corrección: antes se ignoraba)', () => {
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
// pago 1 vence 10-ene y sigue sin cubrir (abono de 100) → 22 días al 1-feb
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(22)
expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(false)
})
it('alCorriente ignora los vencidos ya cubiertos', () => {
let plan = planDePrueba()
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A' })
// con el pago 1 cubierto y el 2 (10-feb) aún vigente, está al corriente
expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(true)
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
})
})

View File

@@ -85,7 +85,8 @@ export function generarCalendario(
* 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`.
* PARCIAL (tiene abono aplicado); el atraso se señala con `pagoAtrasado`,
* `diasAtraso` y `alCorriente`, que sí lo cuentan como vencido.
*/
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
@@ -132,10 +133,20 @@ export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date(
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) */
/**
* ¿El pago está atrasado? Un pago sin cubrir cuya fecha de vencimiento ya
* pasó: incluye el PARCIAL vencido (tiene abono pero no cubre el monto), que
* es un atraso real aunque `statusPago` lo siga etiquetando PARCIAL.
*/
export function pagoAtrasado(pago: Pago, hoy: Date = new Date()): boolean {
if (estaCubierto(pago)) return false
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0
}
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin cubrir) */
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
const vencidos = plan.pagos
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
.filter((p) => pagoAtrasado(p, hoy))
.sort(
(a, b) =>
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
@@ -145,15 +156,29 @@ export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
}
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
/**
* Status derivado del plan:
* - CANCELADO: solo manual
* - SIN_PLAN: no hay calendario capturado (0 pagos o montos en 0). Distinto de
* LIQUIDADO: el adeudo real es desconocido, no saldado; queda fuera de la
* cobranza del día hasta que se capture el plan.
* - LIQUIDADO: saldo == 0
* - ACTIVO: tiene saldo pendiente
*/
export function statusPlan(plan: PlanPago): StatusPlan {
if (plan.status === 'CANCELADO') return 'CANCELADO'
if (!tieneCalendario(plan)) return 'SIN_PLAN'
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
}
/** Al corriente = sin pagos VENCIDOS */
/** ¿El plan tiene calendario capturado? (al menos un pago con monto > 0) */
export function tieneCalendario(plan: PlanPago): boolean {
return plan.pagos.some((p) => p.monto > EPS)
}
/** Al corriente = sin pagos atrasados (un PARCIAL vencido también es atraso) */
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
return !plan.pagos.some((p) => pagoAtrasado(p, hoy))
}
/**
@@ -194,10 +219,11 @@ export function telefonoParaEnlace(telefono: string): string {
}
/**
* 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.
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS
* (los SIN_PLAN quedan fuera: sin calendario capturado no hay nada que
* cobrar): 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[],

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { PlanPago } from '../../types'
import { generarCalendario, registrarAbono } from '../cobranza'
import { crearDocumento, folioRecibo } from './reciboAbono'
import { generarCalendario, registrarAbono, saldoPlan } from '../cobranza'
import { crearDocumento, folioRecibo, saldosDelRecibo } from './reciboAbono'
function planConAbono(): PlanPago {
const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3)
@@ -47,4 +47,56 @@ describe('reciboAbono PDF', () => {
// smoke: el contenido interno es un PDF válido
expect(doc.output('datauristring')).toContain('application/pdf')
})
it('saldosDelRecibo usa los totales del plan, no solo los abonos registrados', () => {
const plan = planConAbono()
const pago = plan.pagos[0]
const { restantePago, saldoPlan: saldo } = saldosDelRecibo(plan, pago)
expect(restantePago).toBe(28333.33)
expect(saldo).toBe(saldoPlan(plan))
expect(saldo).toBe(95000)
})
it('el saldo incluye el apartado/enganche histórico sin fila de AplicacionPago', () => {
// Caso real del maestro importado: el pago inicial (numPago 0) trae
// montoPagado == monto pero sin aplicaciones. Antes el recibo restaba solo
// aplicaciones y mostraba un saldo inflado (o negativo).
const base = planConAbono()
const pagoInicial = {
id: 'pago-0',
numPago: 0,
fechaVencimiento: '2026-01-01',
monto: 20000,
montoPagado: 20000,
aplicaciones: [],
}
const plan: PlanPago = {
...base,
pagos: [pagoInicial, ...base.pagos],
engancheTotal: base.engancheTotal,
}
const pago = plan.pagos[1]
const aplicacion = pago.aplicaciones[0]
const { saldoPlan: saldo, restantePago } = saldosDelRecibo(plan, pago)
// saldo real = Σ monto − Σ montoPagado = 120000 − 25000
expect(saldo).toBe(95000)
expect(restantePago).toBe(28333.33)
expect(crearDocumento(plan, pago, aplicacion).doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
})
it('un abono mayor al monto de su pago no produce saldo negativo (dato importado)', () => {
// El importador viejo guardaba el valor crudo en AplicacionPago.monto
// aunque montoPagado se topara con Math.min: el recibo restaba de más.
const base = planConAbono()
const pago = base.pagos[0]
const inflado: PlanPago = {
...base,
pagos: base.pagos.map((p) =>
p.id === pago.id
? { ...p, aplicaciones: [{ ...p.aplicaciones[0], monto: 500000 }] }
: p,
),
}
expect(saldosDelRecibo(inflado, inflado.pagos[0]).saldoPlan).toBe(95000)
})
})

View File

@@ -1,7 +1,7 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AplicacionPago, Pago, PlanPago } from '../../types'
import { round2 } from '../cobranza'
import { round2, saldoPlan } from '../cobranza'
import { formatoFechaCorta, formatoFechaLarga, formatoMoneda } from '../formatters'
import type { DocumentoPdf } from './estadoCuenta'
@@ -12,11 +12,25 @@ export function folioRecibo(aplicacion: AplicacionPago): string {
return aplicacion.id.slice(0, 8).toUpperCase()
}
/**
* Saldos que imprime el recibo, tomados de los TOTALES del plan y del pago
* (suma de montos − suma de pagado), no del historial de `AplicacionPago`:
* el enganche/apartado histórico vive en `Pago.montoPagado` sin fila de
* aplicación, así que acumular solo aplicaciones daba saldos falsos
* (incluso negativos). El saldo nunca puede ser negativo porque un abono
* jamás excede el restante de su pago.
*/
export function saldosDelRecibo(plan: PlanPago, pago: Pago): { restantePago: number; saldoPlan: number } {
return {
restantePago: round2(pago.monto - pago.montoPagado),
saldoPlan: saldoPlan(plan),
}
}
/**
* 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.
* El restante del pago y el saldo del plan son los totales vigentes del plan
* (ver `saldosDelRecibo`).
*/
export function crearDocumento(
plan: PlanPago,
@@ -28,23 +42,7 @@ export function crearDocumento(
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)
const { restantePago, saldoPlan: saldo } = saldosDelRecibo(plan, pago)
// 1. Encabezado
doc.setFontSize(14)
@@ -72,8 +70,8 @@ export function crearDocumento(
['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)],
['RESTANTE DEL PAGO', formatoMoneda(restantePago)],
['SALDO DEL PLAN (a la fecha de emisión)', formatoMoneda(saldo)],
],
theme: 'plain',
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { halign: 'left' } },

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { getSesion } from '../services/auth'
import {
listarPlanes,
miosPorDefecto,
sinPlanesAsignados,
type ResultadoPlanes,
} from '../services/repository'
/** Alcance del listado: solo los planes del usuario o todos */
export type VistaPlanes = 'mios' | 'todos'
export interface EstadoPlanes {
mios: boolean
/** Cambia el toggle; null borra el parámetro y vuelve al valor por defecto del rol */
definirVista: (vista: VistaPlanes | null) => void
resultado: ResultadoPlanes | null
errorCarga: string | null
/**
* true cuando "Mis planes" vino vacío (el usuario no tiene planes asignados:
* los históricos tienen vendedorId nulo) y se está mostrando el listado
* completo para no dejar la pantalla en blanco.
*/
sinAsignados: boolean
}
/**
* Carga de planes compartida por el dashboard y "Cobranza del día": el toggle
* "Mis planes / Todos" vive en la URL (?vista=mios|todos) para que ambas
* pantallas respeten la misma elección; sin parámetro arranca según el rol.
*/
export function usePlanesCobranza(): EstadoPlanes {
const [searchParams, setSearchParams] = useSearchParams()
const paramVista = searchParams.get('vista')
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
const [sinAsignados, setSinAsignados] = useState(false)
useEffect(() => {
let activo = true
setResultado(null)
setErrorCarga(null)
void (async () => {
try {
const r = await listarPlanes(undefined, mios)
if (!activo) return
if (sinPlanesAsignados(r, mios)) {
const todos = await listarPlanes(undefined, false)
if (!activo) return
setSinAsignados(true)
setResultado(todos)
return
}
setSinAsignados(false)
setResultado(r)
} catch (e) {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
}
})()
return () => {
activo = false
}
}, [mios])
const definirVista = useCallback(
(vista: VistaPlanes | null) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
if (vista === null) next.delete('vista')
else next.set('vista', vista)
return next
},
{ replace: true },
)
},
[setSearchParams],
)
return { mios, definirVista, resultado, errorCarga, sinAsignados }
}

View File

@@ -1,5 +1,5 @@
import { SkeletonList } from "../components/Skeleton"
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import {
Bar,
@@ -15,8 +15,7 @@ import {
YAxis,
} from 'recharts'
import type { StatusPlan } from '../types'
import { listarPlanes, miosPorDefecto, type ResultadoPlanes } from '../services/repository'
import { getSesion } from '../services/auth'
import { usePlanesCobranza } from '../lib/usePlanesCobranza'
import {
alCorriente,
avancePlan,
@@ -33,6 +32,7 @@ import { useHoy } from '../lib/useHoy'
import MoraDashboard from "../components/MoraDashboard"
import KpiCard from '../components/KpiCard'
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
import ToggleVista from '../components/ToggleVista'
const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']
const COLORES_PIE = ['#2563EB', '#CBD5E1']
@@ -49,8 +49,6 @@ function claveMes(fecha: Date): string {
}
export default function DashboardPage() {
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
// Filtros y ordenamiento persistidos en la URL para que sean compartibles
const [searchParams, setSearchParams] = useSearchParams()
const busqueda = searchParams.get('q') ?? ''
@@ -59,30 +57,13 @@ export default function DashboardPage() {
const paramOrden = searchParams.get('sort')
const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
// Toggle "Mis planes / Todos": persiste en la URL (?vista=mios|todos); sin
// param, los vendedores arrancan en "Mis planes" y el resto en "Todos"
const paramVista = searchParams.get('vista')
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
// Toggle "Mis planes / Todos" (persiste en ?vista=): lo comparte con
// "Cobranza del día" y avisa si el usuario no tiene planes asignados
const { mios, definirVista, resultado, errorCarga, sinAsignados } = usePlanesCobranza()
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
// pestaña queda abierta de un día para otro)
const hoy = useHoy()
useEffect(() => {
let activo = true
setResultado(null)
setErrorCarga(null)
listarPlanes(undefined, mios)
.then((r) => {
if (activo) setResultado(r)
})
.catch((e: unknown) => {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
})
return () => {
activo = false
}
}, [mios])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const idsPlan = useMemo(() => new Set(planes.map((p) => p.id)), [planes])
@@ -239,6 +220,11 @@ export default function DashboardPage() {
: 'Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.'}
</p>
)}
{sinAsignados && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
No tienes planes asignados a tu usuario; se muestra el listado de <strong>todos</strong> los planes.
</p>
)}
{/* KPIs */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
@@ -302,29 +288,8 @@ export default function DashboardPage() {
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
{/* Filtros */}
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5">
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
<div className="flex rounded-lg border border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]" role="group" aria-label="Alcance de planes">
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? null : 'mios' })}
aria-pressed={mios}
className={`rounded-l-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? 'bg-accent text-white shadow-sm' : 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:hover:text-[#E2E8F0] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
}`}
>
Mis planes
</button>
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? 'todos' : null })}
aria-pressed={!mios}
className={`rounded-r-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:hover:text-[#E2E8F0] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]' : 'bg-accent text-white shadow-sm'
}`}
>
Todos
</button>
</div>
{/* Toggle Mis planes / Todos (?vista= filtra en el API por el vendedor del token) */}
<ToggleVista mios={mios} onCambiar={definirVista} />
<label htmlFor="filtro-busqueda" className="sr-only">
Buscar por cliente o unidad
</label>
@@ -348,6 +313,7 @@ export default function DashboardPage() {
<option value="TODOS">Todos los status</option>
<option value="ACTIVO">ACTIVO</option>
<option value="LIQUIDADO">LIQUIDADO</option>
<option value="SIN_PLAN">SIN PLAN CAPTURADO</option>
<option value="CANCELADO">CANCELADO</option>
</select>
<label htmlFor="filtro-proyecto" className="sr-only">

View File

@@ -1,34 +1,24 @@
import { SkeletonList } from "../components/Skeleton"
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
import { useHoy } from '../lib/useHoy'
import { usePlanesCobranza } from '../lib/usePlanesCobranza'
import KpiCard from '../components/KpiCard'
import ToggleVista from '../components/ToggleVista'
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
/**
* Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de los
* planes activos del alcance elegido (el mismo toggle "Mis planes / Todos" del
* dashboard, compartido por la URL).
*/
export default function HoyPage() {
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
const { mios, definirVista, resultado, errorCarga, sinAsignados } = usePlanesCobranza()
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
// pestaña queda abierta de un día para otro)
const hoy = useHoy()
useEffect(() => {
let activo = true
listarPlanes()
.then((r) => {
if (activo) setResultado(r)
})
.catch((e: unknown) => {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
})
return () => {
activo = false
}
}, [])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy])
@@ -51,8 +41,18 @@ export default function HoyPage() {
<div>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Cobranza del día</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
Pagos vencidos y vencimientos de los próximos 7 días, en los planes activos
{mios ? ' de tu alcance' : ' de todos los proyectos'}.
</p>
<div className="mt-3">
{/* Mismo toggle que el dashboard: la elección viaja en ?vista= */}
<ToggleVista mios={mios} onCambiar={(vista) => definirVista(vista)} />
</div>
{sinAsignados && (
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
No tienes planes asignados a tu usuario; se muestra la cobranza de <strong>todos</strong> los planes.
</p>
)}
{resultado.origen === 'local' && (
<p className="mt-2 text-xs text-amber-700">
Modo offline: sin conexión al API; mostrando datos guardados en este navegador.
@@ -123,7 +123,8 @@ export default function HoyPage() {
{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.
No hay pagos vencidos ni vencimientos en los próximos 7 días
{mios ? ' en tus planes.' : '.'}
</td>
</tr>
)}

View File

@@ -2,11 +2,14 @@ import { Fragment, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import type { Pago, PlanPago } from '../types'
import {
actualizarPlanAsync,
agregarNotaAsync,
cancelarPlanAsync,
obtenerPlanPorId,
puedeEditarMora,
registrarAbonoAsync,
} from '../services/repository'
import { getSesion } from '../services/auth'
import {
avancePlan,
diasAtraso,
@@ -43,6 +46,12 @@ export default function PlanDetallePage() {
const [error, setError] = useState<string | null>(null)
const [notaTexto, setNotaTexto] = useState('')
const [errorNota, setErrorNota] = useState<string | null>(null)
// Captura de la tasa de mora (vive en el plan, editable por admin/manager/dirección)
const [tasaInput, setTasaInput] = useState('0')
const [guardandoMora, setGuardandoMora] = useState(false)
const [errorMora, setErrorMora] = useState<string | null>(null)
const [avisoMora, setAvisoMora] = useState<string | null>(null)
const puedeEditarTasa = puedeEditarMora(getSesion()?.usuario.rol)
// "Hoy" se recalcula al enfocar la ventana (evita atrasos/mora congelados si
// la pestaña queda abierta de un día para otro)
const hoy = useHoy()
@@ -70,6 +79,12 @@ export default function PlanDetallePage() {
}
}, [id])
// La caja de la tasa se prellena al cargar el plan (o 0 si no tiene). No se
// reinicia con cada recarga del plan para no borrar lo que el usuario escribe.
useEffect(() => {
if (plan) setTasaInput(String(plan.tasaMoraMensual ?? 0))
}, [plan?.id])
if (cargando) {
return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando...</p>
}
@@ -94,6 +109,11 @@ export default function PlanDetallePage() {
const atraso = diasAtraso(plan, hoy)
const tasaMora = plan.tasaMoraMensual ?? 0
const mora = totalMora(plan, hoy)
// Mora que resultaría con lo capturado en la caja (vista previa antes de guardar)
const tasaCapturada = Number(tasaInput)
const tasaCapturadaValida = Number.isFinite(tasaCapturada) && tasaCapturada >= 0 && tasaCapturada <= 100
const moraConTasaCapturada = tasaCapturadaValida ? totalMora({ ...plan, tasaMoraMensual: tasaCapturada }, hoy) : 0
const tasaSinCambios = tasaCapturadaValida && round2(tasaCapturada) === round2(tasaMora)
const toggleExpandido = (pagoId: string) => {
setExpandidos((prev) => {
@@ -154,6 +174,31 @@ export default function PlanDetallePage() {
}
}
/** Guarda la tasa de mora mensual del plan (PATCH /planes/:id) */
const guardarTasaMora = async () => {
if (!tasaCapturadaValida) {
setErrorMora('Captura una tasa mensual entre 0 y 100 (%)')
return
}
setGuardandoMora(true)
setErrorMora(null)
setAvisoMora(null)
try {
const r = await actualizarPlanAsync(plan.id, { tasaMoraMensual: round2(tasaCapturada) })
setPlan(r.plan)
setOffline(r.origen === 'local')
setAvisoMora(
r.origen === 'local'
? 'Sin conexión al API: la tasa se guardó solo en este navegador.'
: 'Tasa de mora actualizada.',
)
} catch (e) {
setErrorMora(e instanceof Error ? e.message : 'Error al guardar la tasa de mora')
} finally {
setGuardandoMora(false)
}
}
const avance = avancePlan(plan)
/** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */
@@ -175,7 +220,9 @@ export default function PlanDetallePage() {
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
{status === 'SIN_PLAN' ? 'SIN PLAN CAPTURADO' : status}
</span>
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
</p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
@@ -201,6 +248,14 @@ export default function PlanDetallePage() {
</p>
)}
{status === 'SIN_PLAN' && (
<div className="rounded-lg border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
<strong>Sin plan capturado:</strong> este plan no tiene calendario de pagos (enganche y
mensualidades en cero), así que no aparece en “Cobranza del día” ni en el reporte de mora. No es
un plan liquidado: hay que capturar su calendario para poder cobrarlo.
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
@@ -421,6 +476,75 @@ export default function PlanDetallePage() {
</div>
</section>
{/* Mora del plan: la tasa vive en el plan y la editan admin/manager/dirección */}
<section
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
aria-labelledby="titulo-mora"
>
<h2 id="titulo-mora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Mora por atraso
</h2>
{puedeEditarTasa ? (
<form
className="mt-4"
onSubmit={(e) => {
e.preventDefault()
void guardarTasaMora()
}}
>
<label htmlFor="tasa-mora" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Tasa de mora mensual (%)
</label>
<input
id="tasa-mora"
type="number"
min={0}
max={100}
step="0.01"
inputMode="decimal"
value={tasaInput}
onChange={(e) => {
setTasaInput(e.target.value)
setErrorMora(null)
setAvisoMora(null)
}}
className="mt-1 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
/>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
La tasa es <strong>mensual en %</strong> y se prorratea por día (tasa ÷ 30) sobre el restante
de cada pago vencido, incluidos los parciales vencidos. 0 = sin mora.
</p>
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Mora acumulada con esta tasa:{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(moraConTasaCapturada)}
</span>
</p>
{errorMora && <p className="mt-1 text-xs text-red-700">{errorMora}</p>}
{avisoMora && <p className="mt-1 text-xs text-green-700">{avisoMora}</p>}
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={guardandoMora || !tasaCapturadaValida || tasaSinCambios}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
>
{guardandoMora ? 'Guardando…' : 'Guardar tasa'}
</button>
</div>
</form>
) : (
<div className="mt-4 space-y-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
<p>
Tasa: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{tasaMora}% mensual</span>
</p>
<p>
Mora acumulada: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(mora)}</span>
</p>
<p className="text-xs">Solo admin, manager y dirección pueden modificar la tasa.</p>
</div>
)}
</section>
{/* Bitácora del plan */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-bitacora">
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">

View File

@@ -379,3 +379,68 @@ describe('mis cobranzas (toggle Mis planes / Todos)', () => {
expect(r.planes.length).toBeGreaterThan(0)
})
})
describe('edición del plan y alcance sin planes asignados', () => {
it('sinPlanesAsignados: solo cuando "míos" vino vacío desde el API', async () => {
const { sinPlanesAsignados } = await import('./repository')
expect(sinPlanesAsignados({ planes: [], origen: 'api', planesSoloLocales: false }, true)).toBe(true)
// con resultados, con vista "todos" o en modo offline no aplica
expect(sinPlanesAsignados({ planes: [planLocal()], origen: 'api', planesSoloLocales: false }, true)).toBe(false)
expect(sinPlanesAsignados({ planes: [], origen: 'api', planesSoloLocales: false }, false)).toBe(false)
expect(sinPlanesAsignados({ planes: [], origen: 'local', planesSoloLocales: false }, true)).toBe(false)
})
it('puedeEditarMora: admin/manager/dirección sí; vendedor y roles de operación no', async () => {
const { puedeEditarMora } = await import('./repository')
expect(puedeEditarMora('admin')).toBe(true)
expect(puedeEditarMora('manager')).toBe(true)
expect(puedeEditarMora('direccion_general')).toBe(true)
expect(puedeEditarMora('director_ventas')).toBe(true)
expect(puedeEditarMora('coordinador')).toBe(true) // legacy de dirección
expect(puedeEditarMora('vendedor')).toBe(false)
expect(puedeEditarMora('administracion_venta')).toBe(false)
expect(puedeEditarMora('escrituracion')).toBe(false)
expect(puedeEditarMora(null)).toBe(false)
expect(puedeEditarMora(undefined)).toBe(false)
})
it('actualizarPlanAsync hace PATCH /planes/:id con la tasa de mora', async () => {
const { actualizarPlanAsync } = await import('./repository')
const fetchMock = fetchQue(async () => respuestaJson(planApi({ tasaMoraMensual: 2.5 })))
vi.stubGlobal('fetch', fetchMock)
const r = await actualizarPlanAsync('api-1', { tasaMoraMensual: 2.5 })
expect(r.origen).toBe('api')
expect(r.plan.tasaMoraMensual).toBe(2.5)
expect(fetchMock).toHaveBeenCalledWith(
`${API_URL}/planes/api-1`,
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ tasaMoraMensual: 2.5 }) }),
)
})
it('actualizarPlanAsync propaga el 403 del API (rol sin permiso) sin tocar localStorage', async () => {
const { actualizarPlanAsync } = await import('./repository')
vi.stubGlobal(
'fetch',
fetchQue(async () => respuestaJson({ error: 'Solo admin, manager o dirección pueden modificar el plan' }, 403)),
)
await expect(actualizarPlanAsync('api-1', { tasaMoraMensual: 2 }, crearStorageStub())).rejects.toBeInstanceOf(ErrorApi)
})
it('actualizarPlanAsync cae a localStorage cuando falla la red', async () => {
const { actualizarPlanAsync } = await import('./repository')
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()]))
const r = await actualizarPlanAsync('local-1', { tasaMoraMensual: 3 }, storage)
expect(r.origen).toBe('local')
expect(r.plan.tasaMoraMensual).toBe(3)
const guardado = JSON.parse(storage.getItem(CLAVE_STORAGE) ?? '[]') as PlanPago[]
expect(guardado[0].tasaMoraMensual).toBe(3)
})
})

View File

@@ -183,11 +183,30 @@ export function rutaListadoPlanes(mios: boolean): string {
/**
* Vista inicial del toggle "Mis planes / Todos": los vendedores arrancan en
* "Mis planes"; el resto de roles (admin, coordinador, corporativo) en "Todos".
* Ojo: los planes históricos tienen `vendedorId` nulo, así que "Mis planes"
* puede legítimamente venir vacío; las páginas detectan ese caso y muestran
* todos los planes con un aviso (ver `sinPlanesAsignados`).
*/
export function miosPorDefecto(rol: string | null | undefined): boolean {
return rol === 'vendedor'
}
/**
* Roles que pueden editar parámetros del plan (tasa de mora). Espejo de
* ROLES_EDITAN_PLAN del API (PATCH /planes/:id): admin, manager y dirección
* (más los roles legacy que la matriz homologa a dirección de ventas).
*/
export function puedeEditarMora(rol: string | null | undefined): boolean {
return (
rol === 'admin' ||
rol === 'manager' ||
rol === 'direccion_general' ||
rol === 'director_ventas' ||
rol === 'coordinador' ||
rol === 'corporativo'
)
}
/**
* GET /planes. Ante error de red cae a localStorage (sembrando si está vacío).
* Si el API responde vacío pero hay planes locales, los conserva solo localmente
@@ -306,3 +325,43 @@ export async function cancelarPlanAsync(
return { plan: cancelarPlan(id, storage), origen: 'local' }
}
}
/** Campos del plan que se pueden editar (PATCH /planes/:id) */
export interface EdicionPlan {
/** Tasa de mora mensual en % sobre el restante vencido; 0 = sin mora */
tasaMoraMensual: number
}
/**
* PATCH /planes/:id. El API exige rol admin/manager/dirección (403 → se
* propaga). Solo la red cae a localStorage.
*/
export async function actualizarPlanAsync(
id: string,
cambios: EdicionPlan,
storage: StorageLike = storageDefault(),
): Promise<ResultadoPlan> {
try {
const plan = await request<PlanApi>(`/planes/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(cambios),
})
return { plan: desdeApi(plan), origen: 'api' }
} catch (err) {
if (!esErrorDeRed(err)) throw err
console.warn('[cobranza] API no disponible; los cambios se guardaron solo en localStorage:', err)
const local = obtenerPlan(id, storage)
if (!local) throw new Error('Plan no encontrado')
return { plan: actualizarPlan({ ...local, ...cambios }, storage), origen: 'local' }
}
}
/**
* ¿El listado "Mis planes" vino vacío? El API respondió sin error pero el
* usuario no tiene planes asignados (los planes históricos tienen
* `vendedorId` nulo). Las páginas lo usan para no dejar al usuario viendo una
* lista vacía: caen a todos los planes y lo avisan.
*/
export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean {
return mios && resultado.origen === 'api' && resultado.planes.length === 0
}

View File

@@ -1,4 +1,5 @@
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
/** SIN_PLAN = no hay calendario capturado (no debe confundirse con LIQUIDADO) */
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' | 'SIN_PLAN'
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
export interface PlanPago {

View File

@@ -23,18 +23,24 @@ interface Fila {
/**
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
* con la propuesta activa calculada por el motor con los parámetros de cada
* desarrollo.
* desarrollo. Solo ofrece unidades DISPONIBLES: no se compara (ni se cotiza)
* inventario apartado, vendido o bloqueado.
*/
export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props) {
const [abierto, setAbierto] = useState(false)
const [codigoA, setCodigoA] = useState('')
const [codigoB, setCodigoB] = useState('')
const disponibles = useMemo(
() => unidades.filter((u) => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'),
[unidades],
)
const porCodigo = useMemo(() => {
const m = new Map<string, Unidad>()
for (const u of unidades) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
return m
}, [unidades])
}, [disponibles])
const unidadA = porCodigo.get(codigoA) ?? null
const unidadB = porCodigo.get(codigoB) ?? null
@@ -82,7 +88,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
>
<option value="">Selecciona una unidad…</option>
{LISTA_DESARROLLOS.map((dev) => {
const delDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
if (delDesarrollo.length === 0) return null
return (
<optgroup key={dev} label={obtenerDesarrollo(dev).nombre}>
@@ -172,8 +178,8 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
</tbody>
</table>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Cada unidad se calcula con los parámetros de su desarrollo y el mismo apartado.
El enganche ya incluye la deducción del apartado.
Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
</p>
</div>
)}

View File

@@ -27,6 +27,15 @@ const formatoFechaHora = (iso: string): string =>
minute: '2-digit',
})
/**
* Mensualidades guardadas: "N × $m". Las cotizaciones viejas (plazo 0/null, sin
* mensualidad) muestran "—".
*/
const formatoMensualidades = (c: CotizacionGuardada): string => {
if (c.mensualidad === null || c.numMensualidades === 0) return '—'
return `${c.numMensualidades} × ${formatoMoneda(c.mensualidad)}`
}
/** Sección colapsable "Mis cotizaciones": historial del vendedor logueado */
export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Props) {
const [abierto, setAbierto] = useState(false)
@@ -65,7 +74,7 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<table className="w-full min-w-[640px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Precio final', ''].map(
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Mensualidades', 'Precio final', ''].map(
(h) => (
<th
key={h}
@@ -95,6 +104,9 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{TITULO_PROPUESTA[c.tipoPropuesta] ?? c.tipoPropuesta}
</td>
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{formatoMensualidades(c)}
</td>
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap">
{formatoMoneda(c.precioFinal)}
</td>

View File

@@ -12,6 +12,9 @@ const DIAGONAL: ParametrosDesarrollo = {
nombre: 'Diagonal',
precioUnico: false,
fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega: mediana de la
// terminación de obra del inventario disponible (2026-09) = 13 mensualidades
nDefault: 13,
cuotaMantenimiento: 1500,
prefijoUnidadPdf: 'CASA',
propuestas: [
@@ -180,7 +183,10 @@ const SENDA: ParametrosDesarrollo = {
id: 'SENDA',
nombre: 'Senda',
precioUnico: true,
fuenteDeN: 'fechaEntrega', // unidades sin fecha en TERM => N null (mensualidades "—")
fuenteDeN: 'fechaEntrega', // N = meses hasta TERM. OBRA (captura ESCRITURACION)
// Último recurso si la unidad no trae fecha: mediana del inventario
// disponible (2026-09) = 6 mensualidades
nDefault: 6,
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [
@@ -236,6 +242,9 @@ const SOLANA: ParametrosDesarrollo = {
nombre: 'Solana',
precioUnico: true,
fuenteDeN: 'porUnidad', // MESES PARA FIRMA APROX del inventario (1–4)
// Último recurso si la unidad no trae meses para firma: las casas vendibles
// del inventario Excel están en 1 mes
nDefault: 1,
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [

View File

@@ -55,6 +55,8 @@ export interface EstadoRestaurar {
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */
descuento?: number
propuestaIndex?: number
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */
numMensualidades?: number | null
}
/** Estado central de la cotización: desarrollo, unidad, acabados, apartado y descuentos */
@@ -96,6 +98,7 @@ export function useCotizacion(): UseCotizacionResult {
}
setDescuentos(base)
setPropuestaIndex(estado.propuestaIndex ?? 0)
setNumMensualidades(estado.numMensualidades ?? null)
}
const nueva = () => {

View File

@@ -169,7 +169,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0 en el Excel, N = 4)', ()
describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
const unidad = porCodigo(UNIDADES_SENDA, 'SENDA-411-25-A101')
it('N = null y mensualidades "—" con nota; enganche y pago final sí se calculan', () => {
it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => {
expect(unidad.fechaEntrega).toBeNull()
const [bancario] = calcularPropuestas({
unidad,
@@ -177,13 +177,13 @@ describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
apartado: APARTADO_DEFAULT,
descuentos: descuentosDefault('SENDA'),
})
expect(bancario.numMensualidades).toBeNull()
expect(bancario.mensualidad).toBeNull()
expect(bancario.mensualidadesTotal).toBeNull()
expect(bancario.notaMensualidades).toBe('sin fecha de entrega')
// Antes: numMensualidades/mensualidad null y "— (sin fecha de entrega)"
expect(bancario.numMensualidades).toBe(DESARROLLOS['SENDA'].nDefault)
expect(bancario.notaMensualidades).toBeUndefined()
expect(bancario.mensualidad).not.toBeNull()
expect(bancario.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
expect(bancario.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeNull()
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2)
})
})
@@ -209,11 +209,12 @@ describe('SOLANA — casa 6 (L = 2,560,000, apartado 20,000, mesesParaFirma = 1)
expect(p.pagoFinal).toBeCloseTo(2304000, 2)
})
it('INFONAVIT: enganche 0% con guarda (no resta el apartado)', () => {
it('INFONAVIT: enganche 0% y el apartado se descuenta de las mensualidades', () => {
const [, p] = calcular()
expect(p.enganche).toBe(0)
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
expect(p.mensualidadesTotal).toBeCloseTo(108000, 2)
expect(p.pagoFinal).toBeCloseTo(2432000, 2)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2560000, 2)
})
it('CONTADO', () => {
@@ -240,19 +241,115 @@ describe('SOLANA — casa 9 (L = 2,605,000, mesesParaFirma = 1)', () => {
descuentos: descuentosDefault('SOLANA'),
})
it('las 4 propuestas al centavo', () => {
it('las 4 propuestas al centavo (Infonavit descuenta el apartado)', () => {
const [bancario, infonavit, contado, semi] = calcular()
expect(bancario.enganche).toBeCloseTo(110250, 2)
expect(bancario.mensualidadesTotal).toBeCloseTo(130250, 2)
expect(bancario.pagoFinal).toBeCloseTo(2344500, 2)
expect(infonavit.enganche).toBe(0)
expect(infonavit.mensualidadesTotal).toBeCloseTo(130250, 2)
expect(infonavit.mensualidadesTotal).toBeCloseTo(110250, 2)
expect(infonavit.pagoFinal).toBeCloseTo(2474750, 2)
expect(contado.enganche).toBeCloseTo(2324500, 2)
expect(contado.pagoFinal).toBeCloseTo(260500, 2)
expect(semi.enganche).toBeCloseTo(1282500, 2)
expect(semi.mensualidadesTotal).toBeCloseTo(1042000, 2)
expect(semi.pagoFinal).toBeCloseTo(260500, 2)
for (const p of [bancario, infonavit, contado, semi]) {
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2605000, 2)
}
})
})
describe('cuadre del total: apartado y enganche personalizado', () => {
const TIPOS = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
] as const
const CASOS = [
{ desarrollo: 'DIAGONAL', precio: 4_415_000 },
{ desarrollo: 'ATELIER', precio: 3_997_500 },
{ desarrollo: 'ESTANZA', precio: 2_437_250 },
{ desarrollo: 'SENDA', precio: 2_451_750 },
{ desarrollo: 'SOLANA', precio: 2_560_000 },
]
const unidadDe = (desarrollo: string, precio: number): Unidad => ({
codigo: `${desarrollo}-TEST`,
modelo: 'X',
desarrollo,
fechaEntrega: null,
mesesParaFirma: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: precio,
precioConAcabados: precio,
status: 'DISPONIBLE',
})
const calcular = (caso: (typeof CASOS)[number], tipo: (typeof TIPOS)[number], apartado: number, enganchePct?: number) =>
calcularPropuestas({
unidad: unidadDe(caso.desarrollo, caso.precio),
acabados: false,
apartado,
descuentos: descuentosDefault(caso.desarrollo),
enganchePct: enganchePct ?? null,
}).find((p) => p.tipo === tipo)!
it('las 4 propuestas de cada desarrollo cuestan lo que el precio final: ni más ni menos', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
const p = calcular(caso, tipo, APARTADO_DEFAULT)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(p.precioFinal, 2)
}
}
})
it('nunca hay bloques negativos y el total es el precio (apartados y enganches extremos)', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
for (const apartado of [0, APARTADO_DEFAULT, 200_000]) {
for (const enganchePct of [undefined, 0.05, 0.2, 0.5, 1]) {
const p = calcular(caso, tipo, apartado, enganchePct)
expect(p.enganche).toBeGreaterThanOrEqual(0)
expect(p.mensualidadesTotal ?? 0).toBeGreaterThanOrEqual(0)
expect(p.pagoFinal).toBeGreaterThanOrEqual(0)
expect(totalPagado(p, apartado)).toBeCloseTo(p.precioFinal, 2)
}
}
}
}
})
it('enganche personalizado alto (20%) no infla el total: sigue siendo el precio', () => {
const u101 = unidadDe('DIAGONAL', 4_415_000)
const [bancario] = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: descuentosDefault('DIAGONAL'),
enganchePct: 0.2,
})
// Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2)
expect(bancario.mensualidadesTotal).toBe(0)
expect(bancario.enganche).toBeCloseTo(0.2 * 4_415_000 - APARTADO_DEFAULT, 2)
})
it('apartado mayor al enganche: el enganche queda en 0 y el total cuadra', () => {
const u101 = unidadDe('DIAGONAL', 4_415_000)
const [bancario] = calcularPropuestas({
unidad: u101,
acabados: false,
// 2.5% de 4,415,000 = 110,375 → con apartado 200,000 el neto sería −89,625
apartado: 200_000,
descuentos: descuentosDefault('DIAGONAL'),
})
expect(bancario.enganche).toBe(0)
expect(bancario.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - bancario.pagoFinal, 2)
expect(totalPagado(bancario, 200_000)).toBeCloseTo(4_415_000, 2)
})
})

View File

@@ -261,11 +261,16 @@ describe('enganche personalizado (mínimo 2.5% del valor de la propiedad)', () =
})
describe('validarEnganchePct', () => {
it('null es válido (sin override); mínimo 2.5%; máximo 100%', () => {
it('null es válido (sin override); rango válido del Excel: 2.5% a 5%', () => {
expect(validarEnganchePct(null)).toBeNull()
expect(validarEnganchePct(2.5)).toBeNull()
expect(validarEnganchePct(50)).toBeNull()
expect(validarEnganchePct(5)).toBeNull()
expect(validarEnganchePct(2.4)).toBe('El enganche mínimo es 2.5% del valor de la propiedad')
expect(validarEnganchePct(101)).toBe('El enganche no puede ser mayor al 100%')
expect(validarEnganchePct(5.5)).toBe(
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
)
expect(validarEnganchePct(20)).toBe(
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
)
})
})

View File

@@ -23,16 +23,22 @@ export const APARTADO_DEFAULT = 20000
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
export const ENGANCHE_MINIMO_PCT = 0.025
/** Enganche máximo que permite el Excel: 5% (lista de validación 0%, 2.5%, 5%) */
export const ENGANCHE_MAXIMO_PCT = 5
/**
* Mensaje de error si el enganche capturado (en PORCENTAJE, 2.5 = 2.5%) es
* inválido; null si es válido. null de entrada = sin override (default Excel).
* El rango válido es el del Excel: 2.5% a 5%.
*/
export function validarEnganchePct(pct: number | null): string | null {
if (pct === null) return null
if (!Number.isFinite(pct) || pct < 2.5) {
return 'El enganche mínimo es 2.5% del valor de la propiedad'
}
if (pct > 100) return 'El enganche no puede ser mayor al 100%'
if (pct > ENGANCHE_MAXIMO_PCT) {
return 'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)'
}
return null
}
@@ -100,6 +106,10 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
* - fijo: nFijo (entrega inmediata)
* - porUnidad: mesesParaFirma del inventario (null si falta)
* - fechaEntrega: meses desde hoy (null si la unidad no tiene fecha)
*
* Cuando el inventario no trae el dato se usa el plazo por defecto del
* desarrollo (`nDefault`), para que la mensualidad y el total SIEMPRE se
* calculen (antes salían "— (sin fecha de entrega)" y sin total).
*/
export function resolverNumMensualidades(
unidad: Unidad,
@@ -109,9 +119,11 @@ export function resolverNumMensualidades(
case 'fijo':
return params.nFijo ?? 1
case 'porUnidad':
return unidad.mesesParaFirma ?? null
return unidad.mesesParaFirma ?? params.nDefault ?? null
case 'fechaEntrega':
return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null
return unidad.fechaEntrega
? calcularNumMensualidades(unidad.fechaEntrega)
: (params.nDefault ?? null)
}
}
@@ -122,6 +134,42 @@ function baseComun(precioLista: number, descuento: number) {
return { montoDescuento, precioFinal }
}
/** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */
interface BloquesPago {
enganche: number
mensualidades: number | null
pagoFinal: number
}
/**
* Cuadra los bloques para que apartado + enganche + mensualidades + pago final
* sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la
* propuesta), aunque el apartado o un enganche personalizado descuadren las
* fórmulas:
* - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando
* el apartado supera el enganche, ni mensualidades negativas con enganche
* alto);
* - el excedente se recorta de las mensualidades (bloque flexible del
* remanente) y, si no alcanza, del pago final. Nunca se suma por encima del
* precio.
*/
function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago {
const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal
const enganche = Math.max(0, bloques.enganche)
let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades)
let pagoFinal = Math.max(0, bloques.pagoFinal)
let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total
if (excedente > 0 && mensualidades !== null) {
const recorte = Math.min(mensualidades, excedente)
mensualidades -= recorte
excedente -= recorte
}
if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente)
return { enganche, mensualidades, pagoFinal }
}
/** Calcula una propuesta comercial con los parámetros dados */
function calcularPropuestaConfig(
cfg: PropuestaConfig,
@@ -140,18 +188,18 @@ function calcularPropuestaConfig(
? Math.max(ENGANCHE_MINIMO_PCT, enganchePct)
: cfg.pctEnganche
// Enganche: pct sobre su base, neto del apartado (salvo la guarda de SOLANA)
// Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de
// SOLANA lo deja en 0 cuando el % es 0
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
const enganche =
cfg.engancheCeroSinApartado && pctEnganche === 0
? 0
: pctEnganche * baseEnganche - apartado
const engancheBruto = cfg.engancheCeroSinApartado && pctEnganche === 0 ? 0 : pctEnganche * baseEnganche
// Fórmula del Excel: enganche neto del apartado (puede salir negativo si el
// apartado supera el enganche; cuadrarBloques lo acota a 0)
const engancheExcel = engancheBruto - apartado
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal
let mensualidadesTotal: number | null = null
let mensualidad: number | null = null
let mensualidadesExcel: number | null = null
let notaMensualidades: string | undefined
if (cfg.modoMensualidades !== 'ninguna') {
if (n === null) {
@@ -160,23 +208,26 @@ function calcularPropuestaConfig(
} else {
switch (cfg.modoMensualidades) {
case 'remanente':
mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel
break
case 'remanenteLista':
// Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel
break
case 'porcentaje':
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
break
}
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
// (ya no hay nada que diferir; el cliente paga de más al inicio)
mensualidadesTotal = Math.max(0, mensualidadesTotal)
mensualidad = mensualidadesTotal / n
}
}
const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, {
enganche: engancheExcel,
mensualidades: mensualidadesExcel,
pagoFinal: pagoFinalExcel,
})
const mensualidad = mensualidades === null || n === null ? null : mensualidades / n
return {
tipo: cfg.tipo,
titulo: cfg.titulo,
@@ -186,7 +237,7 @@ function calcularPropuestaConfig(
enganche,
numMensualidades: n,
mensualidad,
mensualidadesTotal,
mensualidadesTotal: mensualidades,
pagoFinal,
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
}

View File

@@ -5,6 +5,7 @@ import { compararUnidades } from './comparadorUnidades'
import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
import { UNIDADES_ATELIER } from '../data/unidades-atelier'
import { DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters'
describe('folioCotizacion', () => {
@@ -106,10 +107,11 @@ describe('compararUnidades', () => {
expect(r.a.precioLista).toBe(diagonal.precioSinAcabados)
})
it('total null cuando el plazo no se puede conocer (sin fecha de entrega)', () => {
it('una unidad sin fecha de entrega usa el plazo por defecto del desarrollo', () => {
const sinFecha = { ...diagonal, fechaEntrega: null }
const r = compararUnidades(sinFecha, diagonal, 'CREDITO_BANCARIO', APARTADO_DEFAULT, true)
expect(r.a.total).toBeNull()
expect(r.a.propuesta.numMensualidades).toBe(DESARROLLOS['DIAGONAL']?.nDefault)
expect(r.a.total).not.toBeNull()
expect(r.b.total).not.toBeNull()
})
})

View File

@@ -20,6 +20,7 @@ const respuesta = (data: unknown, status = 200) =>
const almacenamiento = new Map<string, string>()
beforeEach(() => {
almacenamiento.clear()
window.history.replaceState({}, '', '/')
vi.stubGlobal('localStorage', {
getItem: (k: string) => almacenamiento.get(k) ?? null,
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
@@ -88,4 +89,58 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
})
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
})
it('no deja cotizar una unidad que ya no está disponible: avisa y bloquea las acciones', async () => {
const vendida = { ...unidad, codigo: 'U-900', status: 'VENDIDO' as const }
const cotizacionVendida = {
id: 'cot-vendida',
folio: 'COT-VENDID',
unidadCodigo: vendida.codigo,
desarrollo: 'DIAGONAL',
clienteNombre: 'Cliente Antiguo',
clienteTelefono: null,
clienteEmail: null,
tipoPropuesta: 'BANCARIO',
acabados: false,
apartado: 20000,
descuento: 0,
precioLista: vendida.precioConAcabados,
precioFinal: 0,
enganche: 0,
numMensualidades: 12,
mensualidad: 1000,
pagoFinal: 0,
createdAt: '2026-09-01T00:00:00.000Z',
}
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidad, vendida]))
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([cotizacionVendida]))
return Promise.reject(new TypeError(`fetch inesperado: ${url}`))
})
vi.stubGlobal('fetch', fetchMock)
// Prellenado desde Mi día con una unidad que ya se vendió
window.history.replaceState({}, '', `/?unidad=${vendida.codigo}`)
render(
<MemoryRouter>
<CotizadorPage />
</MemoryRouter>,
)
// El prellenado NO la preselecciona y lo dice
expect(await screen.findByText(/ya no está disponible \(estatus: VENDIDO\)/)).toBeTruthy()
// Retomar la cotización vieja de esa unidad: se restaura, pero avisa y
// deja Guardar/Compartir/Generar PDF bloqueados
fireEvent.click(await screen.findByRole('button', { name: /Ver historial/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Retomar' }))
const alerta = await screen.findByRole('alert')
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`)
expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
}
})
})

View File

@@ -21,18 +21,22 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import {
getMisCotizaciones,
guardarCotizacion,
urlPublicaCotizacion,
ErrorApiCotizacion,
TIPO_A_API,
TIPO_DE_API,
type CotizacionGuardada,
} from '../services/api'
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
import type { Cliente } from '../types'
import type { Cliente, Unidad } from '../types'
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
const esquemaCliente = z.object({
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
@@ -162,11 +166,21 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Preselecciona la unidad cuando el inventario terminó de cargar
// Preselecciona la unidad cuando el inventario terminó de cargar. Solo
// unidades DISPONIBLES: si la pedida ya se apartó/vendió (o no está en el
// inventario) se avisa y no se preselecciona nada.
useEffect(() => {
if (unidadPendiente === null || cargando) return
const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
if (encontrada) {
if (!encontrada) {
setAviso(
`La unidad ${unidadPendiente} no está en el inventario actual: elige otra unidad disponible.`,
)
} else if (!esDisponible(encontrada)) {
setAviso(
`La unidad ${encontrada.codigo} ya no está disponible (estatus: ${encontrada.status}). Elige otra unidad disponible.`,
)
} else {
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
setUnidad(encontrada)
@@ -180,7 +194,9 @@ export default function CotizadorPage() {
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
const puedeGenerarPdf = Boolean(unidad) && !errorEnganche && !guardando
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
@@ -234,6 +250,8 @@ export default function CotizadorPage() {
* Persiste la cotización en el API. Si no responde, la guarda localmente
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
* Si el servidor RECHAZA la cotización (unidad no disponible, datos inválidos)
* devuelve null: se avisa y no se guarda nada.
*/
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null
@@ -247,6 +265,7 @@ export default function CotizadorPage() {
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
...(numMeses !== null ? { numMensualidades: numMeses } : {}),
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
...(oportunidadId !== null ? { oportunidadId } : {}),
})
@@ -256,6 +275,12 @@ export default function CotizadorPage() {
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
return conFolio
} catch (err) {
// El servidor contestó y rechazó la cotización: no se guarda ni local
if (err instanceof ErrorApiCotizacion) {
toast.error(err.message)
setAviso(err.message)
return null
}
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
const local = cotizacionLocal(cliente)
void guardarCotizacionLocal(local)
@@ -272,17 +297,17 @@ export default function CotizadorPage() {
setGuardando(true)
try {
const guardada = await guardarEnApi(cliente)
let folio: string | undefined
// Rechazada por el servidor (p. ej. la unidad dejó de estar disponible):
// no se genera el PDF
if (!guardada) return
const folio = guardada.folio ?? folioCotizacion(guardada.id)
let qrDataUrl: string | undefined
if (guardada) {
folio = guardada.folio ?? folioCotizacion(guardada.id)
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
if (!esCotizacionLocal(guardada)) {
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
margin: 1,
width: 200,
})
}
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
if (!esCotizacionLocal(guardada)) {
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
margin: 1,
width: 200,
})
}
generarPdf({
unidad,
@@ -293,7 +318,7 @@ export default function CotizadorPage() {
propuestas: propuestaActiva ? [propuestaActiva] : [],
cliente,
desarrollo: params,
...(folio ? { folio } : {}),
folio,
...(qrDataUrl ? { qrDataUrl } : {}),
vigencia: calcularVigencia(),
})
@@ -402,12 +427,17 @@ export default function CotizadorPage() {
apartado: c.apartado,
descuento: c.descuento,
propuestaIndex: idx >= 0 ? idx : 0,
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null,
})
setValue('nombre', c.clienteNombre, { shouldValidate: true })
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
setUltimaGuardada(c)
setAviso(`Cotización ${c.folio ?? ''} retomada.`)
setAviso(
esDisponible(u)
? `Cotización ${c.folio ?? ''} retomada.`
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`,
)
}
const handleNueva = () => {
@@ -442,6 +472,17 @@ export default function CotizadorPage() {
</p>
)}
{unidadNoDisponible && unidad && (
<p
role="alert"
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-800"
>
La unidad {unidad.codigo} no está disponible (estatus: {unidad.status}): no se puede
cotizar ni apartar. Elige otra unidad disponible; Guardar, Compartir y Generar PDF
quedan bloqueados.
</p>
)}
<div className="grid items-start gap-6 md:grid-cols-1 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
{/* Panel de configuración */}
<aside className="space-y-6 lg:sticky lg:top-24">
@@ -549,7 +590,7 @@ export default function CotizadorPage() {
id="enganche"
type="number"
min={2.5}
max={100}
max={ENGANCHE_MAXIMO_PCT}
step={0.5}
placeholder="Según propuesta"
value={enganchePct ?? ''}
@@ -568,7 +609,8 @@ export default function CotizadorPage() {
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
) : (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Aplica a crédito bancario e Infonavit/Cofinavit; mínimo 2.5%
Aplica a crédito bancario e Infonavit/Cofinavit; rango válido 2.5% a{' '}
{ENGANCHE_MAXIMO_PCT}% (lo que permite el Excel)
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
</p>
)}

View File

@@ -87,10 +87,27 @@ export interface GuardarCotizacionPayload {
acabados: boolean
apartado?: number
descuento?: number
/** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */
numMensualidades?: number
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string
}
/**
* Error del API al guardar una cotización (status HTTP + mensaje del servidor).
* Se distingue de los fallos de red: el servidor SÍ contestó y rechazó la
* cotización (p. ej. la unidad ya no está disponible), así que no se guarda.
*/
export class ErrorApiCotizacion extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ErrorApiCotizacion'
}
}
/**
* URL pública de una cotización (destino del QR del PDF). Debe ser ABSOLUTA:
* un QR con ruta relativa ("/api/...") no es navegable al escanearlo desde el
@@ -111,7 +128,11 @@ export async function guardarCotizacion(payload: GuardarCotizacionPayload): Prom
body: JSON.stringify(payload),
}))
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
if (!resp.ok) {
// El API explica el rechazo (unidad no disponible, datos inválidos, …)
const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null
throw new ErrorApiCotizacion(resp.status, cuerpo?.error ?? `No se pudo guardar la cotización (HTTP ${resp.status})`)
}
return (await resp.json()) as CotizacionGuardada
}

View File

@@ -86,6 +86,12 @@ export interface ParametrosDesarrollo {
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
/** N fijo cuando fuenteDeN === 'fijo' (entrega inmediata / term. obra) */
nFijo?: number
/**
* N por defecto del desarrollo: se usa cuando el inventario no trae fecha de
* entrega ni meses para firma. Garantiza que la mensualidad y el total
* siempre se calculen.
*/
nDefault?: number
/** Cuota mensual de mantenimiento (MXN); null => se muestra "PENDIENTE" */
cuotaMantenimiento: number | null
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */

View File

@@ -1,5 +1,10 @@
import { useCallback, useEffect, useState } from 'react'
import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro'
import {
listarClientesMaestro,
exportarClientesMaestro,
obtenerCatalogosClientesMaestro,
type ClienteMaestro,
} from '../services/clientes-maestro'
import { ClientesMaestroTable } from '../components/ClientesMaestroTable'
import ModalClienteMaestro from '../components/ModalClienteMaestro'
import toast from 'react-hot-toast'
@@ -8,13 +13,11 @@ interface Props {
onVolver: () => void
}
/** Desarrollos disponibles para el filtro */
const DESARROLLOS = ['', 'SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA'] as const
const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCELADO'] as const
/**
* Página / clientes-maestro: tabla paginada con filtros de búsqueda,
* desarrollo y estatus. Botón exportar genera CSV desde los datos filtrados.
* desarrollo y estatus. Las opciones de los selectores salen de la base
* (GET /clientes-maestro/catalogos), no de una lista fija. Botón exportar
* genera CSV desde los datos filtrados.
*/
export function ClientesMaestroPage({ onVolver }: Props) {
const [datos, setDatos] = useState<ClienteMaestro[]>([])
@@ -26,6 +29,20 @@ export function ClientesMaestroPage({ onVolver }: Props) {
const [filtroDesarrollo, setFiltroDesarrollo] = useState('')
const [filtroEstatus, setFiltroEstatus] = useState('')
const [busquedaDebounced, setBusquedaDebounced] = useState('')
const [desarrollos, setDesarrollos] = useState<string[]>([])
const [estatuses, setEstatuses] = useState<string[]>([])
// Catálogos reales de la base para los filtros
useEffect(() => {
obtenerCatalogosClientesMaestro()
.then((catalogos) => {
setDesarrollos(catalogos.desarrollos)
setEstatuses(catalogos.estatuses)
})
.catch(() => {
toast.error('No se pudieron cargar los catálogos de filtros.')
})
}, [])
// Debounce búsqueda
useEffect(() => {
@@ -135,7 +152,7 @@ export function ClientesMaestroPage({ onVolver }: Props) {
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Todos los desarrollos</option>
{DESARROLLOS.filter(Boolean).map((d) => (
{desarrollos.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
@@ -145,7 +162,7 @@ export function ClientesMaestroPage({ onVolver }: Props) {
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Todos los estatus</option>
{ESTATUSES.filter(Boolean).map((e) => (
{estatuses.map((e) => (
<option key={e} value={e}>{e}</option>
))}
</select>

View File

@@ -1,9 +1,16 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import {
DEPARTAMENTOS_SOPORTE,
ESTADOS_SOPORTE,
MAX_ADJUNTOS_SOPORTE,
PRIORIDADES_SOPORTE,
cambiarEstadoSolicitud,
crearSolicitudSoporte,
descargarArchivoSolicitud,
listarSolicitudesSoporte,
nombreArchivoSoporte,
validarAdjuntoSoporte,
type EstadoSoporte,
type SolicitudSoporte,
} from '../services/soporte'
import { getSesion } from '../services/auth'
@@ -23,6 +30,16 @@ const ETIQUETA_ESTADO: Record<string, string> = {
CERRADA: 'Cerrada',
}
const OPCIONES_ESTADO = [
{ valor: '', etiqueta: 'Todos los estatus' },
...ESTADOS_SOPORTE.map((e) => ({ valor: e, etiqueta: ETIQUETA_ESTADO[e] ?? e })),
]
const OPCIONES_DEPARTAMENTO = [
{ valor: '', etiqueta: 'Todos los departamentos' },
...DEPARTAMENTOS_SOPORTE.map((d) => ({ valor: d, etiqueta: d })),
]
const CLASE_ESTADO: Record<string, string> = {
ABIERTA: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300',
EN_PROCESO: 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300',
@@ -39,6 +56,7 @@ function hoyISO(): string {
export function SoportePage({ onVolver }: Props) {
const sesion = getSesion()
const puedeGestionar = sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager'
const [titulo, setTitulo] = useState('')
const [descripcion, setDescripcion] = useState('')
const [departamento, setDepartamento] = useState<string>(DEPARTAMENTOS_SOPORTE[0])
@@ -47,23 +65,33 @@ export function SoportePage({ onVolver }: Props) {
const [fechaSolicitud, setFechaSolicitud] = useState(hoyISO())
const [fechaLimite, setFechaLimite] = useState('')
const [notas, setNotas] = useState('')
const [archivos, setArchivos] = useState<string[]>([])
const [email, setEmail] = useState('')
const [archivos, setArchivos] = useState<File[]>([])
const [errorArchivos, setErrorArchivos] = useState('')
const [enviando, setEnviando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
const [solicitudes, setSolicitudes] = useState<SolicitudSoporte[]>([])
const [cargando, setCargando] = useState(true)
const [filtroEstado, setFiltroEstado] = useState('')
const [filtroDepartamento, setFiltroDepartamento] = useState('')
const [ocupado, setOcupado] = useState('')
const cargar = useCallback(async () => {
setCargando(true)
try {
setSolicitudes(await listarSolicitudesSoporte())
setSolicitudes(
await listarSolicitudesSoporte({
...(filtroEstado ? { estado: filtroEstado } : {}),
...(filtroDepartamento ? { departamento: filtroDepartamento } : {}),
}),
)
} catch {
// La lista es secundaria: el form sigue usable si el listado falla
} finally {
setCargando(false)
}
}, [])
}, [filtroEstado, filtroDepartamento])
useEffect(() => {
void cargar()
@@ -71,7 +99,19 @@ export function SoportePage({ onVolver }: Props) {
const agregarArchivos = (lista: FileList | null) => {
if (lista === null) return
setArchivos((prev) => [...prev, ...Array.from(lista).map((f) => f.name)])
const nuevos: File[] = []
const rechazados: string[] = []
for (const archivo of Array.from(lista)) {
const problema = validarAdjuntoSoporte(archivo)
if (problema) rechazados.push(problema)
else nuevos.push(archivo)
}
const combinados = [...archivos, ...nuevos]
if (combinados.length > MAX_ADJUNTOS_SOPORTE) {
rechazados.push(`Máximo ${MAX_ADJUNTOS_SOPORTE} archivos por solicitud.`)
}
setArchivos(combinados.slice(0, MAX_ADJUNTOS_SOPORTE))
setErrorArchivos(rechazados.join(' '))
}
const enviar = async (e: FormEvent) => {
@@ -94,6 +134,7 @@ export function SoportePage({ onVolver }: Props) {
...(fechaLimite ? { fechaLimite } : {}),
...(notas.trim() ? { notas: notas.trim() } : {}),
...(sesion?.usuario.nombre ? { solicitante: sesion.usuario.nombre } : {}),
...(email.trim() ? { email: email.trim() } : {}),
...(archivos.length > 0 ? { archivos } : {}),
})
setAviso(`Solicitud registrada (${ETIQUETA_ESTADO[creada.estado] ?? creada.estado}).`)
@@ -102,6 +143,7 @@ export function SoportePage({ onVolver }: Props) {
setResponsable('')
setNotas('')
setArchivos([])
setErrorArchivos('')
setFechaLimite('')
await cargar()
} catch (err) {
@@ -111,6 +153,42 @@ export function SoportePage({ onVolver }: Props) {
}
}
const descargar = async (solicitud: SolicitudSoporte, indice: number) => {
const ruta = solicitud.archivos[indice] ?? ''
setError('')
setOcupado(`${solicitud.id}:${indice}`)
try {
const blob = await descargarArchivoSolicitud(solicitud.id, indice)
const url = URL.createObjectURL(blob)
const enlace = document.createElement('a')
enlace.href = url
enlace.download = nombreArchivoSoporte(ruta)
document.body.appendChild(enlace)
enlace.click()
enlace.remove()
URL.revokeObjectURL(url)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo descargar el archivo.')
} finally {
setOcupado('')
}
}
const cambiarEstado = async (solicitud: SolicitudSoporte, estado: string) => {
setAviso('')
setError('')
setOcupado(solicitud.id)
try {
const actualizada = await cambiarEstadoSolicitud(solicitud.id, estado as EstadoSoporte)
setSolicitudes((prev) => prev.map((s) => (s.id === actualizada.id ? actualizada : s)))
setAviso(`"${actualizada.titulo}" ahora está ${ETIQUETA_ESTADO[actualizada.estado] ?? actualizada.estado}.`)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar el estado.')
} finally {
setOcupado('')
}
}
return (
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
{/* Header */}
@@ -206,6 +284,18 @@ export function SoportePage({ onVolver }: Props) {
/>
</label>
<label htmlFor="soporte-email" className="block">
<span className={labelClase}>Correo del solicitante</span>
<input
id="soporte-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="nombre@frasa.mx"
className={inputClase}
/>
</label>
<label htmlFor="soporte-fecha-limite" className="block">
<span className={labelClase}>Fecha límite</span>
<input
@@ -218,11 +308,12 @@ export function SoportePage({ onVolver }: Props) {
</label>
<label htmlFor="soporte-archivos" className="block">
<span className={labelClase}>Archivos</span>
<span className={labelClase}>Archivos (PDF, JPG o PNG · máx 10 MB)</span>
<input
id="soporte-archivos"
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
onChange={(e) => agregarArchivos(e.target.files)}
className={`${inputClase} file:mr-3 file:rounded-md file:border-0 file:bg-[#F1F5F9] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-[#374151]`}
/>
@@ -242,15 +333,15 @@ export function SoportePage({ onVolver }: Props) {
{archivos.length > 0 && (
<ul className="mt-3 flex flex-wrap gap-2">
{archivos.map((nombre, i) => (
{archivos.map((archivo, i) => (
<li
key={`${nombre}-${i}`}
key={`${archivo.name}-${i}`}
className="flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-1.5 text-xs text-[#374151] dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#CBD5E1]"
>
{nombre}
{archivo.name}
<button
type="button"
aria-label={`Quitar ${nombre}`}
aria-label={`Quitar ${archivo.name}`}
onClick={() => setArchivos((prev) => prev.filter((_, j) => j !== i))}
className="text-[#64748B] hover:text-red-600 dark:text-[#94A3B8]"
>
@@ -261,6 +352,12 @@ export function SoportePage({ onVolver }: Props) {
</ul>
)}
{errorArchivos && (
<p role="alert" className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
{errorArchivos}
</p>
)}
{error && (
<p role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{error}
@@ -282,14 +379,49 @@ export function SoportePage({ onVolver }: Props) {
</form>
{/* Listado de solicitudes */}
<section aria-label="Mis solicitudes" className="mt-12 max-w-3xl">
<section aria-label={puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'} className="mt-12 max-w-3xl">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
{sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager' ? 'Todas las solicitudes' : 'Mis solicitudes'}
{puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'}
</h2>
{/* Filtros del listado */}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label htmlFor="soporte-filtro-estado" className="block">
<span className={labelClase}>Filtrar por estatus</span>
<select
id="soporte-filtro-estado"
value={filtroEstado}
onChange={(e) => setFiltroEstado(e.target.value)}
className={inputClase}
>
{OPCIONES_ESTADO.map((o) => (
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
))}
</select>
</label>
<label htmlFor="soporte-filtro-departamento" className="block">
<span className={labelClase}>Filtrar por departamento</span>
<select
id="soporte-filtro-departamento"
value={filtroDepartamento}
onChange={(e) => setFiltroDepartamento(e.target.value)}
className={inputClase}
>
{OPCIONES_DEPARTAMENTO.map((o) => (
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
))}
</select>
</label>
</div>
{cargando ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
) : solicitudes.length === 0 ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Aún no hay solicitudes registradas.</p>
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">
{filtroEstado || filtroDepartamento
? 'Ninguna solicitud coincide con los filtros.'
: 'Aún no hay solicitudes registradas.'}
</p>
) : (
<ul className="mt-4 space-y-3">
{solicitudes.map((s) => (
@@ -299,21 +431,48 @@ export function SoportePage({ onVolver }: Props) {
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{s.titulo}</p>
<span
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[s.estado] ?? 'border-[#E5E7EB] text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]'}`}
>
{ETIQUETA_ESTADO[s.estado] ?? s.estado}
</span>
<div className="flex items-center gap-2">
<span
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[s.estado] ?? 'border-[#E5E7EB] text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]'}`}
>
{ETIQUETA_ESTADO[s.estado] ?? s.estado}
</span>
{puedeGestionar && (
<select
aria-label={`Cambiar estatus de ${s.titulo}`}
value={s.estado}
disabled={ocupado === s.id}
onChange={(e) => void cambiarEstado(s, e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
{ESTADOS_SOPORTE.map((e) => (
<option key={e} value={e}>{ETIQUETA_ESTADO[e] ?? e}</option>
))}
</select>
)}
</div>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'} ·{' '}
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'}
{s.solicitanteEmail ? ` (${s.solicitanteEmail})` : ''} ·{' '}
{new Date(s.fechaSolicitud).toLocaleDateString('es-MX')}
</p>
<p className="mt-2 text-sm text-[#374151] dark:text-[#CBD5E1]">{s.descripcion}</p>
{s.archivos.length > 0 && (
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Archivos: {s.archivos.join(', ')}
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{s.archivos.map((ruta, i) => (
<li key={`${ruta}-${i}`}>
<button
type="button"
onClick={() => void descargar(s, i)}
disabled={ocupado === `${s.id}:${i}`}
className="rounded-lg border border-[#D1D5DB] bg-[#F9FAFB] px-2.5 py-1 text-xs text-[#374151] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
⬇ {nombreArchivoSoporte(ruta)}
</button>
</li>
))}
</ul>
)}
</li>
))}

View File

@@ -48,6 +48,12 @@ export interface ResultadoListado {
limite: number
}
/** Valores reales de la base para armar los filtros (no listas fijas) */
export interface CatalogosClientesMaestro {
desarrollos: string[]
estatuses: string[]
}
// ─── API calls ──────────────────────────────────────────────────────
/** GET /api/clientes-maestro — listar con filtros y paginación */
@@ -64,6 +70,17 @@ export async function listarClientesMaestro(filtros: FiltrosClientesMaestro = {}
return (await resp.json()) as ResultadoListado
}
/**
* GET /api/clientes-maestro/catalogos — desarrollos y estatus que existen hoy
* en la base; el front arma los selectores de filtro con esto.
*/
export async function obtenerCatalogosClientesMaestro(): Promise<CatalogosClientesMaestro> {
const resp = await fetch(`${API_URL}/clientes-maestro/catalogos`, conAuth())
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as CatalogosClientesMaestro
}
/** GET /api/clientes-maestro/exportar — array de objetos para generar CSV */
export async function exportarClientesMaestro(filtros: Omit<FiltrosClientesMaestro, 'pagina' | 'limite'> = {}): Promise<ClienteMaestro[]> {
const params = new URLSearchParams()

View File

@@ -0,0 +1,176 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ESTADOS_SOPORTE,
cambiarEstadoSolicitud,
crearSolicitudSoporte,
descargarArchivoSolicitud,
listarSolicitudesSoporte,
nombreArchivoSoporte,
validarAdjuntoSoporte,
MAX_BYTES_ADJUNTO_SOPORTE,
} from './soporte'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const jsonOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
const solicitudFake = {
id: 'sol-1',
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
responsable: null,
fechaSolicitud: '2026-09-21T00:00:00.000Z',
fechaLimite: null,
notas: null,
archivos: ['soporte/sol-1/a1b2c3d4-factura.pdf'],
solicitante: 'Vendedor',
solicitanteEmail: 'vendedor@frasa.mx',
estado: 'ABIERTA',
createdAt: '2026-09-21T00:00:00.000Z',
updatedAt: '2026-09-21T00:00:00.000Z',
}
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
// ── crearSolicitudSoporte (multipart con adjuntos reales) ────────────
describe('crearSolicitudSoporte', () => {
it('manda multipart con los campos y los archivos adjuntos', async () => {
fetchMock.mockResolvedValue(jsonOk(solicitudFake))
const archivo = new File(['%PDF-1.4'], 'factura.pdf', { type: 'application/pdf' })
await crearSolicitudSoporte({
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
fechaSolicitud: '2026-09-21',
email: 'vendedor@frasa.mx',
archivos: [archivo],
})
const [url, init] = fetchMock.mock.calls[0]!
expect(String(url)).toBe('/api/soporte')
expect(init?.method).toBe('POST')
const form = init?.body as FormData
expect(form).toBeInstanceOf(FormData)
expect(form.get('titulo')).toBe('Impresora')
expect(form.get('fechaSolicitud')).toBe('2026-09-21')
expect(form.get('email')).toBe('vendedor@frasa.mx')
const adjuntos = form.getAll('archivos')
expect(adjuntos).toHaveLength(1)
expect((adjuntos[0] as File).name).toBe('factura.pdf')
})
it('muestra el mensaje real del servidor con los issues de validación', async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
error: 'Validación fallida',
issues: [{ path: ['fechaSolicitud'], message: 'Invalid date' }],
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
)
await expect(
crearSolicitudSoporte({
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
fechaSolicitud: '',
}),
).rejects.toThrow('Validación fallida: fechaSolicitud: Invalid date')
})
})
// ── listarSolicitudesSoporte (filtros) ──────────────────────────────
describe('listarSolicitudesSoporte', () => {
it('sin filtros no agrega query', async () => {
fetchMock.mockResolvedValue(jsonOk([]))
await listarSolicitudesSoporte()
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte')
})
it('manda estado y departamento como query params', async () => {
fetchMock.mockResolvedValue(jsonOk([]))
await listarSolicitudesSoporte({ estado: 'EN_PROCESO', departamento: 'Cobranza' })
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte?estado=EN_PROCESO&departamento=Cobranza')
})
it('ignora filtros vacíos', async () => {
fetchMock.mockResolvedValue(jsonOk([]))
await listarSolicitudesSoporte({ estado: '', departamento: '' })
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte')
})
})
// ── cambiarEstadoSolicitud ──────────────────────────────────────────
describe('cambiarEstadoSolicitud', () => {
it('hace PATCH con el estado nuevo', async () => {
fetchMock.mockResolvedValue(jsonOk({ ...solicitudFake, estado: 'CERRADA' }))
const actualizada = await cambiarEstadoSolicitud('sol-1', 'CERRADA')
const [url, init] = fetchMock.mock.calls[0]!
expect(String(url)).toBe('/api/soporte/sol-1')
expect(init?.method).toBe('PATCH')
expect(JSON.parse(String(init?.body))).toEqual({ estado: 'CERRADA' })
expect(actualizada.estado).toBe('CERRADA')
expect(ESTADOS_SOPORTE).toContain('CERRADA')
})
})
// ── descargarArchivoSolicitud ───────────────────────────────────────
describe('descargarArchivoSolicitud', () => {
it('pide el adjunto por índice y devuelve el blob', async () => {
fetchMock.mockResolvedValue(new Response('contenido', { status: 200 }))
const blob = await descargarArchivoSolicitud('sol-1', 0)
expect(String(fetchMock.mock.calls[0]![0])).toBe('/api/soporte/sol-1/archivos/0')
expect(blob).toBeInstanceOf(Blob)
expect(blob.size).toBe('contenido'.length)
})
it('propaga el mensaje del servidor si falla', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: 'Archivo no encontrado en disco' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
}),
)
await expect(descargarArchivoSolicitud('sol-1', 3)).rejects.toThrow('Archivo no encontrado en disco')
})
})
// ── Helpers de UI ───────────────────────────────────────────────────
describe('helpers de adjuntos', () => {
it('valida tipo y tamaño', () => {
expect(validarAdjuntoSoporte(new File(['x'], 'a.pdf', { type: 'application/pdf' }))).toBeNull()
expect(validarAdjuntoSoporte(new File(['x'], 'a.txt', { type: 'text/plain' }))).toContain('a.txt')
const grande = new File(['x'], 'a.pdf', { type: 'application/pdf' })
Object.defineProperty(grande, 'size', { value: MAX_BYTES_ADJUNTO_SOPORTE + 1 })
expect(validarAdjuntoSoporte(grande)).toContain('10 MB')
})
it('nombreArchivoSoporte quita carpeta y prefijo uuid', () => {
expect(nombreArchivoSoporte('soporte/sol-1/a1b2c3d4-factura.pdf')).toBe('factura.pdf')
expect(nombreArchivoSoporte('a1b2c3d4-foto.png')).toBe('foto.png')
})
})

View File

@@ -13,8 +13,17 @@ export const DEPARTAMENTOS_SOPORTE = [
/** Prioridades de una solicitud */
export const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
/** Estados por los que pasa una solicitud (los mueve admin/manager) */
export const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
export type DepartamentoSoporte = (typeof DEPARTAMENTOS_SOPORTE)[number]
export type PrioridadSoporte = (typeof PRIORIDADES_SOPORTE)[number]
export type EstadoSoporte = (typeof ESTADOS_SOPORTE)[number]
/** Tipos y tamaños de adjunto aceptados (igual que el API) */
export const MIMES_ADJUNTO_SOPORTE = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']
export const MAX_BYTES_ADJUNTO_SOPORTE = 10 * 1024 * 1024
export const MAX_ADJUNTOS_SOPORTE = 10
export interface SolicitudSoporte {
id: string
@@ -26,6 +35,7 @@ export interface SolicitudSoporte {
fechaSolicitud: string
fechaLimite: string | null
notas: string | null
/** Rutas relativas de los adjuntos dentro de uploads/ */
archivos: string[]
solicitante: string
solicitanteEmail: string | null
@@ -34,7 +44,44 @@ export interface SolicitudSoporte {
updatedAt: string
}
/** POST /api/soporte — crear solicitud de soporte */
export interface FiltrosSoporte {
estado?: string
departamento?: string
}
/**
* Valida un adjunto antes de subirlo: devuelve el motivo del rechazo o null si
* es válido (el API valida lo mismo, aquí es para no esperar la subida).
*/
export function validarAdjuntoSoporte(archivo: File): string | null {
if (!MIMES_ADJUNTO_SOPORTE.includes(archivo.type)) {
return `${archivo.name}: solo se aceptan PDF, JPG o PNG.`
}
if (archivo.size > MAX_BYTES_ADJUNTO_SOPORTE) {
return `${archivo.name}: excede 10 MB.`
}
return null
}
/** Nombre legible de un adjunto: sin carpeta ni prefijo uuid */
export function nombreArchivoSoporte(ruta: string): string {
const base = ruta.split(/[\\/]/).pop() ?? ruta
return base.replace(/^[0-9a-f]{8}-/i, '')
}
/** Mensaje real del servidor: el error handler responde { error, issues? } */
async function mensajeDeError(resp: Response): Promise<string> {
const body = (await resp.json().catch(() => null)) as
| { error?: string; issues?: Array<{ path?: Array<string | number>; message?: string }> }
| null
const detalles = (body?.issues ?? [])
.map((i) => [i.path?.join('.'), i.message].filter(Boolean).join(': '))
.filter((d) => d.length > 0)
if (detalles.length > 0) return `${body?.error ?? 'Validación fallida'}: ${detalles.join('; ')}`
return body?.error ?? `HTTP ${resp.status}`
}
/** POST /api/soporte — crear solicitud de soporte (multipart si hay adjuntos) */
export async function crearSolicitudSoporte(payload: {
titulo: string
descripcion: string
@@ -46,28 +93,64 @@ export async function crearSolicitudSoporte(payload: {
notas?: string
solicitante?: string
email?: string
archivos?: string[]
/** Adjuntos reales: viajan en el multipart, el API los guarda en disco */
archivos?: File[]
}): Promise<SolicitudSoporte> {
const resp = await fetch(`${API_URL}/soporte`, conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}))
const form = new FormData()
form.set('titulo', payload.titulo)
form.set('descripcion', payload.descripcion)
form.set('departamento', payload.departamento)
form.set('prioridad', payload.prioridad)
form.set('fechaSolicitud', payload.fechaSolicitud)
if (payload.responsable) form.set('responsable', payload.responsable)
if (payload.fechaLimite) form.set('fechaLimite', payload.fechaLimite)
if (payload.notas) form.set('notas', payload.notas)
if (payload.solicitante) form.set('solicitante', payload.solicitante)
if (payload.email) form.set('email', payload.email)
for (const archivo of payload.archivos ?? []) form.append('archivos', archivo)
// Sin Content-Type explícito: el navegador pone el boundary del multipart
const resp = await fetch(`${API_URL}/soporte`, conAuth({ method: 'POST', body: form }))
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return (await resp.json()) as SolicitudSoporte
}
/** GET /api/soporte — solicitudes propias (admin: todas) */
export async function listarSolicitudesSoporte(): Promise<SolicitudSoporte[]> {
const resp = await fetch(`${API_URL}/soporte`, conAuth())
/** GET /api/soporte — solicitudes propias (admin/manager: todas) con filtros */
export async function listarSolicitudesSoporte(filtros: FiltrosSoporte = {}): Promise<SolicitudSoporte[]> {
const params = new URLSearchParams()
if (filtros.estado) params.set('estado', filtros.estado)
if (filtros.departamento) params.set('departamento', filtros.departamento)
const qs = params.toString()
const resp = await fetch(`${API_URL}/soporte${qs ? `?${qs}` : ''}`, conAuth())
manejar401(resp)
if (!resp.ok) {
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return (await resp.json()) as SolicitudSoporte[]
}
/** PATCH /api/soporte/:id — cambiar el estado (admin/manager) */
export async function cambiarEstadoSolicitud(id: string, estado: EstadoSoporte): Promise<SolicitudSoporte> {
const resp = await fetch(
`${API_URL}/soporte/${encodeURIComponent(id)}`,
conAuth({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado }),
}),
)
manejar401(resp)
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return (await resp.json()) as SolicitudSoporte
}
/** GET /api/soporte/:id/archivos/:indice — descarga un adjunto (blob) */
export async function descargarArchivoSolicitud(id: string, indice: number): Promise<Blob> {
const resp = await fetch(
`${API_URL}/soporte/${encodeURIComponent(id)}/archivos/${indice}`,
conAuth(),
)
manejar401(resp)
if (!resp.ok) throw new Error(await mensajeDeError(resp))
return await resp.blob()
}