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,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">