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,111 @@
import { prisma } from './prisma.js'
export interface BuscadoItem {
tipo: 'lead' | 'oportunidad' | 'contacto' | 'unidad'
id: string
nombre: string
detalle: string
url: string
}
/**
* Búsqueda global: LeadCaptura (leads), Cliente (contactos), Cotizacion
* (oportunidades con cotización), y Unidad (inventario).
* Case-insensitive sobre los campos indicados; limita a `limite` resultados.
*/
export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[]> {
const q = termino.trim()
if (q === '') return []
const pat = `%${q}%`
const [leads, clientes, cotizaciones, unidades] = await Promise.all([
prisma.leadCaptura.findMany({
where: {
OR: [
{ nombreLead: { contains: q } },
{ telefono: { contains: q } },
{ email: { contains: q } },
],
},
orderBy: { updatedAt: 'desc' },
take: limite,
}),
prisma.cliente.findMany({
where: {
OR: [
{ nombreCompleto: { contains: q } },
{ curp: { contains: q } },
{ rfc: { contains: q } },
{ email: { contains: q } },
{ celular: { contains: q } },
],
},
orderBy: { createdAt: 'desc' },
take: limite,
}),
prisma.cotizacion.findMany({
where: {
OR: [
{ clienteNombre: { contains: q } },
{ clienteEmail: { contains: q } },
{ unidadCodigo: { contains: q } },
],
},
orderBy: { createdAt: 'desc' },
take: limite,
}),
prisma.unidad.findMany({
where: {
OR: [
{ codigo: { contains: q } },
{ desarrollo: { contains: q } },
],
},
orderBy: { updatedAt: 'desc' },
take: limite,
}),
])
const items: BuscadoItem[] = [
...leads.map((l) => ({
tipo: 'lead' as const,
id: l.leadId,
nombre: l.nombreLead,
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
url: `/app/#leads`,
})),
...clientes.map((c) => ({
tipo: 'contacto' as const,
id: c.id,
nombre: c.nombreCompleto,
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
url: `/app/#clientes`,
})),
...cotizaciones.map((ct) => ({
tipo: 'oportunidad' as const,
id: ct.id,
nombre: ct.clienteNombre,
detalle: [ct.unidadCodigo, ct.desarrollo].filter(Boolean).join(' · ') || ct.tipoPropuesta,
url: `/quote/?cotizacion=${ct.id}`,
})),
...unidades.map((u) => ({
tipo: 'unidad' as const,
id: u.id,
nombre: u.codigo,
detalle: [u.desarrollo, u.modelo, u.status].filter(Boolean).join(' · '),
url: `/quote/?unidad=${u.codigo}`,
})),
]
// Mezclar y limitar: prioridad por tipo (leads primero, luego contactos,
// oportunidades, unidades) y recientes primero (ya ordenados por fecha desc)
const porTipo: BuscadoItem['tipo'][] = ['lead', 'contacto', 'oportunidad', 'unidad']
items.sort((a, b) => {
const ia = porTipo.indexOf(a.tipo)
const ib = porTipo.indexOf(b.tipo)
if (ia !== ib) return ia - ib
return 0
})
return items.slice(0, limite)
}