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:
116
apps/mi-dia/src/services/exportar.test.ts
Normal file
116
apps/mi-dia/src/services/exportar.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
70
apps/mi-dia/src/services/exportar.ts
Normal file
70
apps/mi-dia/src/services/exportar.ts
Normal 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`)
|
||||
}
|
||||
37
apps/mi-dia/src/services/notas.ts
Normal file
37
apps/mi-dia/src/services/notas.ts
Normal 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
|
||||
}
|
||||
51
apps/mi-dia/src/services/offline-db.test.ts
Normal file
51
apps/mi-dia/src/services/offline-db.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
33
apps/mi-dia/src/services/offline-db.ts
Normal file
33
apps/mi-dia/src/services/offline-db.ts
Normal 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')
|
||||
}
|
||||
67
apps/mi-dia/src/services/push.ts
Normal file
67
apps/mi-dia/src/services/push.ts
Normal 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
|
||||
}
|
||||
}
|
||||
47
apps/mi-dia/src/services/reporte-mi-tablero.ts
Normal file
47
apps/mi-dia/src/services/reporte-mi-tablero.ts
Normal 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')
|
||||
}
|
||||
Reference in New Issue
Block a user