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

@@ -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 DarkModeToggle from '../components/DarkModeToggle'
import toast from "react-hot-toast"
@@ -10,25 +10,20 @@ import {
type ConversacionResumen,
} from '../services/api'
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
import { preguntasSugeridas } from '../lib/vocabulario'
import type { Mensaje } from '../types'
let contadorLocal = 0
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() {
const [sessionId, setSessionId] = useState<string | null>(null)
const [mensajes, setMensajes] = useState<Mensaje[]>([])
const [entrada, setEntrada] = useState('')
const [escribiendo, setEscribiendo] = 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)
const [pendiente, setPendiente] = useState<string | null>(null)
// Panel de conversaciones previas
@@ -70,7 +65,7 @@ export default function ChatPage() {
if (esErrorDeRed(err)) {
setSinConexion(true)
} 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
} finally {
@@ -82,7 +77,6 @@ export default function ChatPage() {
const texto = entrada.trim()
if (!texto || escribiendo) return
setEntrada('')
setError(null)
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
await transmitir(texto)
}
@@ -90,7 +84,6 @@ export default function ChatPage() {
/** Reenvía el último mensaje que falló (sin duplicar la burbuja del usuario) */
async function reintentar() {
if (!pendiente || escribiendo) return
setError(null)
setSinConexion(false)
await transmitir(pendiente)
}
@@ -99,15 +92,14 @@ export default function ChatPage() {
limpiarSesion()
setSessionId(null)
setMensajes([])
setError(null)
setSinConexion(false)
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() {
setPanelAbierto(true)
if (conversaciones !== null || cargandoConversaciones) return
if (cargandoConversaciones) return
setCargandoConversaciones(true)
listarConversaciones()
.then(setConversaciones)
@@ -123,7 +115,6 @@ export default function ChatPage() {
guardarSesion(id)
setMensajes(historial)
setPendiente(null)
setError(null)
setSinConexion(false)
setPanelAbierto(false)
} catch {
@@ -132,7 +123,7 @@ export default function ChatPage() {
}
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">
<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">
@@ -176,11 +167,6 @@ export default function ChatPage() {
nuevo.
</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 ? (
<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.
</p>
<div className="mt-6 grid gap-2 sm:grid-cols-2">
{PREGUNTAS_SUGERIDAS.map((pregunta) => (
{preguntasSugeridasHoy.map((pregunta) => (
<button
key={pregunta}
type="button"

View File

@@ -70,7 +70,7 @@ export default function BusquedaLead({ onSeleccion }: Props) {
)}
{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) => (
<li key={`${lead.fuente}-${lead.id}`}>
<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) */
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) */
@@ -478,7 +478,7 @@ export default function AltaClientePage() {
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
{borradores.length === 1 ? '' : 's'}
</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) => (
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
<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 { request } from '../services/api'
import { formatoMoneda } from '../lib/formatters'
@@ -29,7 +29,35 @@ function abrirWhatsApp(telefono: string) {
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 [datos, setDatos] = useState<MoraResponse | null>(null)
const [cargando, setCargando] = useState(true)
@@ -50,6 +78,22 @@ export default function MoraDashboard() {
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) {
return (
<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 (
<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>
<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">
<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>
)
}
return (
<div className="space-y-6">
<EncabezadoMora alcance={alcance} />
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard titulo="Total morosos" valor={datos.clientesConMora} />
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedioAtraso} />
<KpiCard titulo="Total morosos" valor={resumen.total} />
<KpiCard titulo="Monto vencido" valor={formatoMoneda(resumen.monto)} />
<KpiCard titulo="Días promedio atraso" valor={resumen.promedio} />
</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">
<thead>
<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>
</thead>
<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]">
<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>

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,
} from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
import { useHoy } from '../lib/useHoy'
import MoraDashboard from "../components/MoraDashboard"
import KpiCard from '../components/KpiCard'
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"
const paramVista = searchParams.get('vista')
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(() => {
let activo = true
@@ -81,6 +84,7 @@ export default function DashboardPage() {
}, [mios])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const idsPlan = useMemo(() => new Set(planes.map((p) => p.id)), [planes])
const actualizarParams = (cambios: Record<string, string | null>) => {
setSearchParams(
@@ -237,7 +241,7 @@ export default function DashboardPage() {
)}
{/* 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="Al corriente / con atraso"
@@ -295,7 +299,7 @@ export default function DashboardPage() {
</div>
{/* 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 */}
<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) */}
@@ -426,7 +430,7 @@ export default function DashboardPage() {
</div>
</div>
<section className="mt-8">
<MoraDashboard />
<MoraDashboard alcance={mios ? 'mios' : 'global'} planIds={idsPlan} />
</section>
</div>
)

View File

@@ -4,13 +4,16 @@ 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 KpiCard from '../components/KpiCard'
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
export default function HoyPage() {
const [resultado, setResultado] = useState<ResultadoPlanes | 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(() => {
let activo = true
@@ -63,7 +66,7 @@ export default function HoyPage() {
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
</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">
<thead>
<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 type { Pago, PlanPago } from '../types'
import {
@@ -22,9 +22,11 @@ import {
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
import { descargar } from '../lib/pdf/estadoCuenta'
import { descargarRecibo } from '../lib/pdf/reciboAbono'
import { useHoy } from '../lib/useHoy'
import BadgeStatus from '../components/BadgeStatus'
import KpiCard from '../components/KpiCard'
import ModalAbono from '../components/ModalAbono'
import ModalCancelarPlan from '../components/ModalCancelarPlan'
import TimelinePagos from '../components/TimelinePagos'
export default function PlanDetallePage() {
@@ -35,11 +37,15 @@ export default function PlanDetallePage() {
const [offline, setOffline] = useState(false)
const [errorCarga, setErrorCarga] = useState<string | 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 [error, setError] = useState<string | null>(null)
const [notaTexto, setNotaTexto] = useState('')
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(() => {
if (!id) {
@@ -74,7 +80,7 @@ export default function PlanDetallePage() {
if (!plan) {
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>
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
Volver al dashboard
@@ -111,14 +117,27 @@ export default function PlanDetallePage() {
}
}
const cancelar = async () => {
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
/** Cancela el plan y registra el motivo (+comentario) en la bitácora */
const confirmarCancelacion = async (motivo: string, comentario: string) => {
if (!plan) return
setCancelando(true)
try {
const r = await cancelarPlanAsync(plan.id)
setPlan(r.plan)
setOffline(r.origen === 'local')
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) {
setCancelando(false)
setError(e instanceof Error ? e.message : 'Error al cancelar el plan')
}
}
@@ -244,7 +263,7 @@ export default function PlanDetallePage() {
</section>
{/* 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">
<thead>
<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>
{status === 'ACTIVO' && saldo > 0 && (
<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"
>
Cancelar plan
@@ -477,6 +496,15 @@ export default function PlanDetallePage() {
{pagoAbono && (
<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>
)
}

View File

@@ -67,7 +67,7 @@ export function BuscadorCliente({ onSeleccion }: Props) {
)}
{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) => (
<li key={`${lead.fuente}-${lead.id}`}>
<button

View File

@@ -107,7 +107,7 @@ export function UnitSelector({ unidades, valor, onChange }: Props) {
id="unidad-listbox"
role="listbox"
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 ? (
<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`}
/>
<KpiCard
titulo="Ventas del mes"
valor={conversion.vendidos}
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN · ${tablero.ventasMes.unidades} cotizada(s)`}
titulo="Cotizaciones del mes"
valor={tablero.ventasMes.unidades}
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN cotizados`}
/>
<KpiCard
titulo="Actividades abiertas"

View File

@@ -47,7 +47,7 @@ export default function SeccionEquipo() {
{equipo.porVendedor.length === 0 ? (
<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]">
<tr>
<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) */
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/'
/**
@@ -194,7 +195,8 @@ export default function TableroGeneral({
<KpiCard
titulo="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

View File

@@ -1,5 +1,5 @@
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 { SkeletonList } from "./Skeleton"
import toast from "react-hot-toast"
@@ -16,11 +16,12 @@ interface Props {
* información, propiedad, quién lo atiende e historial de atención.
*/
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 [error, setError] = useState('')
const [seleccionado, setSeleccionado] = useState<Contacto | null>(null)
const [busqueda, setBusqueda] = useState('')
const contactos = respuesta?.contactos ?? null
/** Contactos filtrados por texto de búsqueda */
const contactosFiltrados = useMemo(() => {
@@ -35,12 +36,16 @@ export function VistaContactos({ onVolver }: Props) {
)
}, [contactos, busqueda])
const cargar = useCallback(() => {
const cargar = useCallback((pagina = 1) => {
setCargando(true)
setError('')
getContactos()
.then(setContactos)
.catch(() => { toast.error("No se pudieron cargar los contactos."); setError("No se pudieron cargar los contactos."); })
getContactos(pagina)
.then(setRespuesta)
.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))
}, [])
@@ -87,11 +92,11 @@ export function VistaContactos({ onVolver }: Props) {
) : error ? (
<div>
<p role="alert" className="text-sm text-red-600">
{error}
Error al cargar contactos: {error}
</p>
<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]"
>
Reintentar
@@ -145,7 +150,7 @@ export function VistaContactos({ onVolver }: Props) {
))}
</div>
{/* 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) => (
<li key={contacto.id}>
<button
@@ -168,6 +173,32 @@ export function VistaContactos({ onVolver }: Props) {
</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>

View File

@@ -14,7 +14,7 @@ const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCE
/**
* 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) {
const [datos, setDatos] = useState<ClienteMaestro[]>([])
@@ -70,7 +70,7 @@ export function ClientesMaestroPage({ onVolver }: Props) {
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 rows = datosExport.map((c) => [
c.numCliente, c.nombre, c.apellidoPaterno, c.apellidoMaterno ?? '', c.sexo ?? '',

View File

@@ -17,12 +17,25 @@ export interface Contacto {
updatedAt: string
}
/** GET /contactos — contactos locales (máx 100) */
export async function getContactos(): Promise<Contacto[]> {
const resp = await fetch(`${API_URL}/contactos`, conAuth())
/** Respuesta paginada de GET /contactos */
export interface RespuestaContactos {
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)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as Contacto[]
if (!resp.ok) {
// 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) ----------

View File

@@ -64,7 +64,7 @@ export async function listarClientesMaestro(filtros: FiltrosClientesMaestro = {}
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[]> {
const params = new URLSearchParams()
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 type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia'
@@ -56,10 +56,12 @@ export default function DetalleLeadModal({
const [nuevaNota, setNuevaNota] = useState('')
const [enviandoNota, setEnviandoNota] = useState(false)
const [busquedaNota, setBusquedaNota] = useState('')
// Input inline de nueva nota: el botón "+ Añadir nota" lo enfoca
const inputNotaRef = useRef<HTMLInputElement>(null)
// --- Tareas ---
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
// --- Cita ---
const [fechaCita, setFechaCita] = useState('')
const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : ''))
const [guardandoCita, setGuardandoCita] = useState(false)
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
@@ -192,40 +194,37 @@ export default function DetalleLeadModal({
</>
)}
{tab === 'cita' && (
{tab === 'cita' && esOp && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Cita</h3>
{esOp && item.fechaCita ? (
<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-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p>
</div>
) : esOp ? (
<form onSubmit={guardarCita} className="space-y-3">
<div>
<label htmlFor="cita-fecha" className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Fecha y hora de la cita
</label>
<input
id="cita-fecha"
type="datetime-local"
value={fechaCita}
onChange={(e) => setFechaCita(e.target.value)}
disabled={guardandoCita}
className="w-full 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:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50"
/>
<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">
<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 ')} · puedes reagendar abajo</p>
</div>
<button
type="submit"
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"
>
{guardandoCita ? 'Guardando…' : 'Guardar cita'}
</button>
</form>
) : (
<p className="text-sm text-[#94A3B8]">No hay cita programada. Las citas se agendan desde Mis oportunidades.</p>
)}
)}
<div>
<label htmlFor="cita-fecha" className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
{item.fechaCita ? 'Nueva fecha y hora de la cita' : 'Fecha y hora de la cita'}
</label>
<input
id="cita-fecha"
type="datetime-local"
value={fechaCita}
onChange={(e) => setFechaCita(e.target.value)}
disabled={guardandoCita}
className="w-full 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:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50"
/>
</div>
<button
type="submit"
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"
>
{guardandoCita ? 'Guardando…' : item.fechaCita ? 'Actualizar cita' : 'Guardar cita'}
</button>
</form>
</div>
)}
@@ -272,8 +271,8 @@ export default function DetalleLeadModal({
</div>
</div>
{/* Botón añadir */}
<button type="button" onClick={() => { setTab('notas') }}
{/* Botón añadir: enfoca el input inline en vez de recargar el tab */}
<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">
+ Añadir nota
</button>
@@ -282,7 +281,7 @@ export default function DetalleLeadModal({
{/* Formulario inline */}
<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}
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()}

View File

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

View File

@@ -389,10 +389,6 @@ function FilaApartado({
window.open(`/client/?apartado=${apartado.id}`, '_blank')
return
}
if (fase === 'Cancelacion Apartado') {
onFase(fase)
return
}
onFase(fase)
}}
className={
@@ -976,6 +972,20 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
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 iniciales = nombreVendedor
@@ -1095,7 +1105,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
conteos={{
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').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}
vistaKanban={vistaKanban}
@@ -1211,7 +1221,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={apartadosFiltrados}
columnas={columnasApartados}
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">
@@ -1224,6 +1242,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
enviando={enviando}
onVerDetalle={() => setLeadSeleccionado(a)}
onFase={(fase) => {
if (fase === 'Cancelacion Apartado') {
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
setApartadoCancelando({ id: a.id, nombre: a.nombre })
return
}
if (fase === 'Firma Cliente') {
// 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')
@@ -1245,33 +1268,16 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<p className="text-sm text-[#64748B] dark:text-[#94A3B8] mb-4">
Seguimiento de escrituración de apartados.
</p>
{(() => {
// 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 ? (
{escrituracionFiltrados.length === 0 ? (
<EstadoVacio mensaje="No hay apartados en escrituración" />
) : vistaKanban ? (
<KanbanView
items={apartadosEscrituracion}
items={escrituracionFiltrados}
columnas={columnasEscrituracion}
obtenerColumna={obtenerColumnaEscrituracion}
onCambiarEstado={(id, estado) => {
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 })
return
}
@@ -1280,7 +1286,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
/>
) : (
<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]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -1314,7 +1320,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</li>
))}
</ul>
)})()}
)}
</section>
)}
@@ -1325,7 +1331,9 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{tabCrm === 'calendario' && (
<section aria-label="Calendario">
<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}
onVerDetalle={(op) => setLeadSeleccionado(op)}
/>
@@ -1673,8 +1681,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
'modeloInteres' in leadSeleccionado
? (fechaHora) => {
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(
() => cambiarEtapaOportunidad(op.id, 'Agendo Cita', fechaHora),
() => cambiarEtapaOportunidad(op.id, etapa, fechaHora),
'No se pudo guardar la cita',
)
}