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 {