feat(mejoras): 22 mejoras UX en todos los módulos

Mi día: cancelación con motivo, conteo escrituración real, cita sin retroceder, calendario completo
Cobranza: MoraDashboard respeta alcance, hoy dinámico, cancelar con motivo
Landing: contactos paginados, error vs vacío diferenciado
Dashboards: KPI renombrado, drill-down planes activos
Chat: historial recarga, preguntas rotativas, error solo toast
Transversal: shadow-sm corrupto corregido en 9 archivos

553 API tests + 7 apps construidas
This commit is contained in:
2026-09-11 12:48:47 +00:00
parent 89d95eb53b
commit 1ba7aa49da
28 changed files with 518 additions and 160 deletions

View File

@@ -75,16 +75,18 @@ describe('listarContactosEndpoint (GET /api/contactos) — local', () => {
it('lista desde LeadCaptura/Cliente con la misma forma (fuente local)', async () => { it('lista desde LeadCaptura/Cliente con la misma forma (fuente local)', async () => {
const { res, json } = resFake() const { res, json } = resFake()
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res) await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' }, query: {} } as unknown as Request, res)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[] const respuesta = json.mock.calls[0]?.[0] as { contactos: Record<string, unknown>[]; pagina: number; hayMas: boolean }
expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' }) expect(respuesta.contactos[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' })
expect(respuesta.pagina).toBe(1)
expect(respuesta.hayMas).toBe(false)
}) })
it('filtra por el usuario del token (sus clientes y sus capturas)', async () => { it('filtra por el usuario del token (sus clientes y sus capturas)', async () => {
const { res } = resFake() const { res } = resFake()
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res) await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' }, query: {} } as unknown as Request, res)
expect(prismaMock.cliente.findMany).toHaveBeenCalledWith( expect(prismaMock.cliente.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { capturadoPorId: 'u-1' } }), expect.objectContaining({ where: { capturadoPorId: 'u-1' } }),

View File

@@ -5,11 +5,14 @@ import { NotFoundError } from '../utils/errors.js'
/** /**
* GET /api/contactos (Bearer) → contactos locales (tabla Cliente + LeadCaptura: * GET /api/contactos (Bearer) → contactos locales (tabla Cliente + LeadCaptura:
* nombre, teléfono, email, estado), lo más reciente primero, máx 100. Filtra * nombre, teléfono, email, estado), lo más reciente primero, paginado de 100 en
* por usuario actual: clientes capturados por él + leads asignados a él. * 100 (?pagina=N; el front muestra el límite). Filtra por usuario actual:
* clientes capturados por él + leads asignados a él.
*/ */
export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> { export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
res.json(await listarContactosLocal(req.usuarioToken?.sub)) const paginaParam = Number(req.query['pagina'])
const pagina = Number.isFinite(paginaParam) && paginaParam >= 1 ? Math.floor(paginaParam) : 1
res.json(await listarContactosLocal(req.usuarioToken?.sub, pagina))
} }
/** /**

View File

@@ -85,7 +85,7 @@ describe('listarContactosLocal', () => {
capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Pago Apartado', board: 'APARTADO' }), capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Pago Apartado', board: 'APARTADO' }),
]) ])
const lista = await listarContactosLocal() const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(2) expect(lista).toHaveLength(2)
expect(lista[0]).toMatchObject({ expect(lista[0]).toMatchObject({
@@ -113,10 +113,34 @@ describe('listarContactosLocal', () => {
capturaFake({ leadId: 'loc_1', telefono: '4771234567' }), capturaFake({ leadId: 'loc_1', telefono: '4771234567' }),
]) ])
const lista = await listarContactosLocal() const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(1) expect(lista).toHaveLength(1)
expect(lista[0]?.id).toBe('c1') expect(lista[0]?.id).toBe('c1')
}) })
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
// 101 clientes: la página 1 llena 100 y detecta que hay más
prismaMock.cliente.findMany.mockResolvedValue(
Array.from({ length: 101 }, (_, i) =>
clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }),
),
)
const pagina1 = await listarContactosLocal(undefined, 1)
expect(pagina1.pagina).toBe(1)
expect(pagina1.porPagina).toBe(100)
expect(pagina1.contactos).toHaveLength(100)
expect(pagina1.hayMas).toBe(true)
// La página 2 recibe el siguiente bloque (skip=100, take=101)
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c101', celular: '4770000100' }),
])
const pagina2 = await listarContactosLocal(undefined, 2)
expect(pagina2.pagina).toBe(2)
expect(pagina2.contactos).toHaveLength(1)
expect(pagina2.hayMas).toBe(false)
})
}) })
describe('detalleContactoLocal', () => { describe('detalleContactoLocal', () => {

View File

@@ -21,14 +21,30 @@ export interface ContactoListaLocal {
/** /**
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards * Lista local: Clientes (alta de clientes) + capturas del embudo en boards
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la * OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
* ficha completa), más reciente primero, máx 100 (como getContactos). * ficha completa), más reciente primero. Paginada de 100 en 100 (el front
* Filtra por usuario actual: clientes capturados por él + leads asignados a él. * pagina con `?pagina=N` y muestra el límite). Filtra por usuario actual:
* clientes capturados por él + leads asignados a él.
*/ */
export async function listarContactosLocal(usuarioId?: string): Promise<ContactoListaLocal[]> { export const CONTACTOS_POR_PAGINA = 100
export interface ListaContactos {
contactos: ContactoListaLocal[]
pagina: number
porPagina: number
/** true si quedan contactos en la página siguiente */
hayMas: boolean
}
export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
const porPagina = CONTACTOS_POR_PAGINA
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
const skip = (paginaValida - 1) * porPagina
// +1 por fuente para detectar si queda más (hayMas) sin una consulta extra
const clientes = await prisma.cliente.findMany({ const clientes = await prisma.cliente.findMany({
where: usuarioId ? { capturadoPorId: usuarioId } : {}, where: usuarioId ? { capturadoPorId: usuarioId } : {},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 100, skip,
take: porPagina + 1,
}) })
const capturas = await prisma.leadCaptura.findMany({ const capturas = await prisma.leadCaptura.findMany({
where: { where: {
@@ -36,7 +52,8 @@ export async function listarContactosLocal(usuarioId?: string): Promise<Contacto
...(usuarioId ? { usuarioId } : {}), ...(usuarioId ? { usuarioId } : {}),
}, },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take: 100, skip,
take: porPagina + 1,
}) })
const vistos = new Set<string>() const vistos = new Set<string>()
@@ -72,7 +89,14 @@ export async function listarContactosLocal(usuarioId?: string): Promise<Contacto
fuente: 'local', fuente: 'local',
}) })
} }
return lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 100) const ordenada = lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
const hayMas = ordenada.length > porPagina
return {
contactos: ordenada.slice(0, porPagina),
pagina: paginaValida,
porPagina,
hayMas,
}
} }
/** /**

View File

@@ -148,6 +148,8 @@ export interface ResumenMiDia {
misCapturas: CapturaMiDia[] misCapturas: CapturaMiDia[]
misClientes: ClienteMiDia[] misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[] misActividadesHoy: ActividadMiDia[]
/** Todas las actividades ABIERTAS del usuario (sin filtro de fecha): alimenta el calendario */
todasMisActividades: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[] apartadosPorVencer: ApartadoPorVencer[]
/** MIS oportunidades: conteo por etapa + lista de trabajo (etapas no terminales) */ /** MIS oportunidades: conteo por etapa + lista de trabajo (etapas no terminales) */
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] }
@@ -381,13 +383,8 @@ export function resumenMiDiaLocal(
const hoy = fechaLocal(ahora, 0) const hoy = fechaLocal(ahora, 0)
const manana = fechaLocal(ahora, 1) const manana = fechaLocal(ahora, 1)
const en7Dias = fechaLocal(ahora, 7) const en7Dias = fechaLocal(ahora, 7)
const misActividadesHoy: ActividadMiDia[] = actividades const actividadesAbiertasOrdenadas = actividades
.filter((a) => a.estado.toUpperCase() === 'ABIERTA') .filter((a) => a.estado.toUpperCase() === 'ABIERTA')
.filter((a) => {
if (!a.fechaVencimiento) return true
const fv = a.fechaVencimiento.toISOString().slice(0, 10)
return fv <= manana
})
.sort((a, b) => { .sort((a, b) => {
const fa = a.fechaVencimiento?.toISOString() ?? '9999' const fa = a.fechaVencimiento?.toISOString() ?? '9999'
const fb = b.fechaVencimiento?.toISOString() ?? '9999' const fb = b.fechaVencimiento?.toISOString() ?? '9999'
@@ -400,6 +397,12 @@ export function resumenMiDiaLocal(
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '', fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '',
estado: a.estado, estado: a.estado,
})) }))
const todasMisActividades: ActividadMiDia[] = actividadesAbiertasOrdenadas
const misActividadesHoy: ActividadMiDia[] = actividadesAbiertasOrdenadas
.filter((a) => {
if (a.fecha === '') return true
return a.fecha <= manana
})
// --- Apartados por vencer --- // --- Apartados por vencer ---
const apartadosPorVencer: ApartadoPorVencer[] = apartadosLocales const apartadosPorVencer: ApartadoPorVencer[] = apartadosLocales
@@ -479,6 +482,7 @@ export function resumenMiDiaLocal(
misCapturas: misCapturasSinDuplicadosLocal, misCapturas: misCapturasSinDuplicadosLocal,
misClientes, misClientes,
misActividadesHoy, misActividadesHoy,
todasMisActividades,
apartadosPorVencer, apartadosPorVencer,
misOportunidades: { porEtapa, items: itemsOportunidades }, misOportunidades: { porEtapa, items: itemsOportunidades },
misApartados, misApartados,

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { DESARROLLOS, preguntasSugeridas } from './vocabulario'
describe('preguntasSugeridas', () => {
it('devuelve 4 preguntas, cada una de un desarrollo distinto', () => {
const preguntas = preguntasSugeridas(new Date('2026-09-11T12:00:00'))
expect(preguntas).toHaveLength(4)
const usados = DESARROLLOS.filter((d) => preguntas.some((p) => p.includes(d)))
expect(usados).toHaveLength(4)
})
it('rota día con día: dos fechas consecutivas no comparten desarrollo en la primera pregunta', () => {
const lunes = preguntasSugeridas(new Date('2026-09-07T00:00:00'))
const martes = preguntasSugeridas(new Date('2026-09-08T00:00:00'))
expect(lunes[0]).not.toBe(martes[0])
})
it('tras DESARROLLOS.length días vuelve a la combinación inicial', () => {
const base = new Date('2026-01-01T00:00:00')
const inicial = preguntasSugeridas(base)
const vuelta = preguntasSugeridas(new Date(base.getTime() + DESARROLLOS.length * 86_400_000))
expect(vuelta).toEqual(inicial)
})
})

View File

@@ -0,0 +1,33 @@
/**
* Espejo del vocabulario del API (api-gateway/src/config/vocabulario.ts):
* DESARROLLOS es la lista canónica de desarrollos FRASA. Mantener sincronizado.
*/
export const DESARROLLOS = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
/** Plantillas de preguntas sugeridas, parametrizadas por desarrollo */
const PLANTILLAS: ((desarrollo: string) => string)[] = [
(d) => `¿Qué modelos hay en el catálogo ${d}?`,
(d) => `¿Qué incluye la propuesta del cotizador ${d}?`,
(d) => `¿Cuál es la disponibilidad de unidades en ${d}?`,
(d) => `¿Qué amenidades y ubicación tiene ${d}?`,
]
/** Día del año (1..366) para la rotación diaria */
function diaDelAnio(fecha: Date): number {
const inicio = Date.UTC(fecha.getFullYear(), 0, 1)
const actual = Date.UTC(fecha.getFullYear(), fecha.getMonth(), fecha.getDate())
return Math.round((actual - inicio) / 86_400_000) + 1
}
/**
* Preguntas sugeridas rotativas: cada una toma un desarrollo distinto
* (VOCABULARIO.DESARROLLOS) y la combinación avanza día con día, de modo que
* las sugerencias del chat rotan por desarrollo sin repetirse el mismo día.
*/
export function preguntasSugeridas(fecha: Date = new Date()): string[] {
const dia = diaDelAnio(fecha)
return PLANTILLAS.map((plantilla, i) => {
const desarrollo = DESARROLLOS[(dia + i) % DESARROLLOS.length]!
return plantilla(desarrollo)
})
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import Burbuja from '../components/Burbuja' import Burbuja from '../components/Burbuja'
import DarkModeToggle from '../components/DarkModeToggle' import DarkModeToggle from '../components/DarkModeToggle'
import toast from "react-hot-toast" import toast from "react-hot-toast"
@@ -10,25 +10,20 @@ import {
type ConversacionResumen, type ConversacionResumen,
} from '../services/api' } from '../services/api'
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion' import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
import { preguntasSugeridas } from '../lib/vocabulario'
import type { Mensaje } from '../types' import type { Mensaje } from '../types'
let contadorLocal = 0 let contadorLocal = 0
const idLocal = () => `local-${++contadorLocal}` const idLocal = () => `local-${++contadorLocal}`
const PREGUNTAS_SUGERIDAS = [
'¿Qué incluye la propuesta del cotizador DIAGONAL?',
'¿Cuáles son los pasos del proceso de venta?',
'¿Qué políticas de Frasa debo conocer?',
'¿Qué modelos hay en el catálogo DIAGONAL?',
]
export default function ChatPage() { export default function ChatPage() {
const [sessionId, setSessionId] = useState<string | null>(null) const [sessionId, setSessionId] = useState<string | null>(null)
const [mensajes, setMensajes] = useState<Mensaje[]>([]) const [mensajes, setMensajes] = useState<Mensaje[]>([])
const [entrada, setEntrada] = useState('') const [entrada, setEntrada] = useState('')
const [escribiendo, setEscribiendo] = useState(false) const [escribiendo, setEscribiendo] = useState(false)
const [sinConexion, setSinConexion] = useState(false) const [sinConexion, setSinConexion] = useState(false)
const [error, setError] = useState<string | null>(null) // Preguntas sugeridas del día: rotan por desarrollo (VOCABULARIO.DESARROLLOS)
const preguntasSugeridasHoy = useMemo(() => preguntasSugeridas(), [])
// Texto del último envío fallido (habilita "Reintentar" junto al input) // Texto del último envío fallido (habilita "Reintentar" junto al input)
const [pendiente, setPendiente] = useState<string | null>(null) const [pendiente, setPendiente] = useState<string | null>(null)
// Panel de conversaciones previas // Panel de conversaciones previas
@@ -70,7 +65,7 @@ export default function ChatPage() {
if (esErrorDeRed(err)) { if (esErrorDeRed(err)) {
setSinConexion(true) setSinConexion(true)
} else { } else {
toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje') toast.error('Error al enviar el mensaje')
} }
setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar
} finally { } finally {
@@ -82,7 +77,6 @@ export default function ChatPage() {
const texto = entrada.trim() const texto = entrada.trim()
if (!texto || escribiendo) return if (!texto || escribiendo) return
setEntrada('') setEntrada('')
setError(null)
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }]) setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
await transmitir(texto) await transmitir(texto)
} }
@@ -90,7 +84,6 @@ export default function ChatPage() {
/** Reenvía el último mensaje que falló (sin duplicar la burbuja del usuario) */ /** Reenvía el último mensaje que falló (sin duplicar la burbuja del usuario) */
async function reintentar() { async function reintentar() {
if (!pendiente || escribiendo) return if (!pendiente || escribiendo) return
setError(null)
setSinConexion(false) setSinConexion(false)
await transmitir(pendiente) await transmitir(pendiente)
} }
@@ -99,15 +92,14 @@ export default function ChatPage() {
limpiarSesion() limpiarSesion()
setSessionId(null) setSessionId(null)
setMensajes([]) setMensajes([])
setError(null)
setSinConexion(false) setSinConexion(false)
setPendiente(null) setPendiente(null)
} }
/** Abre el panel y carga (una vez) el listado de conversaciones del usuario */ /** Abre el panel y recarga SIEMPRE el listado de conversaciones del usuario */
function abrirPanel() { function abrirPanel() {
setPanelAbierto(true) setPanelAbierto(true)
if (conversaciones !== null || cargandoConversaciones) return if (cargandoConversaciones) return
setCargandoConversaciones(true) setCargandoConversaciones(true)
listarConversaciones() listarConversaciones()
.then(setConversaciones) .then(setConversaciones)
@@ -123,7 +115,6 @@ export default function ChatPage() {
guardarSesion(id) guardarSesion(id)
setMensajes(historial) setMensajes(historial)
setPendiente(null) setPendiente(null)
setError(null)
setSinConexion(false) setSinConexion(false)
setPanelAbierto(false) setPanelAbierto(false)
} catch { } catch {
@@ -132,7 +123,7 @@ export default function ChatPage() {
} }
return ( return (
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]"> <div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A]">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95"> <header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
<div className="mx-auto flex h-14 w-full max-w-3xl items-center justify-between px-4 sm:px-6"> <div className="mx-auto flex h-14 w-full max-w-3xl items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -176,11 +167,6 @@ export default function ChatPage() {
nuevo. nuevo.
</p> </p>
)} )}
{error && (
<p className="mb-6 rounded-lg bg-red-50 px-4 py-3 text-xs font-medium text-red-700">
{error}
</p>
)}
{mensajes.length === 0 && !escribiendo ? ( {mensajes.length === 0 && !escribiendo ? (
<div className="flex flex-1 flex-col items-center justify-center"> <div className="flex flex-1 flex-col items-center justify-center">
@@ -193,7 +179,7 @@ export default function ChatPage() {
venta o las políticas de Frasa. venta o las políticas de Frasa.
</p> </p>
<div className="mt-6 grid gap-2 sm:grid-cols-2"> <div className="mt-6 grid gap-2 sm:grid-cols-2">
{PREGUNTAS_SUGERIDAS.map((pregunta) => ( {preguntasSugeridasHoy.map((pregunta) => (
<button <button
key={pregunta} key={pregunta}
type="button" type="button"

View File

@@ -70,7 +70,7 @@ export default function BusquedaLead({ onSeleccion }: Props) {
)} )}
{resultados !== null && resultados.length > 0 && ( {resultados !== null && resultados.length > 0 && (
<ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm"> <ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
{resultados.map((lead) => ( {resultados.map((lead) => (
<li key={`${lead.fuente}-${lead.id}`}> <li key={`${lead.fuente}-${lead.id}`}>
<button <button

View File

@@ -70,7 +70,7 @@ const formatoFechaHora = (iso: string): string =>
/** Tarjeta del paso actual del wizard (el stepper queda fuera, a la izquierda en lg) */ /** Tarjeta del paso actual del wizard (el stepper queda fuera, a la izquierda en lg) */
function TarjetaPaso({ children }: { children: ReactNode }) { function TarjetaPaso({ children }: { children: ReactNode }) {
return <div className="rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">{children}</div> return <div className="rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">{children}</div>
} }
/** Cuadro con las iniciales de la opción (icono de la tarjeta de entrada) */ /** Cuadro con las iniciales de la opción (icono de la tarjeta de entrada) */
@@ -478,7 +478,7 @@ export default function AltaClientePage() {
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
{borradores.length === 1 ? '' : 's'} {borradores.length === 1 ? '' : 's'}
</h2> </h2>
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm"> <ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
{borradores.map((b) => ( {borradores.map((b) => (
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3"> <li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -0,0 +1,79 @@
import { useState, type FormEvent } from 'react'
/**
* Motivos de cancelación: mismas categorías del vocabulario CRM FRASA
* (MOTIVOS_NO_COMPRA) que usa el descarte en Mi día; el motivo queda registrado
* en la bitácora del plan.
*/
const MOTIVOS_CANCELACION = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'] as const
/**
* Modal de cancelación de plan: pide motivo (+ comentario opcional) antes de
* dar de baja el cobro. Sustituye al window.confirm anterior.
*/
export default function ModalCancelarPlan({
clienteNombre,
onCancelar,
onConfirmar,
enviando,
}: {
clienteNombre: string
onCancelar: () => void
onConfirmar: (motivo: string, comentario: string) => void
enviando: boolean
}) {
const [motivo, setMotivo] = useState<string>('Timing')
const [comentario, setComentario] = useState('')
return (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Cancelar plan</p>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{clienteNombre}</p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
El saldo pendiente dejará de cobrarse y el plan pasará a CANCELADO.
</p>
<form
className="mt-6 space-y-4"
onSubmit={(e: FormEvent) => {
e.preventDefault()
onConfirmar(motivo, comentario.trim())
}}
>
<div>
<label htmlFor="motivo-cancel-plan" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Motivo</label>
<select
id="motivo-cancel-plan"
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
>
{MOTIVOS_CANCELACION.map((m) => (
<option key={m} value={m}>{m === 'Ubicacion' ? 'Ubicación' : m}</option>
))}
</select>
</div>
<div>
<label htmlFor="comentario-cancel-plan" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Comentario (opcional)</label>
<textarea
id="comentario-cancel-plan"
value={comentario}
onChange={(e) => setComentario(e.target.value)}
rows={3}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
/>
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={onCancelar} disabled={enviando}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-sm font-medium text-[#374151] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] hover:bg-[#F9FAFB] disabled:opacity-50">
Cancelar
</button>
<button type="submit" disabled={enviando}
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 disabled:opacity-50">
{enviando ? 'Procesando…' : 'Confirmar cancelación'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { request } from '../services/api' import { request } from '../services/api'
import { formatoMoneda } from '../lib/formatters' import { formatoMoneda } from '../lib/formatters'
@@ -29,7 +29,35 @@ function abrirWhatsApp(telefono: string) {
window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank') window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank')
} }
export default function MoraDashboard() { /** Título del reporte con el alcance visible (toggle "Mis planes / Todos") */
function EncabezadoMora({ alcance }: { alcance: AlcanceMora }) {
return (
<div className="flex items-center gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Reporte de mora
</h2>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
alcance === 'mios' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'
}`}
title={alcance === 'mios' ? 'Solo los planes de tu alcance' : 'Todos los planes del sistema'}
>
{alcance === 'mios' ? 'Mis planes' : 'Global'}
</span>
</div>
)
}
/** Alcance del reporte: los planes cargados en el dashboard ("mios") o todo */
type AlcanceMora = 'mios' | 'global'
interface Props {
alcance?: AlcanceMora
/** ids de los planes del alcance actual (los mismos que lista la tabla de planes) */
planIds?: Set<string>
}
export default function MoraDashboard({ alcance = 'global', planIds }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const [datos, setDatos] = useState<MoraResponse | null>(null) const [datos, setDatos] = useState<MoraResponse | null>(null)
const [cargando, setCargando] = useState(true) const [cargando, setCargando] = useState(true)
@@ -50,6 +78,22 @@ export default function MoraDashboard() {
useEffect(() => { void cargar() }, [cargar]) useEffect(() => { void cargar() }, [cargar])
// El endpoint /reportes/mora es global: con alcance "mios" se filtra por los
// planes cargados en el dashboard y los KPIs se recalculan sobre esas filas.
const filas = useMemo(() => {
if (!datos) return []
if (alcance !== 'mios' || !planIds) return datos.clientes
return datos.clientes.filter((c) => planIds.has(c.planId))
}, [datos, alcance, planIds])
const resumen = useMemo(
() => ({
total: filas.length,
monto: Math.round(filas.reduce((acc, c) => acc + c.monto, 0) * 100) / 100,
promedio: filas.length > 0 ? Math.round(filas.reduce((acc, c) => acc + c.diasAtraso, 0) / filas.length) : 0,
}),
[filas],
)
if (cargando) { if (cargando) {
return ( return (
<div className="flex h-32 items-center justify-center rounded-xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]"> <div className="flex h-32 items-center justify-center rounded-xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -69,23 +113,31 @@ export default function MoraDashboard() {
) )
} }
if (!datos || datos.clientes.length === 0) { if (!datos || filas.length === 0) {
return ( return (
<div>
<EncabezadoMora alcance={alcance} />
<div className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-center dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"> <div className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-center dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay clientes con pagos vencidos</p> <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
{alcance === 'mios'
? 'No hay clientes con pagos vencidos en tu alcance'
: 'No hay clientes con pagos vencidos'}
</p>
</div>
</div> </div>
) )
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<EncabezadoMora alcance={alcance} />
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<KpiCard titulo="Total morosos" valor={datos.clientesConMora} /> <KpiCard titulo="Total morosos" valor={resumen.total} />
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} /> <KpiCard titulo="Monto vencido" valor={formatoMoneda(resumen.monto)} />
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedioAtraso} /> <KpiCard titulo="Días promedio atraso" valor={resumen.promedio} />
</div> </div>
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]"> <div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"> <tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
@@ -99,7 +151,7 @@ export default function MoraDashboard() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[#E5E7EB]"> <tbody className="divide-y divide-[#E5E7EB]">
{datos.clientes.map((c) => ( {filas.map((c) => (
<tr key={`${c.planId}-${c.pagoVencido}`} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]"> <tr key={`${c.planId}-${c.pagoVencido}`} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]">
<td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td> <td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td> <td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>

View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react'
function esMismoDia(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
/**
* Fecha "hoy" para cálculos de cobranza (atrasos, vencimientos, mora). Se
* recalcula al enfocar la ventana o al volver a la pestaña, de modo que dejar
* la app abierta de un día para otro no muestre atrasos del día congelado.
* Devuelve la misma instancia mientras no cambie el día (render estable).
*/
export function useHoy(): Date {
const [hoy, setHoy] = useState(() => new Date())
useEffect(() => {
const actualizar = () => {
const ahora = new Date()
setHoy((prev) => (esMismoDia(prev, ahora) ? prev : ahora))
}
window.addEventListener('focus', actualizar)
document.addEventListener('visibilitychange', actualizar)
return () => {
window.removeEventListener('focus', actualizar)
document.removeEventListener('visibilitychange', actualizar)
}
}, [])
return hoy
}

View File

@@ -29,6 +29,7 @@ import {
sumarMeses, sumarMeses,
} from '../lib/cobranza' } from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters' import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
import { useHoy } from '../lib/useHoy'
import MoraDashboard from "../components/MoraDashboard" import MoraDashboard from "../components/MoraDashboard"
import KpiCard from '../components/KpiCard' import KpiCard from '../components/KpiCard'
import SemaforoDot, { semaforoPlan } from '../components/Semaforo' import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
@@ -62,7 +63,9 @@ export default function DashboardPage() {
// param, los vendedores arrancan en "Mis planes" y el resto en "Todos" // param, los vendedores arrancan en "Mis planes" y el resto en "Todos"
const paramVista = searchParams.get('vista') const paramVista = searchParams.get('vista')
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol) const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
const hoy = useMemo(() => new Date(), []) // "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(() => { useEffect(() => {
let activo = true let activo = true
@@ -81,6 +84,7 @@ export default function DashboardPage() {
}, [mios]) }, [mios])
const planes = useMemo(() => resultado?.planes ?? [], [resultado]) const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const idsPlan = useMemo(() => new Set(planes.map((p) => p.id)), [planes])
const actualizarParams = (cambios: Record<string, string | null>) => { const actualizarParams = (cambios: Record<string, string | null>) => {
setSearchParams( setSearchParams(
@@ -237,7 +241,7 @@ export default function DashboardPage() {
)} )}
{/* KPIs */} {/* KPIs */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4 xl:grid-cols-5"> <div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
<KpiCard titulo="Planes activos" valor={activos.length} /> <KpiCard titulo="Planes activos" valor={activos.length} />
<KpiCard <KpiCard
titulo="Al corriente / con atraso" titulo="Al corriente / con atraso"
@@ -295,7 +299,7 @@ export default function DashboardPage() {
</div> </div>
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */} {/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm"> <div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
{/* Filtros */} {/* Filtros */}
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5"> <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) */} {/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
@@ -426,7 +430,7 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
<section className="mt-8"> <section className="mt-8">
<MoraDashboard /> <MoraDashboard alcance={mios ? 'mios' : 'global'} planIds={idsPlan} />
</section> </section>
</div> </div>
) )

View File

@@ -4,13 +4,16 @@ import { Link } from 'react-router-dom'
import { listarPlanes, type ResultadoPlanes } from '../services/repository' import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza' import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters' import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
import { useHoy } from '../lib/useHoy'
import KpiCard from '../components/KpiCard' import KpiCard from '../components/KpiCard'
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */ /** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
export default function HoyPage() { export default function HoyPage() {
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null) const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null) const [errorCarga, setErrorCarga] = useState<string | null>(null)
const hoy = useMemo(() => new Date(), []) // "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(() => { useEffect(() => {
let activo = true let activo = true
@@ -63,7 +66,7 @@ export default function HoyPage() {
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} /> <KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
</div> </div>
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm"> <div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"> <tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react' import { Fragment, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import type { Pago, PlanPago } from '../types' import type { Pago, PlanPago } from '../types'
import { import {
@@ -22,9 +22,11 @@ import {
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters' import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
import { descargar } from '../lib/pdf/estadoCuenta' import { descargar } from '../lib/pdf/estadoCuenta'
import { descargarRecibo } from '../lib/pdf/reciboAbono' import { descargarRecibo } from '../lib/pdf/reciboAbono'
import { useHoy } from '../lib/useHoy'
import BadgeStatus from '../components/BadgeStatus' import BadgeStatus from '../components/BadgeStatus'
import KpiCard from '../components/KpiCard' import KpiCard from '../components/KpiCard'
import ModalAbono from '../components/ModalAbono' import ModalAbono from '../components/ModalAbono'
import ModalCancelarPlan from '../components/ModalCancelarPlan'
import TimelinePagos from '../components/TimelinePagos' import TimelinePagos from '../components/TimelinePagos'
export default function PlanDetallePage() { export default function PlanDetallePage() {
@@ -35,11 +37,15 @@ export default function PlanDetallePage() {
const [offline, setOffline] = useState(false) const [offline, setOffline] = useState(false)
const [errorCarga, setErrorCarga] = useState<string | null>(null) const [errorCarga, setErrorCarga] = useState<string | null>(null)
const [pagoAbono, setPagoAbono] = useState<Pago | null>(null) const [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
const [modalCancelar, setModalCancelar] = useState(false)
const [cancelando, setCancelando] = useState(false)
const [expandidos, setExpandidos] = useState<Set<string>>(new Set()) const [expandidos, setExpandidos] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [notaTexto, setNotaTexto] = useState('') const [notaTexto, setNotaTexto] = useState('')
const [errorNota, setErrorNota] = useState<string | null>(null) const [errorNota, setErrorNota] = useState<string | null>(null)
const hoy = useMemo(() => new Date(), []) // "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()
useEffect(() => { useEffect(() => {
if (!id) { if (!id) {
@@ -74,7 +80,7 @@ export default function PlanDetallePage() {
if (!plan) { if (!plan) {
return ( return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p- dark:border-[#1E293B] dark:bg-[#1E293B] 16 text-center shadow-sm"> <div className="rounded-xl border border-[#E5E7EB] bg-white p-16 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-[#64748B] dark:text-[#94A3B8]">Plan no encontrado.</p> <p className="text-[#64748B] dark:text-[#94A3B8]">Plan no encontrado.</p>
<Link to="/" className="mt-4 inline-block text-accent hover:underline"> <Link to="/" className="mt-4 inline-block text-accent hover:underline">
Volver al dashboard Volver al dashboard
@@ -111,14 +117,27 @@ export default function PlanDetallePage() {
} }
} }
const cancelar = async () => { /** Cancela el plan y registra el motivo (+comentario) en la bitácora */
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return const confirmarCancelacion = async (motivo: string, comentario: string) => {
if (!plan) return
setCancelando(true)
try { try {
const r = await cancelarPlanAsync(plan.id) const r = await cancelarPlanAsync(plan.id)
setPlan(r.plan) setPlan(r.plan)
setOffline(r.origen === 'local') setOffline(r.origen === 'local')
setError(null) setError(null)
setCancelando(false)
setModalCancelar(false)
// El motivo queda asentado en la bitácora (best-effort)
const detalle = `Plan cancelado. Motivo: ${motivo}${comentario !== '' ? ` — ${comentario}` : ''}`
try {
const rn = await agregarNotaAsync(r.plan, detalle)
setPlan(rn.plan)
} catch {
// la cancelación ya se aplicó; la nota es complementaria
}
} catch (e) { } catch (e) {
setCancelando(false)
setError(e instanceof Error ? e.message : 'Error al cancelar el plan') setError(e instanceof Error ? e.message : 'Error al cancelar el plan')
} }
} }
@@ -244,7 +263,7 @@ export default function PlanDetallePage() {
</section> </section>
{/* Tabla de pagos */} {/* Tabla de pagos */}
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm"> <div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"> <tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
@@ -393,7 +412,7 @@ export default function PlanDetallePage() {
</button> </button>
{status === 'ACTIVO' && saldo > 0 && ( {status === 'ACTIVO' && saldo > 0 && (
<button <button
onClick={cancelar} onClick={() => setModalCancelar(true)}
className="w-full rounded-lg border border-red-600 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-600 hover:text-white" className="w-full rounded-lg border border-red-600 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-600 hover:text-white"
> >
Cancelar plan Cancelar plan
@@ -477,6 +496,15 @@ export default function PlanDetallePage() {
{pagoAbono && ( {pagoAbono && (
<ModalAbono pago={pagoAbono} onCancelar={() => setPagoAbono(null)} onGuardar={guardarAbono} /> <ModalAbono pago={pagoAbono} onCancelar={() => setPagoAbono(null)} onGuardar={guardarAbono} />
)} )}
{modalCancelar && plan && (
<ModalCancelarPlan
clienteNombre={plan.clienteNombre}
enviando={cancelando}
onCancelar={() => setModalCancelar(false)}
onConfirmar={(motivo, comentario) => void confirmarCancelacion(motivo, comentario)}
/>
)}
</div> </div>
) )
} }

View File

@@ -67,7 +67,7 @@ export function BuscadorCliente({ onSeleccion }: Props) {
)} )}
{resultados !== null && resultados.length > 0 && ( {resultados !== null && resultados.length > 0 && (
<ul className="mt-3 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm"> <ul className="mt-3 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
{resultados.map((lead) => ( {resultados.map((lead) => (
<li key={`${lead.fuente}-${lead.id}`}> <li key={`${lead.fuente}-${lead.id}`}>
<button <button

View File

@@ -107,7 +107,7 @@ export function UnitSelector({ unidades, valor, onChange }: Props) {
id="unidad-listbox" id="unidad-listbox"
role="listbox" role="listbox"
aria-label="Unidades disponibles" aria-label="Unidades disponibles"
className="absolute z-10 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -xl" className="absolute z-10 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-[#E5E7EB] bg-white shadow-xl dark:border-[#1E293B] dark:bg-[#1E293B]"
> >
{opciones.length === 0 ? ( {opciones.length === 0 ? (
<li className="px-3 py-2.5 text-sm text-[#64748B] dark:text-[#94A3B8]">Sin unidades que coincidan</li> <li className="px-3 py-2.5 text-sm text-[#64748B] dark:text-[#94A3B8]">Sin unidades que coincidan</li>

View File

@@ -83,9 +83,9 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
detalle={`${conversion.oportunidades} de ${conversion.leads} leads`} detalle={`${conversion.oportunidades} de ${conversion.leads} leads`}
/> />
<KpiCard <KpiCard
titulo="Ventas del mes" titulo="Cotizaciones del mes"
valor={conversion.vendidos} valor={tablero.ventasMes.unidades}
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN · ${tablero.ventasMes.unidades} cotizada(s)`} detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN cotizados`}
/> />
<KpiCard <KpiCard
titulo="Actividades abiertas" titulo="Actividades abiertas"

View File

@@ -47,7 +47,7 @@ export default function SeccionEquipo() {
{equipo.porVendedor.length === 0 ? ( {equipo.porVendedor.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados al equipo" /> <EstadoVacio mensaje="Sin leads asignados al equipo" />
) : ( ) : (
<table className="w-full overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm"> <table className="w-full overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<thead className="border-b border-[#E5E7EB]"> <thead className="border-b border-[#E5E7EB]">
<tr> <tr>
<th className={CLASE_ENCABEZADO}>Vendedor</th> <th className={CLASE_ENCABEZADO}>Vendedor</th>

View File

@@ -49,6 +49,7 @@ const RANGOS_DIAS = [7, 30, 90] as const
/** Rutas de drill-down a otras apps del ecosistema (NGINX las sirve como SPAs aparte) */ /** Rutas de drill-down a otras apps del ecosistema (NGINX las sirve como SPAs aparte) */
const RUTA_PLANES_VENCIDOS = '/pay/?vista=todos&status=ACTIVO&sort=atraso&dir=desc' const RUTA_PLANES_VENCIDOS = '/pay/?vista=todos&status=ACTIVO&sort=atraso&dir=desc'
const RUTA_PLANES_ACTIVOS = '/pay/?vista=todos&status=ACTIVO'
const RUTA_MI_DIA = '/hoy/' const RUTA_MI_DIA = '/hoy/'
/** /**
@@ -194,7 +195,8 @@ export default function TableroGeneral({
<KpiCard <KpiCard
titulo="Planes activos" titulo="Planes activos"
valor={resumen.cobranza.planes.activos} valor={resumen.cobranza.planes.activos}
detalle={`${resumen.cobranza.planes.liquidados} liquidados · ${resumen.cobranza.planes.cancelados} cancelados`} detalle={`${resumen.cobranza.planes.liquidados} liquidados · ${resumen.cobranza.planes.cancelados} cancelados · click para ver planes`}
onClick={() => irAApp(RUTA_PLANES_ACTIVOS)}
/> />
<KpiCard titulo="Actividades abiertas" valor={resumen.actividadesAbiertas} /> <KpiCard titulo="Actividades abiertas" valor={resumen.actividadesAbiertas} />
<KpiCard <KpiCard

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { getContactos, type Contacto } from '../services/api' import { getContactos, type Contacto, type RespuestaContactos } from '../services/api'
import PerfilContactoCompleto from './PerfilContactoCompleto' import PerfilContactoCompleto from './PerfilContactoCompleto'
import { SkeletonList } from "./Skeleton" import { SkeletonList } from "./Skeleton"
import toast from "react-hot-toast" import toast from "react-hot-toast"
@@ -16,11 +16,12 @@ interface Props {
* información, propiedad, quién lo atiende e historial de atención. * información, propiedad, quién lo atiende e historial de atención.
*/ */
export function VistaContactos({ onVolver }: Props) { export function VistaContactos({ onVolver }: Props) {
const [contactos, setContactos] = useState<Contacto[] | null>(null) const [respuesta, setRespuesta] = useState<RespuestaContactos | null>(null)
const [cargando, setCargando] = useState(true) const [cargando, setCargando] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [seleccionado, setSeleccionado] = useState<Contacto | null>(null) const [seleccionado, setSeleccionado] = useState<Contacto | null>(null)
const [busqueda, setBusqueda] = useState('') const [busqueda, setBusqueda] = useState('')
const contactos = respuesta?.contactos ?? null
/** Contactos filtrados por texto de búsqueda */ /** Contactos filtrados por texto de búsqueda */
const contactosFiltrados = useMemo(() => { const contactosFiltrados = useMemo(() => {
@@ -35,12 +36,16 @@ export function VistaContactos({ onVolver }: Props) {
) )
}, [contactos, busqueda]) }, [contactos, busqueda])
const cargar = useCallback(() => { const cargar = useCallback((pagina = 1) => {
setCargando(true) setCargando(true)
setError('') setError('')
getContactos() getContactos(pagina)
.then(setContactos) .then(setRespuesta)
.catch(() => { toast.error("No se pudieron cargar los contactos."); setError("No se pudieron cargar los contactos."); }) .catch((err: unknown) => {
const mensaje = err instanceof Error ? err.message : 'No se pudieron cargar los contactos.'
toast.error('No se pudieron cargar los contactos.')
setError(mensaje)
})
.finally(() => setCargando(false)) .finally(() => setCargando(false))
}, []) }, [])
@@ -87,11 +92,11 @@ export function VistaContactos({ onVolver }: Props) {
) : error ? ( ) : error ? (
<div> <div>
<p role="alert" className="text-sm text-red-600"> <p role="alert" className="text-sm text-red-600">
{error} Error al cargar contactos: {error}
</p> </p>
<button <button
type="button" type="button"
onClick={cargar} onClick={() => cargar()}
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155]" className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
> >
Reintentar Reintentar
@@ -145,7 +150,7 @@ export function VistaContactos({ onVolver }: Props) {
))} ))}
</div> </div>
{/* Desktop: table-style list */} {/* Desktop: table-style list */}
<ul className="hidden divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] md:block -sm"> <ul className="hidden divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B] md:block">
{contactosFiltrados?.map((contacto) => ( {contactosFiltrados?.map((contacto) => (
<li key={contacto.id}> <li key={contacto.id}>
<button <button
@@ -168,6 +173,32 @@ export function VistaContactos({ onVolver }: Props) {
</ul> </ul>
</> </>
)} )}
{/* Paginación: el API entrega bloques de máx 100 contactos */}
{respuesta && (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Página {respuesta.pagina} · máx {respuesta.porPagina} contactos por página
</p>
<div className="flex items-center gap-2">
<button
type="button"
disabled={cargando || respuesta.pagina <= 1}
onClick={() => cargar(respuesta.pagina - 1)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155] disabled:opacity-40"
>
← Anterior
</button>
<button
type="button"
disabled={cargando || !respuesta.hayMas}
onClick={() => cargar(respuesta.pagina + 1)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155] disabled:opacity-40"
>
Siguiente →
</button>
</div>
</div>
)}
</> </>
)} )}
</section> </section>

View File

@@ -14,7 +14,7 @@ const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCE
/** /**
* Página / clientes-maestro: tabla paginada con filtros de búsqueda, * Página / clientes-maestro: tabla paginada con filtros de búsqueda,
* desarrollo y estatus. Botón exportar genera xlsx desde los datos filtrados. * desarrollo y estatus. Botón exportar genera CSV desde los datos filtrados.
*/ */
export function ClientesMaestroPage({ onVolver }: Props) { export function ClientesMaestroPage({ onVolver }: Props) {
const [datos, setDatos] = useState<ClienteMaestro[]>([]) const [datos, setDatos] = useState<ClienteMaestro[]>([])
@@ -70,7 +70,7 @@ export function ClientesMaestroPage({ onVolver }: Props) {
estatus: filtroEstatus || undefined, estatus: filtroEstatus || undefined,
}) })
// Generar CSV como fallback (xlsx no está en el bundle del front) // Generar el archivo CSV desde los datos filtrados
const headers = ['Num Cliente', 'Nombre', 'Apellido Paterno', 'Apellido Materno', 'Sexo', 'Teléfono', 'Email', 'Estado Civil', 'Rango Edad', 'Ocupación', 'Rango Ingresos', 'Crédito', 'Institución', 'Medio Contacto', 'Canal', 'Desarrollo', 'Manzana', 'Lote', 'Interior', 'Tipo Casa', 'Etapa', 'Valor Total', 'Estatus', 'Fecha Liberación', 'Vendedor', 'Vendedor ID'] const headers = ['Num Cliente', 'Nombre', 'Apellido Paterno', 'Apellido Materno', 'Sexo', 'Teléfono', 'Email', 'Estado Civil', 'Rango Edad', 'Ocupación', 'Rango Ingresos', 'Crédito', 'Institución', 'Medio Contacto', 'Canal', 'Desarrollo', 'Manzana', 'Lote', 'Interior', 'Tipo Casa', 'Etapa', 'Valor Total', 'Estatus', 'Fecha Liberación', 'Vendedor', 'Vendedor ID']
const rows = datosExport.map((c) => [ const rows = datosExport.map((c) => [
c.numCliente, c.nombre, c.apellidoPaterno, c.apellidoMaterno ?? '', c.sexo ?? '', c.numCliente, c.nombre, c.apellidoPaterno, c.apellidoMaterno ?? '', c.sexo ?? '',

View File

@@ -17,12 +17,25 @@ export interface Contacto {
updatedAt: string updatedAt: string
} }
/** GET /contactos — contactos locales (máx 100) */ /** Respuesta paginada de GET /contactos */
export async function getContactos(): Promise<Contacto[]> { export interface RespuestaContactos {
const resp = await fetch(`${API_URL}/contactos`, conAuth()) contactos: Contacto[]
pagina: number
porPagina: number
hayMas: boolean
}
/** GET /contactos — contactos locales, paginados de 100 en 100 (?pagina=N) */
export async function getContactos(pagina = 1): Promise<RespuestaContactos> {
const resp = await fetch(`${API_URL}/contactos?pagina=${pagina}`, conAuth())
manejar401(resp) manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`) if (!resp.ok) {
return (await resp.json()) as Contacto[] // Propagar el mensaje del API (p.ej. error upstream) para diferenciarlo
// del caso "sin contactos"
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
return (await resp.json()) as RespuestaContactos
} }
// ---------- Detalle de contacto (modal del módulo Contactos) ---------- // ---------- Detalle de contacto (modal del módulo Contactos) ----------

View File

@@ -64,7 +64,7 @@ export async function listarClientesMaestro(filtros: FiltrosClientesMaestro = {}
return (await resp.json()) as ResultadoListado return (await resp.json()) as ResultadoListado
} }
/** GET /api/clientes-maestro/exportar — array de objetos para xlsx */ /** GET /api/clientes-maestro/exportar — array de objetos para generar CSV */
export async function exportarClientesMaestro(filtros: Omit<FiltrosClientesMaestro, 'pagina' | 'limite'> = {}): Promise<ClienteMaestro[]> { export async function exportarClientesMaestro(filtros: Omit<FiltrosClientesMaestro, 'pagina' | 'limite'> = {}): Promise<ClienteMaestro[]> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (filtros.busqueda) params.set('busqueda', filtros.busqueda) if (filtros.busqueda) params.set('busqueda', filtros.busqueda)

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react' import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react' import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react'
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia' import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia' import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia'
@@ -56,10 +56,12 @@ export default function DetalleLeadModal({
const [nuevaNota, setNuevaNota] = useState('') const [nuevaNota, setNuevaNota] = useState('')
const [enviandoNota, setEnviandoNota] = useState(false) const [enviandoNota, setEnviandoNota] = useState(false)
const [busquedaNota, setBusquedaNota] = useState('') const [busquedaNota, setBusquedaNota] = useState('')
// Input inline de nueva nota: el botón "+ Añadir nota" lo enfoca
const inputNotaRef = useRef<HTMLInputElement>(null)
// --- Tareas --- // --- Tareas ---
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null) const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
// --- Cita --- // --- Cita ---
const [fechaCita, setFechaCita] = useState('') const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : ''))
const [guardandoCita, setGuardandoCita] = useState(false) const [guardandoCita, setGuardandoCita] = useState(false)
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */ /** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
@@ -192,19 +194,19 @@ export default function DetalleLeadModal({
</> </>
)} )}
{tab === 'cita' && ( {tab === 'cita' && esOp && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Cita</h3> <h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Cita</h3>
{esOp && item.fechaCita ? ( <form onSubmit={guardarCita} className="space-y-3">
{item.fechaCita && (
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4"> <div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p> <p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p> <p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')} · puedes reagendar abajo</p>
</div> </div>
) : esOp ? ( )}
<form onSubmit={guardarCita} className="space-y-3">
<div> <div>
<label htmlFor="cita-fecha" className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"> <label htmlFor="cita-fecha" className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Fecha y hora de la cita {item.fechaCita ? 'Nueva fecha y hora de la cita' : 'Fecha y hora de la cita'}
</label> </label>
<input <input
id="cita-fecha" id="cita-fecha"
@@ -220,12 +222,9 @@ export default function DetalleLeadModal({
disabled={guardandoCita || fechaCita === ''} disabled={guardandoCita || fechaCita === ''}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50" className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
> >
{guardandoCita ? 'Guardando…' : 'Guardar cita'} {guardandoCita ? 'Guardando…' : item.fechaCita ? 'Actualizar cita' : 'Guardar cita'}
</button> </button>
</form> </form>
) : (
<p className="text-sm text-[#94A3B8]">No hay cita programada. Las citas se agendan desde Mis oportunidades.</p>
)}
</div> </div>
)} )}
@@ -272,8 +271,8 @@ export default function DetalleLeadModal({
</div> </div>
</div> </div>
{/* Botón añadir */} {/* Botón añadir: enfoca el input inline en vez de recargar el tab */}
<button type="button" onClick={() => { setTab('notas') }} <button type="button" onClick={() => inputNotaRef.current?.focus()}
className="w-full rounded-lg border-2 border-dashed border-[#D1D5DB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] py-3 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] hover:border-accent hover:text-accent transition-colors"> className="w-full rounded-lg border-2 border-dashed border-[#D1D5DB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] py-3 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] hover:border-accent hover:text-accent transition-colors">
+ Añadir nota + Añadir nota
</button> </button>
@@ -282,7 +281,7 @@ export default function DetalleLeadModal({
{/* Formulario inline */} {/* Formulario inline */}
<form onSubmit={agregarNota} className="flex gap-2"> <form onSubmit={agregarNota} className="flex gap-2">
<input type="text" value={nuevaNota} onChange={(e) => setNuevaNota(e.target.value)} <input ref={inputNotaRef} type="text" value={nuevaNota} onChange={(e) => setNuevaNota(e.target.value)}
placeholder="Escribe tu nota…" disabled={enviandoNota} placeholder="Escribe tu nota…" disabled={enviandoNota}
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:opacity-50" /> className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:opacity-50" />
<button type="submit" disabled={enviandoNota || !nuevaNota.trim()} <button type="submit" disabled={enviandoNota || !nuevaNota.trim()}

View File

@@ -84,6 +84,8 @@ export interface ResumenMiDia {
misCapturas: CapturaMiDia[] misCapturas: CapturaMiDia[]
misClientes: ClienteMiDia[] misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[] misActividadesHoy: ActividadMiDia[]
/** Todas las actividades ABIERTAS del usuario (sin filtro de fecha): alimenta el calendario */
todasMisActividades: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[] apartadosPorVencer: ApartadoPorVencer[]
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
misApartados: ApartadoMiDia[] misApartados: ApartadoMiDia[]

View File

@@ -389,10 +389,6 @@ function FilaApartado({
window.open(`/client/?apartado=${apartado.id}`, '_blank') window.open(`/client/?apartado=${apartado.id}`, '_blank')
return return
} }
if (fase === 'Cancelacion Apartado') {
onFase(fase)
return
}
onFase(fase) onFase(fase)
}} }}
className={ className={
@@ -976,6 +972,20 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
fecha: (a) => a.fechaLimite, fecha: (a) => a.fechaLimite,
}) })
: [] : []
// Escrituración: apartados en fases activas posteriores a Firma Cliente
// (Cancelo y Escriturado son terminales). Usa los filtros PROPIOS del tab
// para que el conteo del tab refleje la lista filtrada de escrituración.
const enEscrituracion = resumen
? (resumen.todosApartados || resumen.misApartados).filter((a) => {
const e = (a.estado ?? '').toUpperCase()
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR')
})
: []
const escrituracionFiltrados = aplicarFiltros(enEscrituracion, filtrosPorTab.escrituracion, {
texto: (a) => a.nombre,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
})
const hoy = hoyLocal() const hoy = hoyLocal()
const iniciales = nombreVendedor const iniciales = nombreVendedor
@@ -1095,7 +1105,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
conteos={{ conteos={{
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length, leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
oportunidades: resumen.misOportunidades.items.length, oportunidades: resumen.misOportunidades.items.length,
apartados: resumen.misApartados.length, escrituracion: resumen.misApartados.length, tablero: 0, calendario: 0, apartados: resumen.misApartados.length, escrituracion: escrituracionFiltrados.length, tablero: 0, calendario: 0,
}} }}
onCambiar={setTabCrm} onCambiar={setTabCrm}
vistaKanban={vistaKanban} vistaKanban={vistaKanban}
@@ -1211,7 +1221,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={apartadosFiltrados} items={apartadosFiltrados}
columnas={columnasApartados} columnas={columnasApartados}
obtenerColumna={obtenerColumnaApartado} obtenerColumna={obtenerColumnaApartado}
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')} onCambiarEstado={(id, estado) => {
if (estado === 'Cancelacion Apartado') {
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
const a = apartadosFiltrados.find((x) => x.id === id)
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
return
}
void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')
}}
/> />
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
@@ -1224,6 +1242,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
enviando={enviando} enviando={enviando}
onVerDetalle={() => setLeadSeleccionado(a)} onVerDetalle={() => setLeadSeleccionado(a)}
onFase={(fase) => { onFase={(fase) => {
if (fase === 'Cancelacion Apartado') {
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
setApartadoCancelando({ id: a.id, nombre: a.nombre })
return
}
if (fase === 'Firma Cliente') { if (fase === 'Firma Cliente') {
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración // Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
void ejecutar(() => cambiarEstadoApartado(a.id, fase).then(() => setTabCrm('escrituracion')), 'No se pudo actualizar el apartado') void ejecutar(() => cambiarEstadoApartado(a.id, fase).then(() => setTabCrm('escrituracion')), 'No se pudo actualizar el apartado')
@@ -1245,33 +1268,16 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<p className="text-sm text-[#64748B] dark:text-[#94A3B8] mb-4"> <p className="text-sm text-[#64748B] dark:text-[#94A3B8] mb-4">
Seguimiento de escrituración de apartados. Seguimiento de escrituración de apartados.
</p> </p>
{(() => { {escrituracionFiltrados.length === 0 ? (
// Filtrar: solo apartados en fases de escrituración o Firma Cliente
// Solo fases activas (Cancelo y Escriturado son terminales)
// Fases activas de escrituración (excluye Cancelo y Escriturado que son terminales)
const enEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => {
const e = (a.estado ?? '').toUpperCase()
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR')
})
const apartadosEscrituracion = aplicarFiltros(
enEscrituracion,
filtros,
{
texto: (a) => a.nombre,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
},
)
return apartadosEscrituracion.length === 0 ? (
<EstadoVacio mensaje="No hay apartados en escrituración" /> <EstadoVacio mensaje="No hay apartados en escrituración" />
) : vistaKanban ? ( ) : vistaKanban ? (
<KanbanView <KanbanView
items={apartadosEscrituracion} items={escrituracionFiltrados}
columnas={columnasEscrituracion} columnas={columnasEscrituracion}
obtenerColumna={obtenerColumnaEscrituracion} obtenerColumna={obtenerColumnaEscrituracion}
onCambiarEstado={(id, estado) => { onCambiarEstado={(id, estado) => {
if (estado === 'Cancelo') { if (estado === 'Cancelo') {
const a = apartadosEscrituracion.find((x) => x.id === id) const a = escrituracionFiltrados.find((x) => x.id === id)
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre }) if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
return return
} }
@@ -1280,7 +1286,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
/> />
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{apartadosEscrituracion.map((apartado) => ( {escrituracionFiltrados.map((apartado) => (
<li key={apartado.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"> <li key={apartado.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
@@ -1314,7 +1320,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</li> </li>
))} ))}
</ul> </ul>
)})()} )}
</section> </section>
)} )}
@@ -1325,7 +1331,9 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{tabCrm === 'calendario' && ( {tabCrm === 'calendario' && (
<section aria-label="Calendario"> <section aria-label="Calendario">
<CalendarioCitas <CalendarioCitas
actividades={resumen.misActividadesHoy} // Todas las actividades abiertas del usuario (no solo las de hoy);
// los resúmenes cacheados previos pueden no traer el campo nuevo.
actividades={resumen.todasMisActividades ?? resumen.misActividadesHoy}
oportunidades={resumen.misOportunidades.items} oportunidades={resumen.misOportunidades.items}
onVerDetalle={(op) => setLeadSeleccionado(op)} onVerDetalle={(op) => setLeadSeleccionado(op)}
/> />
@@ -1673,8 +1681,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
'modeloInteres' in leadSeleccionado 'modeloInteres' in leadSeleccionado
? (fechaHora) => { ? (fechaHora) => {
const op = leadSeleccionado const op = leadSeleccionado
// Si la op ya pasó por Agendo Cita (Cotizador/Aparto) NO se
// retrocede la etapa: solo se actualiza la fecha de la cita.
const etapa = op.etapa === 'Cotizador' || op.etapa === 'Aparto' ? op.etapa : 'Agendo Cita'
return ejecutar( return ejecutar(
() => cambiarEtapaOportunidad(op.id, 'Agendo Cita', fechaHora), () => cambiarEtapaOportunidad(op.id, etapa, fechaHora),
'No se pudo guardar la cita', 'No se pudo guardar la cita',
) )
} }