feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday

Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
This commit is contained in:
2026-09-11 06:52:37 +00:00
parent 075d47c304
commit 64b01246d6
69 changed files with 1600 additions and 1133 deletions

View File

@@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from 'react'
import Burbuja from '../components/Burbuja'
import DarkModeToggle from '../components/DarkModeToggle'
import toast from "react-hot-toast"
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api'
import {
enviarMensaje,
esErrorDeRed,
listarConversaciones,
obtenerHistorial,
type ConversacionResumen,
} from '../services/api'
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
import type { Mensaje } from '../types'
@@ -23,6 +29,12 @@ export default function ChatPage() {
const [escribiendo, setEscribiendo] = useState(false)
const [sinConexion, setSinConexion] = useState(false)
const [error, setError] = useState<string | null>(null)
// Texto del último envío fallido (habilita "Reintentar" junto al input)
const [pendiente, setPendiente] = useState<string | null>(null)
// Panel de conversaciones previas
const [panelAbierto, setPanelAbierto] = useState(false)
const [conversaciones, setConversaciones] = useState<ConversacionResumen[] | null>(null)
const [cargandoConversaciones, setCargandoConversaciones] = useState(false)
const finalRef = useRef<HTMLDivElement>(null)
// Al cargar: si hay sesión en localStorage, recupera su historial del API
@@ -41,18 +53,15 @@ export default function ChatPage() {
finalRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [mensajes, escribiendo])
async function enviar() {
const texto = entrada.trim()
if (!texto || escribiendo) return
setEntrada('')
setError(null)
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
/** Envía el texto y añade la respuesta; en fallo deja el texto en `pendiente` */
async function transmitir(texto: string) {
setEscribiendo(true)
try {
const r = await enviarMensaje(sessionId, texto)
setSessionId(r.sessionId)
guardarSesion(r.sessionId)
setSinConexion(false)
setPendiente(null)
setMensajes((prev) => [
...prev,
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
@@ -63,18 +72,63 @@ export default function ChatPage() {
} else {
toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
}
// el mensaje del usuario se queda visible; puede reintentar
setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar
} finally {
setEscribiendo(false)
}
}
async function enviar() {
const texto = entrada.trim()
if (!texto || escribiendo) return
setEntrada('')
setError(null)
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
await transmitir(texto)
}
/** 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)
}
function nuevaConversacion() {
limpiarSesion()
setSessionId(null)
setMensajes([])
setError(null)
setSinConexion(false)
setPendiente(null)
}
/** Abre el panel y carga (una vez) el listado de conversaciones del usuario */
function abrirPanel() {
setPanelAbierto(true)
if (conversaciones !== null || cargandoConversaciones) return
setCargandoConversaciones(true)
listarConversaciones()
.then(setConversaciones)
.catch(() => setConversaciones([]))
.finally(() => setCargandoConversaciones(false))
}
/** Carga el historial de una conversación previa y la vuelve la activa */
async function abrirConversacion(id: string) {
try {
const historial = await obtenerHistorial(id)
setSessionId(id)
guardarSesion(id)
setMensajes(historial)
setPendiente(null)
setError(null)
setSinConexion(false)
setPanelAbierto(false)
} catch {
toast.error('No se pudo cargar la conversación')
}
}
return (
@@ -96,6 +150,14 @@ export default function ChatPage() {
</div>
<div className="flex items-center gap-2">
<DarkModeToggle />
<button
type="button"
onClick={abrirPanel}
aria-expanded={panelAbierto}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Historial
</button>
<button
type="button"
onClick={nuevaConversacion}
@@ -163,6 +225,20 @@ export default function ChatPage() {
</p>
</div>
)}
{pendiente && !escribiendo && (
<div className="flex items-center justify-between gap-3 py-2">
<p className="text-xs font-medium text-red-600">
No se pudo enviar tu último mensaje.
</p>
<button
type="button"
onClick={() => void reintentar()}
className="rounded-lg bg-accent px-4 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-accent-hover"
>
Reintentar
</button>
</div>
)}
<form
className="flex items-stretch gap-3 py-4"
onSubmit={(e) => {
@@ -192,6 +268,64 @@ export default function ChatPage() {
</form>
</div>
</footer>
{/* Panel lateral: conversaciones previas del usuario */}
{panelAbierto && (
<div className="fixed inset-0 z-30 flex justify-end bg-black/40" onClick={() => setPanelAbierto(false)}>
<aside
className="h-full w-80 max-w-[85vw] overflow-y-auto border-l border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B]"
role="dialog"
aria-label="Conversaciones previas"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Conversaciones previas</h2>
<button
type="button"
onClick={() => setPanelAbierto(false)}
aria-label="Cerrar historial"
className="rounded-lg px-2 py-1 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
>
✕
</button>
</div>
{cargandoConversaciones ? (
<p className="mt-6 text-sm text-[#94A3B8]">Cargando…</p>
) : !conversaciones || conversaciones.length === 0 ? (
<p className="mt-6 text-sm text-[#94A3B8]">Sin conversaciones previas.</p>
) : (
<ul className="mt-4 space-y-2">
{conversaciones.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => void abrirConversacion(c.id)}
className={`w-full rounded-lg border p-3 text-left transition-colors ${
c.id === sessionId
? 'border-accent bg-blue-50/60 dark:bg-blue-950/30'
: 'border-[#E5E7EB] hover:border-accent dark:border-[#334155]'
}`}
>
<p className="truncate text-xs text-[#111827] dark:text-[#E2E8F0]">
{c.ultimoMensaje?.contenido || 'Conversación sin mensajes'}
</p>
<p className="mt-1 text-[10px] text-[#94A3B8]">
{new Date(c.createdAt).toLocaleString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</button>
</li>
))}
</ul>
)}
</aside>
</div>
)}
</div>
)
}

View File

@@ -73,3 +73,14 @@ export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
fuentes: m.fuentes ?? [],
}))
}
export interface ConversacionResumen {
id: string
createdAt: string
ultimoMensaje: { rol: string; contenido: string } | null
}
/** GET /ai/conversations — conversaciones previas del usuario (más reciente primero) */
export function listarConversaciones(): Promise<ConversacionResumen[]> {
return request<ConversacionResumen[]>('/ai/conversations')
}

View File

@@ -9,6 +9,7 @@ import type {
TipoCredito,
SlotDocumento,
} from '../types'
import { getSesion } from '../services/auth'
export const PROYECTOS = [
'RESIDENCIAL MONTEBELLO',
@@ -102,5 +103,9 @@ export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOL
// El modelo de interés sale de MODELOS_POR_PROYECTO según el desarrollo elegido.
export const OPCIONES_CALIFICA = ['Sí', 'No', 'Por confirmar'] as const
export const ASESOR_DEFAULT = 'ESMERALDA BECERRA DOMINGUEZ'
/**
* Nombre del asesor para el campo de firma del alta: se toma de la sesión
* activa en el momento en que arranca el alta. Sin sesión queda vacío.
*/
export const asesorDeSesion = (): string => getSesion()?.usuario.nombre ?? ''
export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL'

View File

@@ -1,5 +1,5 @@
import { z } from 'zod'
import { useForm, FormProvider } from 'react-hook-form'
import { useForm, FormProvider, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { Referencia } from '../types'
import { referenciasSchema } from '../lib/schemas'
@@ -11,13 +11,18 @@ import type { PasoProps } from './DatosGeneralesForm'
// RHF trabaja con objetos raíz; el array va envuelto
const schemaFormulario = z.object({ referencias: referenciasSchema })
/** Paso 5: referencias personales (4 filas, mínimo 1 con nombre y teléfono) */
const MAX_REFERENCIAS = 4
const referenciaVacia = (): Referencia => ({ nombre: '', telefono: '', parentesco: '', direccion: '' })
/** Paso 5: referencias personales (1 a 4 bloques; al menos 1 con nombre y teléfono) */
export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: PasoProps<Referencia[]>) {
const metodos = useForm<{ referencias: Referencia[] }>({
resolver: zodResolver(schemaFormulario),
defaultValues: { referencias: valores },
mode: 'onSubmit',
})
const { fields, append, remove } = useFieldArray({ control: metodos.control, name: 'referencias' })
const errorRaiz = metodos.formState.errors.referencias
return (
@@ -30,9 +35,24 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
titulo="Referencias personales"
descripcion="Captura al menos 1 referencia con nombre y teléfono."
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="rounded-xl border border-[#E5E7EB] p-6">
<p className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">Referencia {i + 1}</p>
{fields.map((field, i) => (
<div key={field.id} className="rounded-xl border border-[#E5E7EB] p-6">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Referencia {i + 1}
</p>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(i)}
aria-label={`Eliminar referencia ${i + 1}`}
title="Eliminar referencia"
className="flex h-7 w-7 items-center justify-center rounded-full border border-red-200 text-sm leading-none text-red-600 transition-colors duration-100 hover:bg-red-50"
>
×
</button>
)}
</div>
<div className="space-y-3">
<CampoTexto nombre={`referencias.${i}.nombre`} etiqueta="Nombre completo" />
<CampoTexto nombre={`referencias.${i}.telefono`} etiqueta="Teléfono" />
@@ -41,6 +61,17 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
</div>
</div>
))}
<div className="md:col-span-2 lg:col-span-3">
<button
type="button"
onClick={() => append(referenciaVacia())}
disabled={fields.length >= MAX_REFERENCIAS}
title={fields.length >= MAX_REFERENCIAS ? `Máximo ${MAX_REFERENCIAS} referencias` : undefined}
className="rounded-lg border border-accent bg-white px-4 py-2 text-sm font-medium text-accent transition-colors duration-100 hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
+ Agregar referencia
</button>
</div>
</SectionCard>
{errorRaiz && typeof errorRaiz.message === 'string' && (
<p className="mt-2 px-6 text-xs font-semibold text-red-600">{errorRaiz.message}</p>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import type { AltaCliente } from '../types'
import { fechaISOHoy } from '../lib/formatters'
import { ASESOR_DEFAULT, AUTORIDAD_ID_DEFAULT } from '../data/constantes'
import { asesorDeSesion, AUTORIDAD_ID_DEFAULT } from '../data/constantes'
import { useDocumentos } from './useDocumentos'
const datosGeneralesVacios = (): AltaCliente['datosGenerales'] => ({
@@ -56,10 +56,18 @@ const economicosVacios = (): AltaCliente['economicos'] => ({
dependientes: { personas: '', parentesco: '', edad: '' },
})
const referenciaVacia = (): AltaCliente['referencias'][number] => ({
nombre: '',
telefono: '',
parentesco: '',
direccion: '',
})
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
leadMondayId: null,
apartadoMondayId: null,
progresoId: null,
foto: null,
datosGenerales: datosGeneralesVacios(),
empleo: empleoVacio(),
economicos: economicosVacios(),
@@ -69,12 +77,7 @@ const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
empleo: empleoVacio(),
economicos: economicosVacios(),
},
referencias: [
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
],
referencias: [referenciaVacia()],
viviendaCredito: {
proyecto: '',
proyectoOtro: '',
@@ -107,7 +110,7 @@ const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
pais: 'MEXICO',
},
},
firma: { firmaDataUrl: '', asesorNombre: ASESOR_DEFAULT },
firma: { firmaDataUrl: '', asesorNombre: asesorDeSesion() },
})
export const PASOS = [

View File

@@ -12,6 +12,7 @@ export const fixtureAlta: AltaCliente = {
leadMondayId: null,
apartadoMondayId: null,
progresoId: null,
foto: null,
datosGenerales: {
campaniaVenta: '',
fecha: '2026-07-19',

View File

@@ -170,4 +170,17 @@ describe('referenciasSchema', () => {
])
expect(r.success).toBe(true)
})
it('acepta un solo bloque de referencia (arreglo dinámico, ya no exige 4)', () => {
const r = referenciasSchema.safeParse([
{ nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' },
])
expect(r.success).toBe(true)
})
it('rechaza más de 4 referencias', () => {
const valida = { nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: '', direccion: '' }
const r = referenciasSchema.safeParse([valida, valida, valida, valida, valida])
expect(r.success).toBe(false)
})
})

View File

@@ -149,7 +149,8 @@ const referenciaSchema = z.object({
export const referenciasSchema = z
.array(referenciaSchema)
.length(4, 'Se capturan 4 referencias')
.min(1, 'Captura al menos 1 referencia con nombre y teléfono')
.max(4, 'Máximo 4 referencias')
.refine(
(refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''),
'Captura al menos 1 referencia con nombre y teléfono',

View File

@@ -146,6 +146,11 @@ describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => {
),
).toHaveLength(0)
// El flujo posterior queda idéntico: alta registrada con el id del progreso
expect(await screen.findByText(/Alta registrada en el servidor: prog-1/)).toBeTruthy()
expect(await screen.findByText(/Alta registrada correctamente/)).toBeTruthy()
expect(screen.getByText(/Folio prog-1/)).toBeTruthy()
// Pantalla de éxito con las acciones post-registro (sin redirect automático)
expect(screen.getByRole('button', { name: 'Generar liga de firma' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Ir a Mi día' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Registrar otro cliente' })).toBeTruthy()
})
})

View File

@@ -226,9 +226,9 @@ export default function AltaClientePage() {
setPantalla('wizard')
}
const guardar = async () => {
const guardar = async (pasoDestino: number = paso) => {
try {
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos })
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos })
toast.success("Borrador guardado")
setBorradorId(b.id)
setAvisoBorrador(
@@ -239,6 +239,49 @@ export default function AltaClientePage() {
}
}
// ---------- Guard de cambios sin guardar (saltos con el Stepper) ----------
// El formulario del paso guarda sus capturas solo al avanzar con "Siguiente";
// si el usuario salta a otro paso con el Stepper, se le ofrece guardar antes.
const [sucio, setSucio] = useState(false)
// Paso elegido en el Stepper mientras el confirm de cambios sin guardar está abierto
const [destinoPendiente, setDestinoPendiente] = useState<number | null>(null)
// Al cambiar de paso el formulario se desmonta y remonta: se parte de cero
useEffect(() => {
setSucio(false)
}, [paso])
/** Cualquier edición dentro del paso actual marca cambios sin guardar */
const marcarSucio = () => setSucio(true)
/** Cambio de paso vía Stepper: confirma antes de saltar si hay cambios sin guardar */
const irAPasoConGuard = (destino: number) => {
if (destino === paso) return
if (sucio) {
setDestinoPendiente(destino)
return
}
irAPaso(destino)
}
/** "Guardar": persiste el borrador apuntando al paso destino y continúa hacia él */
const guardarYContinuar = async () => {
const destino = destinoPendiente
if (destino === null) return
setDestinoPendiente(null)
await guardar(destino)
irAPaso(destino)
}
/** "Continuar sin guardar": descarta lo capturado en el paso y salta al destino */
const continuarSinGuardar = () => {
const destino = destinoPendiente
if (destino === null) return
setDestinoPendiente(null)
setSucio(false)
irAPaso(destino)
}
// ---------- Guardado progresivo (borrador local + Contacto en Monday) ----------
// Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente
// avance manda el estado completo (el PATCH es idempotente).
@@ -309,14 +352,20 @@ export default function AltaClientePage() {
}
}
// Redirigir a Mi día después de completar el alta
useEffect(() => {
if (!registro) return
const timer = setTimeout(() => {
window.location.href = '/hoy/'
}, 2000)
return () => clearTimeout(timer)
}, [registro])
// Pantalla de éxito tras registrar el alta (en lugar del redirect automático a Mi día)
const registrarOtro = () => {
setRegistro('')
setSinConexion(false)
setErrorValidacion('')
setLigaUrl('')
setLigaCopiada(false)
setErrorLiga('')
empezarNuevo()
}
const irAMiDia = () => {
window.location.href = '/hoy/'
}
// Genera la liga pública y copia al portapapeles el texto listo para WhatsApp
const generarLiga = async () => {
@@ -485,6 +534,77 @@ export default function AltaClientePage() {
)
}
// ---------- Pantalla de éxito: el alta quedó registrada en el servidor ----------
if (registro) {
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
<AppHeader titulo="Alta de Clientes" />
<main className="px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
<div className="mx-auto max-w-2xl">
<div className="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-900 dark:bg-green-950/40">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-green-100 text-lg text-green-700 dark:bg-green-900 dark:text-green-400"
>
✓
</span>
<div className="min-w-0">
<h1 className="text-lg font-semibold text-green-800 dark:text-green-300">
Alta registrada correctamente
</h1>
<p className="mt-0.5 truncate text-xs text-green-700 dark:text-green-400">
{alta.datosGenerales.nombreCompleto || 'Cliente'} · Folio {registro}
</p>
</div>
</div>
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
type="button"
onClick={() => void generarLiga()}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
>
Generar liga de firma
</button>
<button
type="button"
onClick={irAMiDia}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Ir a Mi día
</button>
<button
type="button"
onClick={registrarOtro}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Registrar otro cliente
</button>
</div>
<p className="mt-6 text-xs text-green-800/80 dark:text-green-400/80">
Con la liga de firma, el cliente firma su expediente y sube documentos desde su
celular (vigencia 72 h, sin usuario ni contraseña).
</p>
{ligaUrl && (
<p className="mt-3 break-all text-xs text-[#111827] dark:text-[#E2E8F0]">
{ligaUrl}
{ligaCopiada && (
<span role="status" className="ml-2 font-medium text-accent">
Liga y mensaje de WhatsApp copiados
</span>
)}
</p>
)}
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
</div>
</div>
</main>
</div>
)
}
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
<AppHeader
@@ -515,10 +635,10 @@ export default function AltaClientePage() {
<main className="px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
<div className="md:grid md:grid-cols-[260px_minmax(0,1fr)] md:gap-8 lg:gap-8">
<div className="mb-6 md:sticky md:top-20 md:mb-0 md:self-start">
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPasoConGuard} />
</div>
<div className="w-full max-w-3xl">
<div className="w-full max-w-3xl" onInput={marcarSucio} onChange={marcarSucio}>
{errorCarga && (
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
{errorCarga}
@@ -549,9 +669,10 @@ export default function AltaClientePage() {
</p>
<div className="mt-3">
<FotoCliente
foto={null}
onFoto={() => {
// Foto capturada (el componente gestiona el preview internamente)
foto={datos.foto}
onFoto={(foto) => {
actualizar('foto', foto || null)
marcarSucio()
}}
/>
</div>
@@ -662,16 +783,6 @@ export default function AltaClientePage() {
)}
{paso === 7 && (
<>
{registro && (
<p className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
Alta registrada en el servidor: {registro}
</p>
)}
{registro && (
<p className="mb-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Redirigiendo a Mi día en unos segundos…
</p>
)}
{sinConexion && (
<p
role="alert"
@@ -688,35 +799,6 @@ export default function AltaClientePage() {
El servidor rechazó el alta: {errorValidacion}
</p>
)}
{registro && (
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Firma en remoto
</p>
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
documentos desde su celular, sin usuario ni contraseña.
</p>
<button
type="button"
onClick={() => void generarLiga()}
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Generar liga para firmar en remoto
</button>
{ligaUrl && (
<p className="mt-4 break-all text-xs text-[#111827] dark:text-[#E2E8F0]">
{ligaUrl}
{ligaCopiada && (
<span role="status" className="ml-2 font-medium text-accent">
Liga y mensaje de WhatsApp copiados
</span>
)}
</p>
)}
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
</div>
)}
<TarjetaPaso>
<FirmasForm
alta={alta}
@@ -729,6 +811,44 @@ export default function AltaClientePage() {
</div>
</div>
</main>
{/* Confirmación de cambios sin guardar al saltar de paso con el Stepper */}
{destinoPendiente !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-900/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="guard-cambios-titulo"
>
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<h2
id="guard-cambios-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Cambios sin guardar
</h2>
<p className="mt-2 text-sm text-[#4B5563] dark:text-[#CBD5E1]">
¿Deseas guardar antes de continuar?
</p>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={continuarSinGuardar}
className="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]"
>
Continuar sin guardar
</button>
<button
type="button"
onClick={() => void guardarYContinuar()}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
>
Guardar
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -157,6 +157,9 @@ export interface AltaCliente {
// ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
progresoId: string | null
// Foto del cliente (data URL base64) capturada en el paso de datos generales;
// null cuando aún no se toma. Viaja en el payload del alta y en los borradores.
foto: string | null
datosGenerales: DatosGenerales
empleo: DatosEmpleo
economicos: DatosEconomicos

View File

@@ -1,36 +1,36 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { request } from '../services/api'
import { formatoMoneda } from '../lib/formatters'
import { telefonoParaEnlace } from '../lib/cobranza'
import KpiCard from './KpiCard'
/** Registro de cliente moroso del endpoint GET /api/reportes/mora */
/** Registro de cliente moroso del endpoint GET /api/reportes/mora (mora.service) */
export interface ClienteMora {
clienteId: string
clienteNombre: string
clienteTelefono: string
unidadCodigo: string
proyecto: string
pagoVencido: number // ISO fecha
pagoVencido: number // número de pago vencido
monto: number
diasAtraso: number
telefono: string | null
planId: string
}
interface MoraResponse {
totalMorosos: number
clientesConMora: number
montoTotalVencido: number
diasPromedio: number
clientes: ClienteMoria[]
diasPromedioAtraso: number
clientes: ClienteMora[]
}
/** Normaliza la respuesta del API; acepta tanto el shape del API como el local */
type ClienteMoria = ClienteMora
function abrirWhatsApp(telefono: string) {
const limpio = telefono.replace(/[^0-9+]/g, '')
window.open(`https://wa.me/${limpio.startsWith('+') ? limpio.slice(1) : limpio}`, '_blank')
// Misma normalización +52 que mi-dia: 10 dígitos → México
window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank')
}
export default function MoraDashboard() {
const navigate = useNavigate()
const [datos, setDatos] = useState<MoraResponse | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -80,9 +80,9 @@ export default function MoraDashboard() {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard titulo="Total morosos" valor={datos.totalMorosos} />
<KpiCard titulo="Total morosos" valor={datos.clientesConMora} />
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedio} />
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedioAtraso} />
</div>
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -100,7 +100,7 @@ export default function MoraDashboard() {
</thead>
<tbody className="divide-y divide-[#E5E7EB]">
{datos.clientes.map((c) => (
<tr key={c.clienteId} 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 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td>
@@ -114,15 +114,25 @@ export default function MoraDashboard() {
</span>
</td>
<td className="px-4 py-3 text-right">
{c.clienteTelefono && (
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => abrirWhatsApp(c.clienteTelefono)}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-green-700"
onClick={() => navigate(`/planes/${c.planId}`)}
className="text-xs font-medium text-accent hover:underline"
aria-label={`Ver plan de ${c.clienteNombre}`}
>
WhatsApp
Ver plan →
</button>
)}
{c.telefono && (
<button
type="button"
onClick={() => c.telefono && abrirWhatsApp(c.telefono)}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-green-700"
>
WhatsApp
</button>
)}
</div>
</td>
</tr>
))}

View File

@@ -183,6 +183,16 @@ export interface CobranzaItem {
atraso: number
}
/**
* Normaliza un teléfono a dígitos para enlaces wa.me. Si el número no trae
* lada internacional (10 dígitos), asume México (+52); si ya la trae, la respeta.
*/
export function telefonoParaEnlace(telefono: string): string {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length === 10) return `52${digitos}`
return digitos
}
/**
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos

View File

@@ -370,6 +370,7 @@ export default function DashboardPage() {
<thead>
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
<th scope="col" className="px-4 py-3">Cliente</th>
<th scope="col" className="px-4 py-3">Teléfono</th>
<th scope="col" className="px-4 py-3">Unidad</th>
<th scope="col" className="px-4 py-3">Proyecto</th>
{encabezadoOrdenable('avance', '% pagado')}
@@ -387,6 +388,7 @@ export default function DashboardPage() {
{d.plan.clienteNombre}
</Link>
</td>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{d.plan.clienteTelefono || '—'}</td>
<td className="px-4 py-3">{d.plan.unidadCodigo}</td>
<td className="px-4 py-3">{d.plan.proyecto}</td>
<td className="px-4 py-3">
@@ -414,7 +416,7 @@ export default function DashboardPage() {
))}
{ordenados.length === 0 && (
<tr>
<td colSpan={8} className="py-8 text-center text-neutral-500">
<td colSpan={9} className="py-8 text-center text-neutral-500">
No hay planes que coincidan con el filtro.
</td>
</tr>

View File

@@ -12,6 +12,7 @@ import {
diasAtraso,
moraDePago,
pagadoTotal,
proximoVencimiento,
round2,
saldoPlan,
statusPago,
@@ -136,22 +137,43 @@ export default function PlanDetallePage() {
const avance = avancePlan(plan)
/** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */
const proximoPago = status === 'ACTIVO' ? proximoVencimiento(plan, hoy) : null
/** Abonar al próximo pago: hace scroll a su fila y abre el modal prellenado */
const abonarProximoPago = (pago: Pago) => {
document.getElementById(`pago-${pago.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
setPagoAbono(pago)
}
return (
<div className="space-y-6">
<div>
<Link to="/" className="text-sm text-accent hover:underline">
← Volver al dashboard
</Link>
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
</p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Tel. {plan.clienteTelefono || '—'}
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
</p>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<Link to="/" className="text-sm text-accent hover:underline">
← Volver al dashboard
</Link>
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
</p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Tel. {plan.clienteTelefono || '—'}
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
</p>
</div>
{proximoPago && (
<button
type="button"
onClick={() => abonarProximoPago(proximoPago)}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
aria-label="Registrar abono al próximo pago no cubierto"
>
Abonar al próximo pago
</button>
)}
</div>
{offline && (
@@ -245,7 +267,7 @@ export default function PlanDetallePage() {
const expandido = expandidos.has(pago.id)
return (
<Fragment key={pago.id}>
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<tr id={`pago-${pago.id}`} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<td className="px-4 py-3 font-medium">{pago.numPago}</td>
<td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td>
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>

View File

@@ -13,6 +13,9 @@ import {
type StorageLike,
} from './repository'
import { ErrorApi } from './api'
// Misma resolución de base que api.ts: el test no depende de la VITE_API_URL local
const API_URL = import.meta.env.VITE_API_URL || '/api'
import type { PlanPago } from '../types'
/** Stub sencillo de localStorage para correr en entorno node */
@@ -120,7 +123,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(plan.tasaMoraMensual).toBeUndefined()
expect(plan).not.toHaveProperty('saldo')
expect(fetchMock).toHaveBeenCalledWith(
'http://192.168.10.123:3000/api/planes',
`${API_URL}/planes`,
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
)
})
@@ -205,7 +208,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(r.plan.id).toBe('api-nuevo')
expect(storage.getItem(CLAVE_STORAGE)).toBeNull()
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes')
expect(url).toBe(`${API_URL}/planes`)
expect(init?.method).toBe('POST')
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body.clienteNombre).toBe('Cliente Prueba')
@@ -239,7 +242,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(r.origen).toBe('api')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/abonos')
expect(url).toBe(`${API_URL}/planes/api-1/abonos`)
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' })
})
@@ -291,7 +294,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
texto: 'Se habló con el cliente',
})
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/notas')
expect(url).toBe(`${API_URL}/planes/api-1/notas`)
})
it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => {
@@ -317,7 +320,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(r.origen).toBe('api')
expect(r.plan.status).toBe('CANCELADO')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/cancelar')
expect(url).toBe(`${API_URL}/planes/api-1/cancelar`)
expect(init?.method).toBe('POST')
})
@@ -361,7 +364,7 @@ describe('mis cobranzas (toggle Mis planes / Todos)', () => {
expect(r.origen).toBe('api')
expect(fetchMock).toHaveBeenCalledWith(
'http://192.168.10.123:3000/api/planes?mios=1',
`${API_URL}/planes?mios=1`,
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
)
})

View File

@@ -107,12 +107,15 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
>
Retomar
</button>
<a
href={`/client/?cotizacion=${c.id}`}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Iniciar alta
</a>
{/* El alta prellenada requiere la cotización en el servidor; las locales (CTE-LOCAL-*) no la tienen */}
{!c.id.startsWith('local-') && (
<a
href={`/client/?cotizacion=${c.id}`}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Iniciar alta
</a>
)}
</div>
</td>
</tr>

View File

@@ -55,6 +55,30 @@ describe('crearDocumentoPdf', () => {
})
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
})
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})
const { doc } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
precioLista: unidad.precioConAcabados,
propuestas,
cliente: { nombre: 'Juan Pérez' },
folio: 'CTE-LOCAL-1757568000000',
})
// El folio local viaja en el contenido del PDF aunque no se haya guardado en el API
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
expect(pdf).toContain('CTE-LOCAL-1757568000000')
expect(pdf).toContain('FOLIO')
})
})
describe('cuota de mantenimiento', () => {

View File

@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import CotizadorPage from './CotizadorPage'
import { INVENTARIO_SEED } from '../data/unidades'
/**
* Smoke del cierre de la cotización:
* - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF
* - preselección por defecto de la propuesta de menor total
* - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-*
*/
const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')!
const respuesta = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })
// El entorno de test no trae localStorage: mock en memoria para el fallback local
const almacenamiento = new Map<string, string>()
beforeEach(() => {
almacenamiento.clear()
vi.stubGlobal('localStorage', {
getItem: (k: string) => almacenamiento.get(k) ?? null,
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
removeItem: (k: string) => void almacenamiento.delete(k),
clear: () => almacenamiento.clear(),
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('CotizadorPage — barra sticky y guardado local', () => {
it('preselecciona la propuesta más barata y guarda local con folio CTE-LOCAL cuando el API no responde', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
const metodo = init?.method ?? 'GET'
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidad]))
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([]))
if (url.includes('/cotizaciones') && metodo === 'POST') {
return Promise.reject(new TypeError('servidor caído'))
}
return Promise.reject(new TypeError(`fetch inesperado: ${metodo} ${url}`))
})
vi.stubGlobal('fetch', fetchMock)
render(
<MemoryRouter>
<CotizadorPage />
</MemoryRouter>,
)
// Barra sticky siempre presente, con las tres acciones
expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy()
expect(screen.getByText(/Selecciona una unidad para elegir propuesta/)).toBeTruthy()
// Elegir una unidad del inventario
const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement
fireEvent.focus(inputUnidad)
fireEvent.change(inputUnidad, { target: { value: unidad.codigo } })
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) }))
// Preselección por defecto: la propuesta de menor total pagado
const selectPropuesta = (await screen.findByLabelText(
'Cambiar propuesta elegida',
)) as HTMLSelectElement
await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata
expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy()
// Guardar con el servidor caído → folio local persistido en el dispositivo
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
target: { value: 'Cliente Prueba Local' },
})
fireEvent.click(screen.getByRole('button', { name: 'Guardar' }))
await waitFor(() => {
const locales = JSON.parse(
almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]',
) as { folio?: string; clienteNombre?: string }[]
expect(locales).toHaveLength(1)
expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/)
expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local')
})
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
})
})

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -21,6 +21,7 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import {
getMisCotizaciones,
@@ -30,6 +31,7 @@ import {
TIPO_DE_API,
type CotizacionGuardada,
} from '../services/api'
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
import type { Cliente } from '../types'
const esquemaCliente = z.object({
@@ -87,6 +89,8 @@ export default function CotizadorPage() {
const [errorMias, setErrorMias] = useState<string | null>(null)
const [ultimaGuardada, setUltimaGuardada] = useState<CotizacionGuardada | null>(null)
const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
// true cuando el modal de cliente se abrió desde "Guardar" (sin generar PDF)
const [guardarSinPdf, setGuardarSinPdf] = useState(false)
const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState<string | null>(null)
// Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda
@@ -96,9 +100,22 @@ export default function CotizadorPage() {
useEffect(() => {
let vivo = true
/** Combina servidor + locales, sin duplicados, más recientes primero */
const mezclar = (principal: CotizacionGuardada[], extra: CotizacionGuardada[]) => {
const ids = new Set(principal.map((c) => c.id))
const combinadas = [...principal, ...extra.filter((c) => !ids.has(c.id))]
return combinadas
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
.slice(0, 50)
}
// Cotizaciones guardadas localmente cuando el API no respondió
void cargarCotizacionesLocales().then((locales) => {
if (!vivo || locales.length === 0) return
setMisCotizaciones((prev) => mezclar(prev, locales as CotizacionGuardada[]))
})
getMisCotizaciones()
.then((lista) => {
if (vivo) setMisCotizaciones(lista)
if (vivo) setMisCotizaciones((prev) => mezclar(lista, prev))
})
.catch((err) => {
console.warn('[api] No se pudo cargar el historial de cotizaciones.', err)
@@ -162,9 +179,63 @@ export default function CotizadorPage() {
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando
/** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
// más barata; la elección manual del vendedor se respeta hasta el siguiente
// cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada.
const unidadPrevia = useRef<string | null | undefined>(undefined)
const saltarAuto = useRef(false)
useEffect(() => {
const codigo = unidad?.codigo ?? null
const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo
unidadPrevia.current = codigo
if (!cambioUnidad || saltarAuto.current) {
saltarAuto.current = false
return
}
const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo))
const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
if (indiceMenor >= 0) setPropuestaIndex(indiceMenor)
}, [unidad?.codigo, propuestas, apartadoEfectivo])
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
const timestamp = Date.now()
return {
id: `local-${timestamp}`,
folio: `CTE-LOCAL-${timestamp}`,
unidadCodigo: unidad!.codigo,
desarrollo,
clienteNombre: cliente.nombre,
clienteTelefono: cliente.telefono ?? null,
clienteEmail: cliente.email ?? null,
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
precioLista,
precioFinal: propuestaActiva.precioFinal,
enganche: propuestaActiva.enganche,
numMensualidades: propuestaActiva.numMensualidades ?? 0,
mensualidad: propuestaActiva.mensualidad,
pagoFinal: propuestaActiva.pagoFinal,
createdAt: new Date(timestamp).toISOString(),
}
}
/**
* Persiste la cotización en el API. Si no responde, la guarda localmente
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
*/
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null
try {
@@ -186,9 +257,14 @@ export default function CotizadorPage() {
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
return conFolio
} catch (err) {
toast.error('No se pudo guardar la cotización'); console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err)
setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).')
return null
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
const local = cotizacionLocal(cliente)
void guardarCotizacionLocal(local)
setUltimaGuardada(local)
setMisCotizaciones((prev) => [local, ...prev].slice(0, 50))
toast('Cotización guardada en este dispositivo (sin conexión al servidor)')
setAviso(`Sin conexión al servidor: cotización guardada localmente con folio ${local.folio}.`)
return local
}
}
@@ -201,10 +277,13 @@ export default function CotizadorPage() {
let qrDataUrl: string | undefined
if (guardada) {
folio = guardada.folio ?? folioCotizacion(guardada.id)
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
margin: 1,
width: 200,
})
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
if (!esCotizacionLocal(guardada)) {
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
margin: 1,
width: 200,
})
}
}
generarPdf({
unidad,
@@ -227,6 +306,7 @@ export default function CotizadorPage() {
if (!unidad) return
// Sin nombre capturado, pide los datos del cliente en el modal
if (nombreCliente.trim().length < 3) {
setGuardarSinPdf(false)
setModalClienteAbierto(true)
return
}
@@ -237,12 +317,42 @@ export default function CotizadorPage() {
})
}
/** Guarda solo (sin PDF); con el flujo de modal si falta el nombre del cliente */
const guardarSolo = async (cliente: Cliente) => {
if (!unidad) return
setGuardando(true)
try {
await guardarEnApi(cliente)
} finally {
setGuardando(false)
}
}
const handleGuardar = () => {
if (!unidad) return
if (nombreCliente.trim().length < 3) {
setGuardarSinPdf(true)
setModalClienteAbierto(true)
return
}
void guardarSolo({
nombre: nombreCliente.trim(),
email: watch('email') || undefined,
telefono: watch('telefono') || undefined,
})
}
const handleModalAceptar = (cliente: Cliente) => {
setModalClienteAbierto(false)
setValue('nombre', cliente.nombre, { shouldValidate: true })
if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true })
if (cliente.email) setValue('email', cliente.email, { shouldValidate: true })
void generarPdfConCliente(cliente)
if (guardarSinPdf) {
setGuardarSinPdf(false)
void guardarSolo(cliente)
} else {
void generarPdfConCliente(cliente)
}
}
const handleCompartir = async () => {
@@ -256,7 +366,10 @@ export default function CotizadorPage() {
...(ultimaGuardada
? {
folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id),
urlPublica: urlPublicaCotizacion(ultimaGuardada.id),
// La URL pública solo existe si la cotización vive en el servidor
...(esCotizacionLocal(ultimaGuardada)
? {}
: { urlPublica: urlPublicaCotizacion(ultimaGuardada.id) }),
}
: {}),
})
@@ -281,6 +394,7 @@ export default function CotizadorPage() {
}
const tipo = TIPO_DE_API[c.tipoPropuesta]
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
restaurar({
desarrollo: c.desarrollo,
unidad: u,
@@ -306,7 +420,7 @@ export default function CotizadorPage() {
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
<main className="px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
<main className="px-4 py-8 pb-28 sm:px-6 lg:px-8 lg:py-10 lg:pb-28">
{/* Encabezado de página */}
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
@@ -599,49 +713,6 @@ export default function CotizadorPage() {
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end sm:justify-end">
<div>
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
Propuesta elegida
</label>
<select
id="propuesta-elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</div>
<button
type="button"
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
>
Compartir
</button>
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{aviso && <p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">{aviso}</p>}
{!puedeGenerarPdf && !guardando && (
<p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para guardar y generar el PDF.
</p>
)}
</div>
</div>
)}
</section>
@@ -664,6 +735,71 @@ export default function CotizadorPage() {
</div>
</main>
{/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */}
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{propuestaActiva ? (
<>
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
<span className="truncate">
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo}
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''}
</span>
</span>
<select
aria-label="Cambiar propuesta elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</>
) : (
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para elegir propuesta, guardar y generar el PDF.
</p>
)}
{aviso && (
<p role="status" className="w-full text-xs text-[#64748B] dark:text-[#94A3B8] sm:w-auto sm:max-w-xs sm:truncate">
{aviso}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-accent bg-white px-4 py-2 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
>
Compartir
</button>
<button
type="button"
onClick={handleGuardar}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:cursor-not-allowed disabled:opacity-40 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] dark:hover:bg-[#1E293B]"
>
Guardar
</button>
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Generar PDF'}
</button>
</div>
</div>
</div>
<ModalCliente
abierto={modalClienteAbierto}
nombreInicial={watch('nombre')}

View File

@@ -12,22 +12,82 @@ async function getDB(): Promise<IDBPDatabase> {
})
}
// Todas las operaciones son tolerantes a fallos: sin IndexedDB (navegación
// privada, tests) no lanzan, para no romper el flujo que las usa.
export async function guardarUnidades(unidades: unknown[]) {
const db = await getDB()
await db.put('unidades', unidades, 'actual')
try {
const db = await getDB()
await db.put('unidades', unidades, 'actual')
} catch {
// Sin almacenamiento: el inventario se recarga del API en la próxima sesión
}
}
export async function cargarUnidadesLocal() {
const db = await getDB()
return db.get('unidades', 'actual')
export async function cargarUnidadesLocal(): Promise<unknown> {
try {
const db = await getDB()
return db.get('unidades', 'actual')
} catch {
return undefined
}
}
export async function guardarCotizaciones(cotizaciones: unknown[]) {
const db = await getDB()
await db.put('cotizaciones', cotizaciones, 'historial')
try {
const db = await getDB()
await db.put('cotizaciones', cotizaciones, 'historial')
} catch {
// Sin almacenamiento
}
}
export async function cargarCotizacionesLocal() {
const db = await getDB()
return db.get('cotizaciones', 'historial')
export async function cargarCotizacionesLocal(): Promise<unknown> {
try {
const db = await getDB()
return db.get('cotizaciones', 'historial')
} catch {
return undefined
}
}
// ---------- Cotizaciones guardadas localmente (fallback sin servidor) ----------
const COTIZACIONES_LS = 'frasa-cotizador:cotizaciones-locales'
const MAX_LOCALES = 50
/**
* Guarda una cotización local (id con prefijo 'local-') en IndexedDB.
* Si IDB no está disponible (p. ej. navegación privada) cae a localStorage;
* si no hay almacenamiento alguno, no lanza: la sesión sigue.
*/
export async function guardarCotizacionLocal(cotizacion: unknown): Promise<void> {
try {
const db = await getDB()
await db.put('cotizaciones', cotizacion, `local:${(cotizacion as { id: string }).id}`)
return
} catch {
// IndexedDB no disponible: intenta localStorage
}
try {
const prev = JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[]
localStorage.setItem(COTIZACIONES_LS, JSON.stringify([cotizacion, ...prev].slice(0, MAX_LOCALES)))
} catch {
// Sin almacenamiento disponible
}
}
/** Lee las cotizaciones guardadas localmente (IndexedDB o localStorage) */
export async function cargarCotizacionesLocales(): Promise<unknown[]> {
try {
const db = await getDB()
const claves = (await db.getAllKeys('cotizaciones')).filter((k) => String(k).startsWith('local:'))
return Promise.all(claves.map((k) => db.get('cotizaciones', k)))
} catch {
// IndexedDB no disponible: intenta localStorage
}
try {
return JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[]
} catch {
return []
}
}

View File

@@ -13,6 +13,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.18.3",
"recharts": "^2.15.0"
},
"devDependencies": {
@@ -2128,6 +2129,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -3271,6 +3285,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@@ -3424,6 +3476,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",

View File

@@ -15,6 +15,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.18.3",
"recharts": "^2.15.0"
},
"devDependencies": {

View File

@@ -1,11 +1,32 @@
import type { ReactNode } from 'react'
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md">
const CLASE_BASE =
'rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md'
interface Props {
titulo: string
valor: ReactNode
detalle?: string
/** Presente = KPI clickeable (drill-down): se renderiza como botón */
onClick?: () => void
}
export default function KpiCard({ titulo, valor, detalle, onClick }: Props) {
const contenido = (
<>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
{detalle && <p className="mt-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]">{detalle}</p>}
</div>
</>
)
if (!onClick) return <div className={CLASE_BASE}>{contenido}</div>
return (
<button
type="button"
onClick={onClick}
className={`${CLASE_BASE} w-full cursor-pointer text-left transition-colors hover:border-accent`}
>
{contenido}
</button>
)
}

View File

@@ -3,26 +3,28 @@ import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } fr
import { tooltipProps } from '../lib/chart-theme'
import KpiCard from './KpiCard'
import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero'
import { fetchResumenMiDia } from '../services/mi-dia'
import { funnelMiTablero, type MiTablero } from '../lib/mi-tablero'
import { fetchMiTablero } from '../services/reportes'
// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros)
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros);
// rojo para la etapa final "Descartados"
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB', '#EF4444']
/**
* "Mi tablero": KPIs personales del vendedor logueado (mis leads, actividades,
* apartados por vencer) y un funnel simple de sus leads por estado.
* Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API).
* "Mi tablero": KPIs personales del vendedor logueado y su funnel de conversión
* (Leads → Oportunidades → Apartados → Vendidos → Descartados).
* Fuente: GET /api/reportes/mi-tablero, el mismo endpoint que usa el panel
* "Mi día" (DashboardVendedor).
*/
export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (acciones: AccionesTablero) => void }) {
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
const [tablero, setTablero] = useState<MiTablero | null>(null)
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
const cargar = useCallback(async () => {
setCargando(true)
try {
setResumen(await fetchResumenMiDia())
setTablero(await fetchMiTablero())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
@@ -40,15 +42,20 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
}, [alCambiarAcciones, cargar, cargando])
const funnel = resumen ? funnelMisLeads(resumen.misLeads) : []
const funnel = tablero ? funnelMiTablero(tablero) : []
const conversion = tablero?.conversion
const tasaConversion =
conversion && conversion.leads > 0
? `${((conversion.oportunidades / conversion.leads) * 100).toFixed(1)}%`
: '—'
return (
<main className="px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Mi tablero</h1>
{resumen && (
{tablero && (
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
Actualizado: {new Date(tablero.generadoEn).toLocaleString('es-MX')}
</p>
)}
</div>
@@ -59,39 +66,43 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
</p>
)}
{resumen?.sinMapeoMonday && (
<p className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
Tu usuario aún no está ligado a Monday: las listas personales aparecen vacías
</p>
)}
{!tablero && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
{!tablero && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
{resumen && (
{tablero && conversion && (
<>
<section aria-label="Mis indicadores" className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<KpiCard
titulo="Mis leads activos"
valor={resumen.misLeads.length}
detalle={`${resumen.conteos.leadsNuevosSemana} nuevo(s) esta semana`}
titulo="Leads totales"
valor={conversion.leads}
detalle={`${conversion.oportunidades} oportunidades · ${conversion.vendidos} vendidos`}
/>
<KpiCard
titulo="Mis actividades abiertas"
valor={resumen.conteos.actividadesAbiertas}
detalle={`${resumen.misActividadesHoy.length} con fecha de hoy o antes`}
titulo="Tasa de conversión"
valor={tasaConversion}
detalle={`${conversion.oportunidades} de ${conversion.leads} leads`}
/>
<KpiCard
titulo="Apartados por vencer"
valor={resumen.apartadosPorVencer.length}
detalle="próximos 7 días (pipeline completo)"
titulo="Ventas del mes"
valor={conversion.vendidos}
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN · ${tablero.ventasMes.unidades} cotizada(s)`}
/>
<KpiCard
titulo="Actividades abiertas"
valor={tablero.actividadesPendientes}
detalle={`${tablero.actividadesHoy} con fecha de hoy o antes`}
/>
<KpiCard
titulo="Leads descartados"
valor={conversion.descartados}
detalle="etapa terminal DESCARTADO"
/>
</section>
<section aria-label="Mis leads por estado" className="mt-10">
<TarjetaGrafica titulo="Mis leads por estado">
{funnel.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
<section aria-label="Funnel de conversión" className="mt-10">
<TarjetaGrafica titulo="Funnel de conversión (incluye descartados)">
{funnel.every((e) => e.valor === 0) ? (
<EstadoVacio mensaje="Sin movimiento comercial" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
Bar,
BarChart,
@@ -43,10 +44,28 @@ const COLORES_STATUS: Record<string, string> = {
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
const COLORES_COBRANZA = ['#2563EB', '#F59E0B']
/** Rangos de fecha disponibles para filtrar las gráficas (días hacia atrás) */
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_MI_DIA = '/hoy/'
/**
* Drill-down cruzado de apps: /pay (cobranza) y /hoy (mi-dia) son SPAs
* distintas detrás de NGINX; navigate() de react-router no puede salir del
* basename de esta app, así que el salto es a nivel document.
*/
function irAApp(ruta: string): void {
window.location.assign(ruta)
}
/**
* Vista "General": el tablero comercial de todo el ecosistema (KPIs, inventario,
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
* admin) agrega la sección Equipo con leads por vendedor.
* El rango de fechas (?dias=7|30|90) vive en la URL y filtra leads,
* oportunidades y perfilamiento; inventario y cobranza son posición actual.
*/
export default function TableroGeneral({
mostrarEquipo,
@@ -58,23 +77,39 @@ export default function TableroGeneral({
const [resumen, setResumen] = useState<ResumenReportes | null>(null)
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
// Rango de fechas persistido en la URL: ?dias=7|30|90 (default 30)
const [searchParams, setSearchParams] = useSearchParams()
const paramDias = Number(searchParams.get('dias'))
const dias = (RANGOS_DIAS as readonly number[]).includes(paramDias) ? paramDias : 30
const cargar = useCallback(async () => {
setCargando(true)
try {
setResumen(await fetchResumen())
setResumen(await fetchResumen(dias))
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
} finally {
setCargando(false)
}
}, [])
}, [dias])
useEffect(() => {
void cargar()
}, [cargar])
/** Cambia el rango y lo persiste en la URL (compartible) */
const cambiarRango = (nuevo: number) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.set('dias', String(nuevo))
return next
},
{ replace: true },
)
}
// Expone "Actualizar" al header sticky de DashboardPage
useEffect(() => {
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
@@ -89,13 +124,33 @@ export default function TableroGeneral({
return (
<main className="px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
{resumen && (
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p>
)}
<div className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
{resumen && (
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p>
)}
</div>
<div className="flex items-center gap-1" role="group" aria-label="Rango de fechas de las gráficas">
<span className="mr-1 text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Rango:</span>
{RANGOS_DIAS.map((r) => (
<button
key={r}
type="button"
onClick={() => cambiarRango(r)}
aria-pressed={dias === r}
className={
dias === r
? 'rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors'
: 'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#64748B] transition-colors hover:text-[#111827] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#94A3B8] dark:hover:text-[#E2E8F0]'
}
>
{r} días
</button>
))}
</div>
</div>
{error && (
@@ -114,6 +169,7 @@ export default function TableroGeneral({
titulo="Leads totales"
valor={resumen.leads.total}
detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`}
onClick={() => irAApp(RUTA_MI_DIA)}
/>
<KpiCard
titulo="Oportunidades activas"
@@ -132,7 +188,8 @@ export default function TableroGeneral({
<KpiCard
titulo="Cartera vencida"
valor={formatoMoneda(resumen.cobranza.montoVencido)}
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s)`}
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s) · click para ver planes`}
onClick={() => irAApp(RUTA_PLANES_VENCIDOS)}
/>
<KpiCard
titulo="Planes activos"

View File

@@ -79,3 +79,40 @@ export function funnelMisLeads(misLeads: LeadMiDia[]): EtapaFunnelPersonal[] {
.map(([etapa, valor]) => ({ etapa, valor }))
.sort((a, b) => b.valor - a.valor || a.etapa.localeCompare(b.etapa))
}
// ---------- Tablero personal (GET /api/reportes/mi-tablero) ----------
// Mismo endpoint que consume DashboardVendedor en la app de Mi día.
export interface MiTablero {
generadoEn: string
leadsPorDia: { fecha: string; cantidad: number }[]
conversion: {
leads: number
oportunidades: number
apartados: number
vendidos: number
/** Leads con etapa terminal DESCARTADO; etapa extra al final del funnel */
descartados: number
}
ventasMes: { monto: number; unidades: number }
porOrigen: { origen: string; total: number; convertidos: number }[]
porDesarrollo: { desarrollo: string; ventas: number }[]
actividadesHoy: number
actividadesPendientes: number
}
/**
* Etapas del funnel personal desde el endpoint unificado: Leads →
* Oportunidades → Apartados → Vendidos, con "Descartados" al final (fuera del
* embudo: leads terminados sin conversión) para que no se omitan.
*/
export function funnelMiTablero(tablero: MiTablero): EtapaFunnelPersonal[] {
const c = tablero.conversion
return [
{ etapa: 'Leads', valor: c.leads },
{ etapa: 'Oportunidades', valor: c.oportunidades },
{ etapa: 'Apartados', valor: c.apartados },
{ etapa: 'Vendidos', valor: c.vendidos },
{ etapa: 'Descartados', valor: c.descartados },
]
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
@@ -11,9 +12,13 @@ import DashboardPage from './pages/DashboardPage'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthGate>
<DashboardPage />
</AuthGate>
{/* La app vive bajo el prefijo /dash/ de NGINX (vite base); el rango de
fechas del tablero general se persiste en la URL (?dias=7|30|90) */}
<BrowserRouter basename={import.meta.env.BASE_URL}>
<AuthGate>
<DashboardPage />
</AuthGate>
</BrowserRouter>
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</StrictMode>,
)

View File

@@ -1,18 +1,32 @@
// Cliente del endpoint de reportes del API Gateway (misma red, same-origin tras NGINX)
import { conAuth, manejar401 } from './auth'
import type { ResumenReportes } from '../lib/reportes'
import type { MiTablero } from '../lib/mi-tablero'
const API_URL = import.meta.env.VITE_API_URL || '/api'
/** GET /reportes/resumen; lanza Error con mensaje legible si el API falla */
export async function fetchResumen(): Promise<ResumenReportes> {
async function obtener<T>(path: string): Promise<T> {
let res: Response
try {
res = await fetch(`${API_URL}/reportes/resumen`, conAuth())
res = await fetch(`${API_URL}${path}`, conAuth())
} catch {
throw new Error('No se pudo conectar con el servidor')
}
manejar401(res)
if (!res.ok) throw new Error(`El API respondió ${res.status}`)
return (await res.json()) as ResumenReportes
return (await res.json()) as T
}
/**
* GET /reportes/resumen; `dias` (7/30/90) acota leads y oportunidades al rango.
* Lanza Error con mensaje legible si el API falla.
*/
export async function fetchResumen(dias?: number): Promise<ResumenReportes> {
const query = dias !== undefined && dias > 0 ? `?dias=${Math.floor(dias)}` : ''
return obtener<ResumenReportes>(`/reportes/resumen${query}`)
}
/** GET /reportes/mi-tablero — tablero personal del vendedor (mismo que Mi día) */
export async function fetchMiTablero(): Promise<MiTablero> {
return obtener<MiTablero>('/reportes/mi-tablero')
}

View File

@@ -1,192 +0,0 @@
import { useEffect, useState } from 'react'
import { getContactoDetalle, type DetalleContacto, type RelacionCrm } from '../services/api'
interface Props {
contactoId: string
nombre: string
onCerrar: () => void
}
const formatoMoneda = (n: number): string =>
n > 0 ? n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }) : '—'
const formatoFecha = (iso: string): string =>
iso
? new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: ''
function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
if (valor === '') return null
return (
<div>
<p className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
<p className="mt-0.5 text-sm text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
</div>
)
}
function ListaRelaciones({ titulo, items }: { titulo: string; items: RelacionCrm[] }) {
if (items.length === 0) return null
return (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<ul className="mt-2 space-y-2">
{items.map((r) => (
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[#E5E7EB] px-3 py-2 dark:border-[#334155]">
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">
{r.estado || 'Sin estado'}
{r.detalle ? <span className="ml-2 text-xs text-[#64748B] dark:text-[#94A3B8]">{r.detalle}</span> : null}
</span>
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{r.atendidoPor ?? 'Sin asignar'}
</span>
</li>
))}
</ul>
</div>
)
}
/**
* Modal de contacto: toda su información (columnas del board), la propiedad
* comprada/en proceso, quién atiende cada item relacionado y el historial
* completo de atención (Monday + capturas de las apps).
*/
export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
const [detalle, setDetalle] = useState<DetalleContacto | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let vivo = true
getContactoDetalle(contactoId)
.then((d) => {
if (vivo) setDetalle(d)
})
.catch(() => {
if (vivo) setError('No se pudo cargar el detalle del contacto.')
})
return () => {
vivo = false
}
}, [contactoId])
// Cerrar con Escape
useEffect(() => {
const alTeclear = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCerrar()
}
window.addEventListener('keydown', alTeclear)
return () => window.removeEventListener('keydown', alTeclear)
}, [onCerrar])
const c = detalle?.contacto ?? {}
return (
<div
className="fixed inset-0 z-20 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
role="dialog"
aria-modal="true"
aria-label={`Detalle de ${nombre}`}
onClick={(e) => {
if (e.target === e.currentTarget) onCerrar()
}}
>
<div className="w-full max-w-2xl rounded-2xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -xl">
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2>
{c['Estado'] && (
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">{c['Estado']}</span>
)}
</div>
<button
type="button"
onClick={onCerrar}
aria-label="Cerrar"
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] hover:text-[#111827] dark:hover:bg-[#334155] dark:hover:text-[#E2E8F0]"
>
✕
</button>
</div>
<div className="space-y-6 px-6 py-5">
{error && (
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</p>
)}
{!detalle && !error && <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando detalle…</p>}
{detalle && (
<>
<section aria-label="Datos del contacto">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Datos del contacto</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
<Dato etiqueta="Teléfono" valor={c['Teléfono'] ?? ''} />
<Dato etiqueta="Email" valor={c['Email'] ?? ''} />
<Dato etiqueta="CURP" valor={c['CURP'] ?? ''} />
<Dato etiqueta="RFC" valor={c['RFC'] ?? ''} />
<Dato etiqueta="Estado civil" valor={c['Estado Civil'] ?? ''} />
<Dato etiqueta="Fecha de nacimiento" valor={c['Fecha Nacimiento'] ?? ''} />
<Dato etiqueta="Rango de edad" valor={c['Rango de Edad'] ?? ''} />
<Dato etiqueta="Actividad económica" valor={c['Actividad Economica'] ?? ''} />
<Dato etiqueta="Vivienda actual" valor={c['Vivienda Actual'] ?? ''} />
<Dato etiqueta="Propósito de compra" valor={c['Proposito de Compra'] ?? ''} />
<Dato etiqueta="Dirección" valor={c['Dirección'] ?? ''} />
</div>
</section>
{detalle.propiedad && (
<section aria-label="Propiedad">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Propiedad</p>
<div className="rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] p-4 dark:border-[#334155] dark:bg-[#0F172A]">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
<Dato etiqueta="Desarrollo" valor={detalle.propiedad.desarrollo} />
<Dato etiqueta="Unidad" valor={detalle.propiedad.unidadCodigo} />
<Dato etiqueta="Precio final" valor={formatoMoneda(detalle.propiedad.precioFinal)} />
<Dato etiqueta="Estatus" valor={detalle.propiedad.estado} />
<Dato etiqueta="Fecha de compra" valor={detalle.propiedad.fechaCompra} />
<Dato etiqueta="Atendido por" valor={detalle.propiedad.atendidoPor ?? ''} />
</div>
</div>
</section>
)}
<section aria-label="Relaciones CRM" className="space-y-4">
<ListaRelaciones titulo="Leads" items={detalle.relaciones.leads} />
<ListaRelaciones titulo="Oportunidades" items={detalle.relaciones.oportunidades} />
<ListaRelaciones titulo="Apartados" items={detalle.relaciones.apartados} />
</section>
<section aria-label="Historial de atención">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Historial de atención
</p>
{detalle.historial.length === 0 ? (
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin movimientos registrados.</p>
) : (
<ul className="max-h-64 space-y-2 overflow-y-auto">
{detalle.historial.map((h, i) => (
<li key={i} className="flex items-baseline justify-between gap-3 rounded-lg border border-[#E5E7EB] px-3 py-2 dark:border-[#334155]">
<div className="min-w-0">
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">
{h.detalle}
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-[#334155] dark:text-[#94A3B8]">
{h.fuente}
</span>
</p>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">{h.usuario ?? 'Automatización'}</p>
</div>
<span className="shrink-0 text-xs text-[#94A3B8]">{formatoFecha(h.fechaISO)}</span>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { getContactoDetalle, type DetalleContacto } from '../services/api'
import { getContactoDetalle, type DatosContacto, type DetalleContacto } from '../services/api'
const formatoFecha = (iso: string): string =>
iso
@@ -47,7 +47,7 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
return () => window.removeEventListener('keydown', alTeclear)
}, [onCerrar])
const c = detalle?.contacto ?? {}
const c: Partial<DatosContacto> = detalle?.contacto ?? {}
const prop = detalle?.propiedad ?? null
const hist = detalle?.historial ?? []
const rels = detalle?.relaciones ?? { leads: [], oportunidades: [], apartados: [] }
@@ -64,7 +64,7 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] pb-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Perfil del contacto</p>
<h2 className="mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2>
<h2 className="mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c.nombre ?? nombre}</h2>
</div>
<button
type="button"
@@ -83,15 +83,15 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
<div className="mt-5 space-y-5">
{/* Datos personales */}
<Section titulo="Datos personales">
<Field label="Email" value={c['Email']} />
<Field label="Teléfono" value={c['Teléfono']} />
<Field label="Estado" value={c['Estado']} />
<Field label="CURP" value={c['CURP']} />
<Field label="RFC" value={c['RFC']} />
<Field label="Estado civil" value={c['Estado Civil']} />
<Field label="Fecha nacimiento" value={c['Fecha Nacimiento']} />
<Field label="Actividad económica" value={c['Actividad Economica']} />
<Field label="Dirección" value={c['Dirección']} />
<Field label="Email" value={c.email} />
<Field label="Teléfono" value={c.telefono} />
<Field label="Estado" value={c.estado} />
<Field label="CURP" value={c.curp} />
<Field label="RFC" value={c.rfc} />
<Field label="Estado civil" value={c.estadoCivil} />
<Field label="Fecha nacimiento" value={c.fechaNacimiento} />
<Field label="Actividad económica" value={c.actividadEconomica} />
<Field label="Dirección" value={c.direccion} />
</Section>
{/* Propiedad */}

View File

@@ -45,15 +45,15 @@ afterEach(() => {
})
describe('ConfigPage', () => {
it('renderiza los 4 tabs y muestra "Modo de trabajo" por defecto', () => {
it('renderiza los 3 tabs y muestra "Roles y permisos" por defecto', async () => {
render(<ConfigPage onVolver={() => {}} />)
expect(screen.getByText('Configuraciones')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Modo de trabajo' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Roles y permisos' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Sistema' })).toBeTruthy()
// El tab de "Modo de trabajo" está activo por defecto
expect(screen.getByText('Modo de trabajo')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Modo de trabajo' })).toBeNull()
// El tab de "Roles y permisos" está activo por defecto (su contenido carga async)
expect(await screen.findByText('Guardar cambios')).toBeTruthy()
})
it('al hacer clic en un tab, muestra su contenido', async () => {

View File

@@ -1,5 +1,4 @@
import { useState } from 'react'
import { ModoTrabajo } from './ModoTrabajo'
import { RolesPermisos } from './RolesPermisos'
import { Usuarios } from './Usuarios'
import { Sistema } from './Sistema'
@@ -10,17 +9,16 @@ interface Props {
onVolver: () => void
}
type Tab = 'modo' | 'roles' | 'usuarios' | 'sistema' | 'auditoria'
type Tab = 'roles' | 'usuarios' | 'sistema' | 'auditoria'
const TABS_BASE: { key: Tab; label: string }[] = [
{ key: 'modo', label: 'Modo de trabajo' },
{ key: 'roles', label: 'Roles y permisos' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'sistema', label: 'Sistema' },
]
export function ConfigPage({ onVolver }: Props) {
const [tab, setTab] = useState<Tab>('modo')
const [tab, setTab] = useState<Tab>('roles')
const esAdmin = getSesion()?.usuario.rol === 'admin'
const tabs = esAdmin ? [...TABS_BASE, { key: 'auditoria' as const, label: 'Auditoría' }] : TABS_BASE
@@ -62,7 +60,6 @@ export function ConfigPage({ onVolver }: Props) {
{/* Contenido del tab activo */}
<section className="mt-6">
{tab === 'modo' && <ModoTrabajo />}
{tab === 'roles' && <RolesPermisos />}
{tab === 'usuarios' && <Usuarios />}
{tab === 'sistema' && <Sistema />}

View File

@@ -1,85 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ModoTrabajo } from './ModoTrabajo'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
function crearStorageStub(): Storage {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => void mapa.set(k, v),
removeItem: (k: string) => void mapa.delete(k),
clear: () => mapa.clear(),
key: () => null,
get length() {
return mapa.size
},
} as Storage
}
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('localStorage', crearStorageStub())
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('ModoTrabajo', () => {
it('muestra el estado de Monday y el toggle', async () => {
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.includes('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 }))
if (url.includes('/config/sistema')) return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' }))
return Promise.resolve(respuestaOk({}))
})
render(<ModoTrabajo />)
await waitFor(() => {
expect(screen.getByText(/Conectado|No disponible/)).toBeTruthy()
}, { timeout: 5000 })
expect(screen.getByRole('switch', { name: /cambiar modo de trabajo/i })).toBeTruthy()
})
it('al hacer clic en el toggle, hace PATCH y actualiza el texto', async () => {
fetchMock.mockImplementation((input, init) => {
const url = String(input)
if (url.includes('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 }))
if (url.includes('/config/sistema') && (!init?.method || init.method === 'GET')) {
return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' }))
}
if (url.includes('/config/sistema') && init?.method === 'PATCH') {
return Promise.resolve(respuestaOk({}))
}
return Promise.resolve(respuestaOk({}))
})
render(<ModoTrabajo />)
await waitFor(() => {
expect(screen.getByRole('switch', { name: /cambiar modo de trabajo/i })).toBeTruthy()
}, { timeout: 5000 })
fireEvent.click(screen.getByRole('switch', { name: /cambiar modo de trabajo/i }))
await waitFor(() => {
const llamadaPatch = fetchMock.mock.calls.find(
([url, init]) => String(url).includes('/config/sistema') && init?.method === 'PATCH',
)
expect(llamadaPatch).toBeTruthy()
}, { timeout: 5000 })
expect(await screen.findByText('Modo local activado')).toBeTruthy()
})
})

View File

@@ -1,155 +0,0 @@
import { useEffect, useState } from 'react'
import { fetchEstadoSistema, type EstadoSistema } from '../../services/sistema'
import { getConfigSistema, patchConfigSistema } from '../../services/config'
import { API_URL, conAuth } from '../../services/auth'
export function ModoTrabajo() {
const [estado, setEstado] = useState<EstadoSistema | null>(null)
const [modoLocal, setModoLocal] = useState(false)
const [cargando, setCargando] = useState(true)
const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
useEffect(() => {
let activo = true
const cargar = async () => {
setCargando(true)
try {
const [estadoRes, configRes] = await Promise.all([
fetchEstadoSistema(),
getConfigSistema(),
])
if (!activo) return
setEstado(estadoRes)
setModoLocal(configRes.modo_local_forzado === 'true')
} catch {
if (activo) setError('No se pudo cargar el estado del sistema.')
} finally {
if (activo) setCargando(false)
}
}
void cargar()
return () => { activo = false }
}, [])
const toggleModo = async () => {
const nuevoModo = !modoLocal
setGuardando(true)
setAviso('')
setError('')
try {
await patchConfigSistema({ modo_local_forzado: String(nuevoModo) })
setModoLocal(nuevoModo)
setAviso(nuevoModo ? 'Modo local activado' : 'Modo vinculado a Monday restaurado')
} catch {
setError('No se pudo actualizar el modo de trabajo.')
} finally {
setGuardando(false)
}
}
const reconectar = async () => {
setGuardando(true)
setAviso('')
setError('')
try {
// Intenta reconectar — si el endpoint no existe, simplemente informa
const res = await fetch(`${API_URL}/sistema/reconexion`, conAuth({ method: 'POST' }))
if (res.ok) {
setAviso('Reconexión forzada. El estado se actualizará en unos segundos.')
// Recargar estado después de un breve delay
setTimeout(async () => {
const nuevoEstado = await fetchEstadoSistema()
setEstado(nuevoEstado)
}, 3000)
} else {
setAviso('El endpoint de reconexión no está disponible aún.')
}
} catch {
setAviso('El endpoint de reconexión no está disponible aún.')
} finally {
setGuardando(false)
}
}
if (cargando) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando estado del sistema…</p>
</div>
)
}
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Modo de trabajo</h2>
{/* Indicador del estado de Monday */}
<div className="mt-4 flex items-center gap-2">
<span
className={`h-2.5 w-2.5 rounded-full ${estado?.mondayOk ? 'bg-green-500' : 'bg-red-500'}`}
/>
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Monday: {estado?.mondayOk ? 'Conectado' : 'No disponible'}
{estado && ` · ${estado.pendientesSync} pendiente(s) de sincronizar`}
</span>
</div>
{/* Toggle de modo */}
<div className="mt-6 flex items-center gap-4">
<button
type="button"
onClick={() => void toggleModo()}
disabled={guardando}
className={`relative inline-flex h-8 w-14 flex-shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50 ${
modoLocal ? 'bg-amber-500' : 'bg-accent'
}`}
role="switch"
aria-checked={modoLocal}
aria-label="Cambiar modo de trabajo"
>
<span
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
modoLocal ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
<div>
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{modoLocal ? 'Modo local (sin Monday)' : 'Vinculado a Monday'}
</p>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">
{modoLocal
? 'Las capturas se guardan localmente y se sincronizarán cuando Monday esté disponible.'
: 'Los leads, actividades y contactos se sincronizan con Monday en tiempo real.'}
</p>
</div>
</div>
{/* Botón reconectar */}
<div className="mt-6">
<button
type="button"
onClick={() => void reconectar()}
disabled={guardando}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
{guardando ? 'Procesando…' : 'Forzar reconexión'}
</button>
</div>
{/* Feedback */}
{aviso && (
<p role="status" className="mt-4 text-sm font-medium text-green-600">
{aviso}
</p>
)}
{error && (
<p role="alert" className="mt-4 text-sm text-red-600">
{error}
</p>
)}
</div>
)
}

View File

@@ -8,8 +8,8 @@ const porId = (id: string): Modulo => {
}
describe('MODULOS', () => {
it('expone los 8 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(8)
it('expone los 9 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(9)
expect(MODULOS.map((m) => m.id)).toEqual([
'cotizador',
'alta-clientes',
@@ -19,6 +19,7 @@ describe('MODULOS', () => {
'mi-dia',
'contactos',
'captura',
'clientes-maestro',
])
})

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react'
import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro'
import { ClientesMaestroTable } from '../components/ClientesMaestroTable'
import ModalClienteMaestro from '../components/ModalClienteMaestro'
import { getSesion } from '../services/auth'
import toast from 'react-hot-toast'
interface Props {
@@ -16,7 +15,6 @@ 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.
* Botón importar (solo admin) sube Excel al backend.
*/
export function ClientesMaestroPage({ onVolver }: Props) {
const [datos, setDatos] = useState<ClienteMaestro[]>([])
@@ -29,9 +27,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
const [filtroEstatus, setFiltroEstatus] = useState('')
const [busquedaDebounced, setBusquedaDebounced] = useState('')
const sesion = getSesion()
const esAdmin = sesion?.usuario.rol === 'admin'
// Debounce búsqueda
useEffect(() => {
const timer = setTimeout(() => {
@@ -102,24 +97,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
}
}
const handleImportar = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.xlsx,.xls,.csv'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
toast.loading('Importando...', { id: 'importar' })
try {
// For CSV import we'd need a reader; for now show a message
toast.error('La importación se realiza desde el script del backend', { id: 'importar' })
} catch {
toast.error('Error al importar', { id: 'importar' })
}
}
input.click()
}
return (
<>
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
@@ -141,15 +118,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
</p>
)}
</div>
{esAdmin && (
<button
type="button"
onClick={handleImportar}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover"
>
📤 Importar
</button>
)}
</div>
{/* Filtros */}

View File

@@ -72,9 +72,24 @@ export interface EventoHistorialContacto {
fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App'
}
export interface DatosContacto {
nombre: string
telefono: string
email: string
estado: string
curp?: string
rfc?: string
estadoCivil?: string
fechaNacimiento?: string
actividadEconomica?: string
direccion?: string
/** Cualquier otro campo que el backend incluya */
[campo: string]: string | undefined
}
export interface DetalleContacto {
/** Columnas del board por título ('nombre' incluido); '' si vacía */
contacto: Record<string, string>
/** Campos nombrados del modelo local (antes: títulos de columna de Monday) */
contacto: DatosContacto
propiedad: {
id: string
unidadCodigo: string

View File

@@ -53,9 +53,9 @@ describe('DashboardVendedor', () => {
await waitFor(() => {
expect(screen.getByText('Ventas del mes')).toBeTruthy()
})
expect(screen.getByText('Actividades de hoy')).toBeTruthy()
expect(screen.getByText('Leads nuevos hoy')).toBeTruthy()
expect(screen.getByText('Leads totales')).toBeTruthy()
expect(screen.getByText('Funnel conversión')).toBeTruthy()
expect(screen.getByText('Funnel de conversión')).toBeTruthy()
expect(screen.getByText('Leads por día (últimos 30 días)')).toBeTruthy()
expect(screen.getByText('Funnel de conversión')).toBeTruthy()
expect(screen.getByText('Rendimiento por origen')).toBeTruthy()

View File

@@ -76,16 +76,14 @@ export default function DashboardVendedor() {
const mesAnterior = mesActual === 0 ? 11 : mesActual - 1
const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual
// leadsPorDia es el único histórico mensual del tablero (últimos 30 días);
// se agrupa por mes parseando 'YYYY-MM-DD' sin Date (evita corrimientos UTC)
let leadsMesActual = 0
let leadsMesAnterior = 0
for (const d of tablero.leadsPorDia) {
const fecha = new Date(d.fecha)
if (fecha.getMonth() === mesActual && fecha.getFullYear() === anioActual) {
leadsMesActual += d.cantidad
}
if (fecha.getMonth() === mesAnterior && fecha.getFullYear() === anioAnterior) {
leadsMesAnterior += d.cantidad
}
const [anio, mes] = d.fecha.split('-').map(Number)
if (anio === anioActual && mes === mesActual + 1) leadsMesActual += d.cantidad
if (anio === anioAnterior && mes === mesAnterior + 1) leadsMesAnterior += d.cantidad
}
const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
@@ -100,14 +98,16 @@ export default function DashboardVendedor() {
{
metrica: 'Oportunidades',
mesActual: tablero.conversion.oportunidades,
mesAnterior: Math.round(tablero.conversion.oportunidades * 0.8), // estimado
// Sin histórico mensual: no se estima el mes anterior
mesAnterior: null,
nombreMesActual: nombresMes[mesActual],
nombreMesAnterior: nombresMes[mesAnterior],
},
{
metrica: 'Ventas',
mesActual: tablero.ventasMes.unidades,
mesAnterior: Math.round(tablero.ventasMes.unidades * 0.85), // estimado
// Sin histórico mensual: no se estima el mes anterior
mesAnterior: null,
nombreMesActual: nombresMes[mesActual],
nombreMesAnterior: nombresMes[mesAnterior],
},
@@ -211,6 +211,9 @@ export default function DashboardVendedor() {
{/* F1: Comparativa mensual */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm lg:col-span-2">
<TituloSeccion>Comparativa mensual (mes actual vs anterior)</TituloSeccion>
<p className="-mt-2 mb-3 text-xs text-[#94A3B8]">
El mes anterior solo se compara en leads (único histórico disponible); oportunidades y ventas muestran el mes en curso.
</p>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />

View File

@@ -27,14 +27,23 @@ const CLASE_ACCION_RAPIDA =
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
{ id: 'info', titulo: 'Información del cliente potencial', icono: User },
{ id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar },
{ id: 'info', titulo: 'Información', icono: User },
{ id: 'cita', titulo: 'Cita', icono: Calendar },
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
{ id: 'notas', titulo: 'Notas', icono: FileText },
{ id: 'miembros', titulo: 'Objetos de los miembros', icono: Users },
{ id: 'miembros', titulo: 'Miembros', icono: Users },
]
export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; onCerrar: () => void }) {
export default function DetalleLeadModal({
item,
onCerrar,
onGuardarCita,
}: {
item: LeadItem
onCerrar: () => void
/** Guarda la cita de una oportunidad (fechaHora 'YYYY-MM-DDTHH:mm'); la página maneja el error */
onGuardarCita?: (fechaHora: string) => Promise<void> | void
}) {
const esOp = esOportunidad(item)
const esLd = esLead(item)
const vendedor = getSesion()?.usuario.nombre ?? ''
@@ -49,6 +58,23 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
const [busquedaNota, setBusquedaNota] = useState('')
// --- Tareas ---
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
// --- Cita ---
const [fechaCita, setFechaCita] = useState('')
const [guardandoCita, setGuardandoCita] = useState(false)
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
async function guardarCita(e: FormEvent) {
e.preventDefault()
if (!onGuardarCita || fechaCita === '') return
setGuardandoCita(true)
try {
await onGuardarCita(fechaCita)
} catch {
// el error ya se muestra en la página (toast/aviso del caller)
} finally {
setGuardandoCita(false)
}
}
const cargarNotas = useCallback(async () => {
setCargandoNotas(true)
@@ -102,8 +128,8 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{/* Header */}
<div className="absolute top-0 left-0 right-0 flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-3 bg-white dark:bg-[#1E293B] z-10">
<div>
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Editar "{item.nombre}"</h2>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Añadir y editar oportunidad Detalles, Actividades, notas y Cita.</p>
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Bitácora de {item.nombre}</h2>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Seguimiento del cliente: información, citas, tareas y notas</p>
</div>
<button type="button" onClick={onCerrar} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#334155]"><X className="h-5 w-5" /></button>
</div>
@@ -168,14 +194,37 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{tab === 'cita' && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Reserve o actualice una cita</h3>
<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"
/>
</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. Use los botones de fase en la lista para agendar una.</p>
<p className="text-sm text-[#94A3B8]">No hay cita programada. Las citas se agendan desde Mis oportunidades.</p>
)}
</div>
)}
@@ -267,7 +316,7 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{tab === 'miembros' && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Objetos de los miembros</h3>
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Miembros</h3>
<p className="text-sm text-[#94A3B8]">Información de los miembros del equipo asignados a este lead.</p>
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
<p className="text-xs text-[#94A3B8]">Vendedor: {(item as any).vendedor ?? '—'}</p>

View File

@@ -1,5 +1,4 @@
import { useState, useCallback } from 'react'
import ScoreBadge from './ScoreBadge'
/** Columna genérica del kanban */
interface ColumnaDef {
@@ -77,7 +76,6 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
<>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
{'score' in item && <ScoreBadge score={Number((item as Record<string, unknown>).score) || 0} />}
</div>
{item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>}
{item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>}

View File

@@ -1,69 +0,0 @@
/** Badge circular con score 0-100 para lead scoring. */
interface ScoreBadgeProps {
score: number
size?: 'sm' | 'md'
}
function colorForScore(score: number): string {
if (score > 70) return 'bg-green-500 text-white'
if (score > 40) return 'bg-yellow-400 text-yellow-900'
return 'bg-red-500 text-white'
}
/**
* Calcula el score de un lead de forma local usando:
* etapa (20-90) + recencia + teléfono + email + origen.
*
* Para oportunidades usa la `etapa`; para leads usa `estado`.
*/
export function calcularScore(lead: {
estado?: string
etapa?: string
telefono?: string
email?: string
origen?: string
updatedAt?: string
}): number {
const etapa = lead.etapa ?? lead.estado ?? ''
const normalizada = etapa.toUpperCase()
// Base por etapa: 20-90
let base = 20
if (normalizada.includes('AGENDO') || normalizada.includes('CITA')) base = 60
else if (normalizada.includes('INTERES')) base = 50
else if (normalizada.includes('CONTACTAD')) base = 40
else if (normalizada.includes('COTIZ')) base = 70
else if (normalizada.includes('APARTO') || normalizada.includes('APARTAD')) base = 90
else if (normalizada.includes('DESCART') || normalizada.includes('NO ASIST')) base = 5
// Recencia
let recencia = 0
if (lead.updatedAt) {
const diffMs = Date.now() - new Date(lead.updatedAt).getTime()
const diffHoras = diffMs / (1000 * 60 * 60)
if (diffHoras < 24) recencia = 10
else if (diffHoras < 168) recencia = 5
else if (diffHoras > 720) recencia = -20 // >30 días
}
// Señales extra
const tel = lead.telefono && lead.telefono.trim() !== '' ? 5 : 0
const em = lead.email && lead.email.trim() !== '' ? 5 : 0
const ori = lead.origen && lead.origen.trim() !== '' ? 5 : 0
return Math.max(0, Math.min(100, base + recencia + tel + em + ori))
}
export default function ScoreBadge({ score, size = 'sm' }: ScoreBadgeProps) {
const dim = size === 'sm' ? 'h-6 w-6 text-[10px]' : 'h-8 w-8 text-xs'
return (
<span
className={`inline-flex items-center justify-center rounded-full font-bold ${dim} ${colorForScore(score)}`}
title={`Score: ${score}`}
aria-label={`Score: ${score}`}
>
{score}
</span>
)
}

View File

@@ -112,50 +112,57 @@ export interface ResumenMiDia {
}
}
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades),
// vocabulario FRASA verificado y sembrado en Monday el 2026-09-04
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const
/**
* VOCABULARIO canónico FRASA: única fuente de verdad de fases, estados,
* etapas, motivos, subcategorías, orígenes y desarrollos. Las constantes
* individuales (FASES_OPORTUNIDAD, MOTIVOS_NO_COMPRA…) se derivan de aquí.
* Espejo del backend en api-gateway/src/config/vocabulario.ts (mantener
* sincronizados).
*/
export const VOCABULARIO = {
// Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del
// API + NUEVO/DESCARTADO)
FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
// Etapas del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
FASES_APARTADO: ['Pago Apartado', 'Alta Enkontrol', 'Entrego Documentacion', 'Cita Clientes', 'Contrato Enkontrol', 'Firma Cliente', 'Cancelacion Apartado'],
// Fases de escrituración (pipeline posterior a 'Firma Cliente')
FASES_ESCRITURACION: ['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'],
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA)
MOTIVOS_NO_COMPRA: ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'],
// Subcategorías por categoría (catálogo del tablero Motivos No Compra,
// sembrado en Frasa-CAS el 2026-09-04; 'Mesualidad' y 'Cambo de Planes'
// conservan las faltas de ortografía originales del tablero del cliente)
SUBCATEGORIAS: {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
},
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead'
// (Oportunidades), verificados y sembrados en Monday el 2026-09-04
ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'],
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'],
} as const
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
// Constantes derivadas (nombres estables que consume el resto de la app)
export const ORIGENES_LEAD = VOCABULARIO.ORIGENES
// Estados de la columna status del board Leads (vocabulario real del API:
// ESTADOS_LEAD_PERMITIDOS + NUEVO/DESCARTADO); alimenta el filtro por estado
export const ESTADOS_LEAD = [
'NUEVO',
'CONTACTADO',
'Intento de contacto',
'INTERESADO',
'AGENDA CITA',
'CALIFICADO',
'DESCARTADO',
] as const
export const DESARROLLOS_LEAD = VOCABULARIO.DESARROLLOS
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API)
export const MOTIVOS_NO_COMPRA = [
'Precio',
'Producto',
'Ubicacion',
'Financiamiento',
'Competencia',
'Cliente',
'Timing',
] as const
// Alimenta el filtro por estado de Mis leads
export const ESTADOS_LEAD = VOCABULARIO.FASES_LEAD
export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
// Subcategorías por categoría del tablero Motivos No Compra (catálogo del
// workspace de referencia CRM FRASA, sembrado en Frasa-CAS el 2026-09-04).
// Espejo de SUBCATEGORIAS_NO_COMPRA del API (mantener sincronizados).
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, string[]> = {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
}
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, readonly string[]> = VOCABULARIO.SUBCATEGORIAS
// LADAs soportadas en la captura de teléfonos (México default, USA opcional)
export const LADAS = [
@@ -193,20 +200,11 @@ export function urlWhatsApp(nombre: string, telefono: string, vendedor: string):
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
}
// Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const
// Fases del funnel de Oportunidades y del pipeline Enkontrol (del VOCABULARIO)
export const FASES_OPORTUNIDAD = VOCABULARIO.FASES_OPORTUNIDAD
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
export const FASES_APARTADO = [
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente',
'Cancelacion Apartado',
] as const
export const FASES_APARTADO = VOCABULARIO.FASES_APARTADO
export type FaseApartado = (typeof FASES_APARTADO)[number]
// Texto bonito de los botones (el valor que se manda al API es el label real)
@@ -220,17 +218,7 @@ export const ETIQUETA_FASE_APARTADO: Record<FaseApartado, string> = {
'Cancelacion Apartado': 'Cancelación',
}
export const FASES_ESCRITURACION = [
'Venta',
'Seguimiento',
'Integracion',
'X Autorizar',
'Autorizado',
'X Escriturar',
'Escriturado',
'Titulado',
'Cancelo',
] as const
export const FASES_ESCRITURACION = VOCABULARIO.FASES_ESCRITURACION
export type FaseEscrituracion = (typeof FASES_ESCRITURACION)[number]
export const ETIQUETA_FASE_ESCRITURACION: Record<FaseEscrituracion, string> = {

View File

@@ -51,7 +51,6 @@ import { exportarLeads, exportarOportunidades, exportarApartados } from '../serv
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
import { getSesion } from '../services/auth'
import DashboardVendedor from '../components/DashboardVendedor'
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
import KanbanView from '../components/KanbanView'
import CalendarioCitas from '../components/CalendarioCitas'
import DetalleLeadModal from '../components/DetalleLeadModal'
@@ -618,7 +617,6 @@ function FilaLead({
>
{lead.nombre}
</button>
<ScoreBadge score={calcularScore(lead)} />
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
<span
@@ -1677,6 +1675,17 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<DetalleLeadModal
item={leadSeleccionado}
onCerrar={() => setLeadSeleccionado(null)}
onGuardarCita={
'modeloInteres' in leadSeleccionado
? (fechaHora) => {
const op = leadSeleccionado
return ejecutar(
() => cambiarEtapaOportunidad(op.id, 'Agendo Cita', fechaHora),
'No se pudo guardar la cita',
)
}
: undefined
}
/>
)}
</>