feat(mi-dia): app /hoy + CRM ligero para vendedores
- App Mi día: KPIs, mis actividades, mis leads con acciones rápidas (contactado/intento/agenda cita/descartar con motivo), captura rápida de lead (409 en duplicado por teléfono), vista equipo para coordinadores - API: GET /api/mi-dia/resumen, POST /api/leads, /leads/:id/estado, /leads/:id/descartar; mapeo Usuario.mondayUserId (seed listo para cuando inviten a los vendedores a Monday) - Landing: tarjeta Mi día; tests api 188, mi-dia 8, landing 4
This commit is contained in:
64
apps/mi-dia/src/lib/mi-dia.test.ts
Normal file
64
apps/mi-dia/src/lib/mi-dia.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
esActividadVencida,
|
||||
esRolConEquipo,
|
||||
formatoFechaCorta,
|
||||
hoyLocal,
|
||||
MOTIVOS_NO_COMPRA,
|
||||
ORIGENES_LEAD,
|
||||
textoDiasRestantes,
|
||||
} from './mi-dia'
|
||||
|
||||
describe('catálogos', () => {
|
||||
it('orígenes = labels reales de la columna Origen del board Leads', () => {
|
||||
expect([...ORIGENES_LEAD]).toEqual(['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'])
|
||||
})
|
||||
|
||||
it('motivos de descarte = vocabulario CRM (igual que el API)', () => {
|
||||
expect(MOTIVOS_NO_COMPRA).toContain('Precio')
|
||||
expect(MOTIVOS_NO_COMPRA).toContain('Timing')
|
||||
expect(MOTIVOS_NO_COMPRA).toHaveLength(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('esRolConEquipo', () => {
|
||||
it('coordinador, corporativo y admin ven equipo; vendedor no', () => {
|
||||
expect(esRolConEquipo('coordinador')).toBe(true)
|
||||
expect(esRolConEquipo('corporativo')).toBe(true)
|
||||
expect(esRolConEquipo('admin')).toBe(true)
|
||||
expect(esRolConEquipo('vendedor')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatoFechaCorta', () => {
|
||||
it('recorta el año', () => {
|
||||
expect(formatoFechaCorta('2026-08-29')).toBe('29/08')
|
||||
})
|
||||
|
||||
it('tolera cadenas mal formadas devolviéndolas tal cual', () => {
|
||||
expect(formatoFechaCorta('sin-fecha')).toBe('sin-fecha')
|
||||
})
|
||||
})
|
||||
|
||||
describe('textoDiasRestantes', () => {
|
||||
it('hoy, mañana y n días', () => {
|
||||
expect(textoDiasRestantes(0)).toBe('Vence hoy')
|
||||
expect(textoDiasRestantes(1)).toBe('Vence mañana')
|
||||
expect(textoDiasRestantes(5)).toBe('Vence en 5 días')
|
||||
})
|
||||
})
|
||||
|
||||
describe('esActividadVencida', () => {
|
||||
it('compara contra la fecha de hoy', () => {
|
||||
expect(esActividadVencida('2026-08-27', '2026-08-28')).toBe(true)
|
||||
expect(esActividadVencida('2026-08-28', '2026-08-28')).toBe(false)
|
||||
expect(esActividadVencida('2026-08-29', '2026-08-28')).toBe(false)
|
||||
expect(esActividadVencida('', '2026-08-28')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hoyLocal', () => {
|
||||
it('devuelve YYYY-MM-DD', () => {
|
||||
expect(hoyLocal()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
})
|
||||
96
apps/mi-dia/src/lib/mi-dia.ts
Normal file
96
apps/mi-dia/src/lib/mi-dia.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Tipos y transformaciones de la app "Mi día" (espejo del contrato del API)
|
||||
|
||||
export interface LeadMiDia {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
estado: string
|
||||
origen: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ActividadMiDia {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
fecha: string // 'YYYY-MM-DD'
|
||||
estado: string
|
||||
}
|
||||
|
||||
export interface ApartadoPorVencer {
|
||||
id: string
|
||||
nombre: string
|
||||
unidadCodigo: string
|
||||
fechaLimite: string // 'YYYY-MM-DD'
|
||||
estado: string
|
||||
diasRestantes: number
|
||||
}
|
||||
|
||||
export interface ResumenMiDia {
|
||||
generadoEn: string
|
||||
sinMapeoMonday: boolean
|
||||
misLeads: LeadMiDia[]
|
||||
misActividadesHoy: ActividadMiDia[]
|
||||
apartadosPorVencer: ApartadoPorVencer[]
|
||||
misOportunidades: { porEtapa: Record<string, number> } | null
|
||||
notaOportunidades: string | null
|
||||
conteos: {
|
||||
leadsNuevosSemana: number
|
||||
actividadesAbiertas: number
|
||||
}
|
||||
equipo?: {
|
||||
leadsNuevosSinAsignar: number
|
||||
porVendedor: { mondayUserId: number; nombre: string | null; leadsAsignados: number }[]
|
||||
}
|
||||
}
|
||||
|
||||
// Labels reales de la columna status 'Origen' del board Leads (verificados en Monday)
|
||||
export const ORIGENES_LEAD = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'] as const
|
||||
|
||||
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API)
|
||||
export const MOTIVOS_NO_COMPRA = [
|
||||
'Precio',
|
||||
'Producto',
|
||||
'Ubicacion',
|
||||
'Financiamiento',
|
||||
'Competencia',
|
||||
'Cliente',
|
||||
'Timing',
|
||||
] as const
|
||||
|
||||
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
|
||||
|
||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||
|
||||
/** ¿El rol ve la sección de equipo? (misma regla que el API) */
|
||||
export function esRolConEquipo(rol: string): boolean {
|
||||
return ROLES_CON_EQUIPO.includes(rol)
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' → 'dd/mm' (sin año: la agenda es de esta semana) */
|
||||
export function formatoFechaCorta(fecha: string): string {
|
||||
const [, m, d] = fecha.split('-')
|
||||
if (!m || !d) return fecha
|
||||
return `${d}/${m}`
|
||||
}
|
||||
|
||||
/** Texto de urgencia de un apartado según los días que faltan */
|
||||
export function textoDiasRestantes(dias: number): string {
|
||||
if (dias <= 0) return 'Vence hoy'
|
||||
if (dias === 1) return 'Vence mañana'
|
||||
return `Vence en ${dias} días`
|
||||
}
|
||||
|
||||
/** ¿La fecha de una actividad ya pasó? (vencida, para marcarla en la lista) */
|
||||
export function esActividadVencida(fecha: string, hoy: string): boolean {
|
||||
return fecha !== '' && fecha < hoy
|
||||
}
|
||||
|
||||
/** Fecha local 'YYYY-MM-DD' de hoy (para comparar contra las del API) */
|
||||
export function hoyLocal(): string {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${dd}`
|
||||
}
|
||||
Reference in New Issue
Block a user