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,116 @@
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
// Mock xlsx before importing the module under test
vi.mock('xlsx', () => {
const ws = {
'!cols': [],
}
const wb = {
SheetNames: [] as string[],
Sheets: {} as Record<string, unknown>,
}
return {
utils: {
book_new: () => ({ ...wb, SheetNames: [], Sheets: {} }),
json_to_sheet: (_data: unknown[]) => ({ ...ws }),
book_append_sheet: (workbook: typeof wb, worksheet: typeof ws, name: string) => {
workbook.SheetNames.push(name)
workbook.Sheets[name] = worksheet
},
},
write: (_workbook: unknown, _opts: unknown) => new ArrayBuffer(8),
}
})
import { exportarLeads, exportarOportunidades, exportarApartados } from './exportar'
const leadFake: LeadMiDia = {
id: '1',
nombre: 'Juan Pérez',
telefono: '6641234567',
email: 'juan@test.com',
estado: 'CONTACTADO',
origen: 'PASE',
updatedAt: '2026-09-01T10:00:00Z',
fuente: 'lead',
ultimoCambio: { fechaISO: '2026-09-01T10:00:00Z', usuario: 'Admin', detalle: 'Lead creado' },
}
const opFake: OportunidadMiDia = {
id: '2',
nombre: 'María López',
telefono: '6649876543',
email: 'maria@test.com',
etapa: 'Agendo Cita',
desarrollo: 'DIAGONAL',
modeloInteres: 'Modelo A',
unidadCodigo: 'D-101',
updatedAt: '2026-09-02T12:00:00Z',
ultimoCambio: { fechaISO: '2026-09-02T12:00:00Z', usuario: 'Admin', detalle: 'Etapa cambiada' },
}
const apartadoFake: ApartadoMiDia = {
id: '3',
nombre: 'Carlos Ruiz',
unidadCodigo: 'E-202',
estado: 'En proceso',
fechaLimite: '2026-09-15',
ultimoCambio: null,
}
let clickSpy: ReturnType<typeof vi.fn>
beforeEach(() => {
clickSpy = vi.fn()
// Mock document.createElement('a').click
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = originalCreateElement(tag)
if (tag === 'a') {
el.click = clickSpy
}
return el
})
// Stub URL.createObjectURL / revokeObjectURL (jsdom may not have them)
if (typeof URL.createObjectURL !== 'function') {
Object.defineProperty(URL, 'createObjectURL', { value: () => 'blob:mock', configurable: true })
} else {
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
}
if (typeof URL.revokeObjectURL !== 'function') {
Object.defineProperty(URL, 'revokeObjectURL', { value: () => {}, configurable: true })
} else {
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
}
})
describe('exportarLeads', () => {
it('genera buffer xlsx válido y dispara descarga', () => {
const leads = [leadFake]
exportarLeads(leads, 'test_leads.xlsx')
expect(clickSpy).toHaveBeenCalledOnce()
expect(document.createElement).toHaveBeenCalledWith('a')
})
it('usa nombre de archivo por defecto cuando no se proporciona', () => {
exportarLeads([leadFake])
expect(clickSpy).toHaveBeenCalledOnce()
})
})
describe('exportarOportunidades', () => {
it('genera buffer xlsx válido y dispara descarga', () => {
exportarOportunidades([opFake], 'test_ops.xlsx')
expect(clickSpy).toHaveBeenCalledOnce()
})
})
describe('exportarApartados', () => {
it('genera buffer xlsx válido y dispara descarga', () => {
exportarApartados([apartadoFake], 'test_aparts.xlsx')
expect(clickSpy).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,70 @@
import * as XLSX from 'xlsx'
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
import { formatoFechaCorta } from '../lib/mi-dia'
function hoyStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function descargar(wb: XLSX.WorkBook, nombre: string) {
const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = nombre
a.click()
URL.revokeObjectURL(url)
}
/** Exporta leads filtrados a .xlsx y dispara descarga automática. */
export function exportarLeads(leads: LeadMiDia[], nombreArchivo?: string) {
const wb = XLSX.utils.book_new()
const rows = leads.map((l) => ({
Nombre: l.nombre,
Teléfono: l.telefono,
Email: l.email,
Estado: l.estado,
Origen: l.origen,
Desarrollo: '',
'Último cambio': l.ultimoCambio ? `${l.ultimoCambio.detalle} · ${l.ultimoCambio.usuario ?? ''}` : '',
Fuente: l.fuente,
}))
const ws = XLSX.utils.json_to_sheet(rows)
XLSX.utils.book_append_sheet(wb, ws, 'Leads')
descargar(wb, nombreArchivo ?? `leads_${hoyStr()}.xlsx`)
}
/** Exporta oportunidades filtradas a .xlsx. */
export function exportarOportunidades(ops: OportunidadMiDia[], nombreArchivo?: string) {
const wb = XLSX.utils.book_new()
const rows = ops.map((o) => ({
Nombre: o.nombre,
Teléfono: o.telefono,
Email: o.email,
Estado: o.etapa || 'Sin etapa',
Origen: '',
Desarrollo: o.desarrollo,
'Último cambio': o.ultimoCambio ? `${o.ultimoCambio.detalle} · ${o.ultimoCambio.usuario ?? ''}` : '',
Fuente: '',
Etapa: o.etapa,
}))
const ws = XLSX.utils.json_to_sheet(rows)
XLSX.utils.book_append_sheet(wb, ws, 'Oportunidades')
descargar(wb, nombreArchivo ?? `oportunidades_${hoyStr()}.xlsx`)
}
/** Exporta apartados filtrados a .xlsx. */
export function exportarApartados(aparts: ApartadoMiDia[], nombreArchivo?: string) {
const wb = XLSX.utils.book_new()
const rows = aparts.map((a) => ({
Nombre: a.nombre,
Unidad: a.unidadCodigo || '—',
Estado: a.estado || 'Sin estado',
'Fecha límite': a.fechaLimite !== '' ? formatoFechaCorta(a.fechaLimite) : '—',
}))
const ws = XLSX.utils.json_to_sheet(rows)
XLSX.utils.book_append_sheet(wb, ws, 'Apartados')
descargar(wb, nombreArchivo ?? `apartados_${hoyStr()}.xlsx`)
}

View File

@@ -0,0 +1,37 @@
import { conAuth, manejar401 } from './auth'
const API_URL = import.meta.env.VITE_API_URL ?? '/api'
export interface NotaApartado {
id: string
apartadoId: string
usuarioId: string
contenido: string
createdAt: string // ISO
}
/** GET /api/apartados/:id/notas — lista de notas orden cronológico */
export async function listarNotas(id: string): Promise<NotaApartado[]> {
const res = await fetch(`${API_URL}/apartados/${id}/notas`, conAuth())
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
return (await res.json()) as NotaApartado[]
}
/** POST /api/apartados/:id/notas — crea una nota nueva */
export async function crearNota(id: string, contenido: string): Promise<NotaApartado> {
const res = await fetch(`${API_URL}/apartados/${id}/notas`, conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contenido }),
}))
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
return (await res.json()) as NotaApartado
}

View File

@@ -0,0 +1,51 @@
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach } from 'vitest'
// Mock idb using vi.hoisted so variables are available in vi.mock factory
const mockPut = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const mockGet = vi.hoisted(() => vi.fn().mockResolvedValue(null))
vi.mock('idb', () => ({
openDB: vi.fn().mockResolvedValue({
put: mockPut,
get: mockGet,
objectStoreNames: { contains: vi.fn().mockReturnValue(false) },
createObjectStore: vi.fn(),
}),
}))
import { guardarResumen, cargarResumenLocal, guardarUnidades, cargarUnidadesLocal } from './offline-db'
beforeEach(() => {
vi.clearAllMocks()
})
describe('offline-db mi-dia', () => {
it('guardarResumen guarda en store resumen con key actual', async () => {
const resumen = { misLeads: [], misOportunidades: { items: [] } }
await guardarResumen(resumen)
expect(mockPut).toHaveBeenCalledWith('resumen', resumen, 'actual')
})
it('cargarResumenLocal lee de store resumen con key actual', async () => {
const cached = { misLeads: [{ id: '1' }] }
mockGet.mockResolvedValueOnce(cached)
const result = await cargarResumenLocal()
expect(result).toEqual(cached)
expect(mockGet).toHaveBeenCalledWith('resumen', 'actual')
})
it('guardarUnidades guarda en store resumen con key unidades', async () => {
const unidades = [{ codigo: 'U-1' }, { codigo: 'U-2' }]
await guardarUnidades(unidades)
expect(mockPut).toHaveBeenCalledWith('resumen', unidades, 'unidades')
})
it('cargarUnidadesLocal lee de store resumen con key unidades', async () => {
const cached = [{ codigo: 'U-1' }]
mockGet.mockResolvedValueOnce(cached)
const result = await cargarUnidadesLocal()
expect(result).toEqual(cached)
expect(mockGet).toHaveBeenCalledWith('resumen', 'unidades')
})
})

View File

@@ -0,0 +1,33 @@
import { openDB, type IDBPDatabase } from 'idb'
const DB_NAME = 'frasa-midia'
const DB_VERSION = 1
async function getDB(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains('resumen')) db.createObjectStore('resumen')
if (!db.objectStoreNames.contains('unidad')) db.createObjectStore('unidad')
},
})
}
export async function guardarResumen(resumen: unknown) {
const db = await getDB()
await db.put('resumen', resumen, 'actual')
}
export async function cargarResumenLocal() {
const db = await getDB()
return db.get('resumen', 'actual')
}
export async function guardarUnidades(unidades: unknown[]) {
const db = await getDB()
await db.put('resumen', unidades, 'unidades')
}
export async function cargarUnidadesLocal() {
const db = await getDB()
return db.get('resumen', 'unidades')
}

View File

@@ -0,0 +1,67 @@
import { conAuth, manejar401 } from './auth'
const API_URL = import.meta.env.VITE_API_URL ?? '/api'
/** Convierte la VAPID public key (base64url) a Uint8Array para PushManager */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
/** Obtiene la VAPID public key del backend */
async function getVapidPublicKey(): Promise<string> {
const res = await fetch(`${API_URL}/push/vapid-key`)
if (!res.ok) throw new Error('No se pudo obtener la VAPID key')
const data = (await res.json()) as { publicKey: string }
return data.publicKey
}
/** Registra el service worker y suscribe a push */
export async function suscribirPush(): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false
try {
const registration = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const vapidKey = await getVapidPublicKey()
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
const sub = subscription.toJSON()
if (!sub.endpoint || !sub.keys) return false
const res = await fetch(`${API_URL}/push/suscribir`, conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: sub.endpoint,
p256dh: sub.keys.p256dh ?? '',
auth: sub.keys.auth ?? '',
}),
}))
manejar401(res)
return res.ok
} catch (err) {
console.warn('[push] No se pudo suscribir:', err)
return false
}
}
/** Elimina la suscripción push del backend */
export async function desuscribirPush(): Promise<void> {
try {
const res = await fetch(`${API_URL}/push/desuscribir`, conAuth({ method: 'POST' }))
manejar401(res)
} catch {
// Silencioso
}
}

View File

@@ -0,0 +1,47 @@
import { conAuth, manejar401 } from './auth'
const API_URL = import.meta.env.VITE_API_URL ?? '/api'
async function fetchJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}${path}`, conAuth(init))
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
return (await res.json()) as T
}
// ---------- Interfaces ----------
export interface MiTablero {
generadoEn: string
leadsPorDia: { fecha: string; cantidad: number }[]
conversion: {
leads: number
oportunidades: number
apartados: number
vendidos: number
}
ventasMes: {
monto: number
unidades: number
}
porOrigen: {
origen: string
total: number
convertidos: number
}[]
porDesarrollo: {
desarrollo: string
ventas: number
}[]
actividadesHoy: number
actividadesPendientes: number
}
// ---------- Fetch ----------
export function fetchMiTablero(): Promise<MiTablero> {
return fetchJson<MiTablero>('/reportes/mi-tablero')
}