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

@@ -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