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:
2026-08-28 06:23:15 +00:00
parent 1f5b6ee088
commit bc0f9da8fe
31 changed files with 5917 additions and 3 deletions

View 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}$/)
})
})

View 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}`
}