feat(mejoras): 14 mejoras al CRM Frasa

Fase 1 - UI: dark mode, skeletons, toasts
Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K
Fase 3 - Datos: export Excel, notas apartados, auditoría
Fase 4 - Dashboard: gráficas vendedor + métricas uso
Fase 5 - Responsive: tablets optimizadas
Fase 6 - PWA: IndexedDB offline, push notifications

Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos
721 API tests + fronts verdes.
This commit is contained in:
2026-09-07 06:04:49 +00:00
parent 388e1da338
commit c77b4c0628
170 changed files with 5802 additions and 637 deletions

View File

@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: { findMany: vi.fn() },
cliente: { findMany: vi.fn() },
cotizacion: { findMany: vi.fn() },
unidad: { findMany: vi.fn() },
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import { buscar } from './buscar.service.js'
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.unidad.findMany.mockResolvedValue([])
})
describe('buscar (GET /api/buscar)', () => {
it('término vacío retorna [] sin consultar la base', async () => {
const r = await buscar('')
expect(r).toEqual([])
expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled()
})
it('término con solo espacios también retorna []', async () => {
const r = await buscar(' ')
expect(r).toEqual([])
expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled()
})
it('consulta los 4 modelos con el patrón contains', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l1', nombreLead: 'María López', telefono: '4771112233', email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() },
])
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'c1', nombreCompleto: 'María López', curp: '', rfc: '', email: 'maria@x.com', celular: '4771112233', proyecto: 'DIAGONAL', createdAt: new Date() },
])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.unidad.findMany.mockResolvedValue([])
const r = await buscar('María')
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledTimes(1)
expect(prismaMock.cliente.findMany).toHaveBeenCalledTimes(1)
expect(prismaMock.cotizacion.findMany).toHaveBeenCalledTimes(1)
expect(prismaMock.unidad.findMany).toHaveBeenCalledTimes(1)
expect(r.length).toBe(2)
expect(r[0]?.tipo).toBe('lead')
expect(r[1]?.tipo).toBe('contacto')
})
it('aplica take: limite por modelo', async () => {
await buscar('test', 5)
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 5 }),
)
})
it('prioriza leads sobre contactos, contactos sobre oportunidades, oportunidades sobre unidades', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() },
])
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() },
])
prismaMock.cotizacion.findMany.mockResolvedValue([
{ id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() },
])
prismaMock.unidad.findMany.mockResolvedValue([
{ id: 'u1', codigo: 'X', desarrollo: 'SENDA', modelo: null, status: null, updatedAt: new Date() },
])
const r = await buscar('X')
expect(r.map((i) => i.tipo)).toEqual(['lead', 'contacto', 'oportunidad', 'unidad'])
})
it('mezcla los resultados y limita al total indicado', async () => {
const leads = Array.from({ length: 10 }, (_, i) => ({
leadId: `l${i}`, nombreLead: `L${i}`, telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date(),
}))
const clientes = Array.from({ length: 10 }, (_, i) => ({
id: `c${i}`, nombreCompleto: `C${i}`, curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date(),
}))
prismaMock.leadCaptura.findMany.mockResolvedValue(leads)
prismaMock.cliente.findMany.mockResolvedValue(clientes)
const r = await buscar('x', 15)
expect(r).toHaveLength(15)
// Primeros 10 son leads (prioridad), siguientes 5 son contactos
expect(r.filter((i) => i.tipo === 'lead')).toHaveLength(10)
expect(r.filter((i) => i.tipo === 'contacto')).toHaveLength(5)
})
it('lead: detalle muestra teléfono · email, o origen como fallback', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l1', nombreLead: 'Ana', telefono: '4771112233', email: 'ana@x.com', origen: null, board: 'LEADS', updatedAt: new Date() },
])
const r = await buscar('Ana')
expect(r[0]?.detalle).toBe('4771112233 · ana@x.com')
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l2', nombreLead: 'Bob', telefono: null, email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() },
])
const r2 = await buscar('Bob')
expect(r2[0]?.detalle).toBe('Facebook')
})
it('contacto: detalle muestra email · celular · proyecto', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'c1', nombreCompleto: 'Luis', curp: '', rfc: '', email: 'luis@x.com', celular: '4772223333', proyecto: 'SENDA', createdAt: new Date() },
])
const r = await buscar('Luis')
expect(r[0]?.detalle).toBe('luis@x.com · 4772223333 · SENDA')
})
it('oportunidad: detalle muestra unidadCodigo · desarrollo, o tipoPropuesta como fallback', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([
{ id: 'ct1', clienteNombre: 'Carlos', clienteEmail: null, unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', tipoPropuesta: null, createdAt: new Date() },
])
const r = await buscar('Carlos')
expect(r[0]?.detalle).toBe('U-101 · DIAGONAL')
prismaMock.cotizacion.findMany.mockResolvedValue([
{ id: 'ct2', clienteNombre: 'Diana', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: 'CONTADO', createdAt: new Date() },
])
const r2 = await buscar('Diana')
expect(r2[0]?.detalle).toBe('CONTADO')
})
it('unidad: detalle muestra desarrollo · modelo · status', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
{ id: 'u1', codigo: 'U-202', desarrollo: 'ESTANZA', modelo: 'SOHO', status: 'Disponible', updatedAt: new Date() },
])
const r = await buscar('U-202')
expect(r[0]?.detalle).toBe('ESTANZA · SOHO · Disponible')
})
it('urls de navegación son correctas por tipo', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{ leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() },
])
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() },
])
prismaMock.cotizacion.findMany.mockResolvedValue([
{ id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() },
])
prismaMock.unidad.findMany.mockResolvedValue([
{ id: 'u1', codigo: 'X', desarrollo: null, modelo: null, status: null, updatedAt: new Date() },
])
const r = await buscar('X')
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads')
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes')
expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1')
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
})
})