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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user