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

@@ -23,7 +23,7 @@ export default function AuthGate({ children }: { children: (acciones: AccionesSe
}
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
{children({
onCambiarPassword: () => setModalPassword(true),
onLogout: () => {

View File

@@ -3,8 +3,8 @@ import { validarConfirmacion, validarPasswordNueva } from '../lib/password'
import { cambiarPassword } from '../services/auth'
const CLASE_INPUT =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]'
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/**
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
@@ -45,8 +45,8 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
aria-modal="true"
aria-label="Cambiar contraseña"
>
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Cambiar contraseña
</h2>
{exito ? (
@@ -127,7 +127,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
<button
type="button"
onClick={onCerrar}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-3 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Cancelar
</button>

View File

@@ -0,0 +1,32 @@
import { Sun, Moon } from "lucide-react"
import { useEffect, useState } from "react"
function getStoredTheme(): boolean {
if (typeof window === "undefined") return false
try {
const stored = localStorage.getItem("frasa_theme")
if (stored) return stored === "dark"
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false
} catch {
return false
}
}
export default function DarkModeToggle() {
const [dark, setDark] = useState(getStoredTheme)
useEffect(() => {
document.documentElement.classList.toggle("dark", dark)
try {
localStorage.setItem("frasa_theme", dark ? "dark" : "light")
} catch {
// localStorage not available (test env)
}
}, [dark])
return (
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
)
}

View File

@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import DashboardVendedor from './DashboardVendedor'
// Mock del servicio
vi.mock('../services/reporte-mi-tablero', () => ({
fetchMiTablero: vi.fn(),
}))
import { fetchMiTablero } from '../services/reporte-mi-tablero'
const fetchMiTableroMock = vi.mocked(fetchMiTablero)
const TABLERO_FAKE = {
generadoEn: '2026-09-07T12:00:00.000Z',
leadsPorDia: [
{ fecha: '2026-09-05', cantidad: 3 },
{ fecha: '2026-09-06', cantidad: 1 },
{ fecha: '2026-09-07', cantidad: 5 },
],
conversion: { leads: 20, oportunidades: 8, apartados: 3, vendidos: 1 },
ventasMes: { monto: 850000, unidades: 1 },
porOrigen: [
{ origen: 'Facebook', total: 12, convertidos: 4 },
{ origen: 'Referido', total: 8, convertidos: 2 },
],
porDesarrollo: [
{ desarrollo: 'DIAGONAL', ventas: 1 },
],
actividadesHoy: 2,
actividadesPendientes: 5,
}
describe('DashboardVendedor', () => {
it('renderiza skeleton de carga inicialmente', () => {
fetchMiTableroMock.mockReturnValue(new Promise(() => never()))
render(<DashboardVendedor />)
expect(screen.getByText('Cargando…')).toBeTruthy()
})
it('muestra error cuando falla la carga', async () => {
fetchMiTableroMock.mockRejectedValue(new Error('Error de red'))
render(<DashboardVendedor />)
await waitFor(() => {
expect(screen.getByText('Error de red')).toBeTruthy()
})
})
it('renderiza KPIs y gráficas cuando carga correctamente', async () => {
fetchMiTableroMock.mockResolvedValue(TABLERO_FAKE)
render(<DashboardVendedor />)
await waitFor(() => {
expect(screen.getByText('Ventas del mes')).toBeTruthy()
})
expect(screen.getByText('Actividades de hoy')).toBeTruthy()
expect(screen.getByText('Leads totales')).toBeTruthy()
expect(screen.getByText('Funnel conversión')).toBeTruthy()
expect(screen.getByText('Leads por día (últimos 30 días)')).toBeTruthy()
expect(screen.getByText('Funnel de conversión')).toBeTruthy()
expect(screen.getByText('Rendimiento por origen')).toBeTruthy()
expect(screen.getByText('Ranking de desarrollos')).toBeTruthy()
})
})
function never(): Promise<never> {
return new Promise(() => {})
}

View File

@@ -0,0 +1,220 @@
import { useCallback, useEffect, useState } from 'react'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
Cell,
} from 'recharts'
import { fetchMiTablero, type MiTablero } from '../services/reporte-mi-tablero'
import KpiCard from './KpiCard'
// ---------- Colores ----------
const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6']
const LIGHT_TEXT = '#64748B'
const GRID_LIGHT = '#E5E7EB'
// ---------- Sub-componentes ----------
function TituloSeccion({ children }: { children: string }) {
return (
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
{children}
</h3>
)
}
function SkeletonChart() {
return (
<div className="flex h-[280px] items-center justify-center rounded-xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-sm text-[#94A3B8]">Cargando…</p>
</div>
)
}
function FunnelBar({ label, value, total }: { label: string; value: number; total: number }) {
const pct = total > 0 ? (value / total) * 100 : 0
return (
<div className="flex items-center gap-3">
<span className="w-28 shrink-0 text-xs font-medium text-[#374151] dark:text-[#CBD5E1]">{label}</span>
<div className="relative h-6 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-[#334155]">
<div
className="absolute inset-y-0 left-0 rounded-full bg-accent transition-all duration-500"
style={{ width: `${pct}%` }}
/>
<span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-[#111827] dark:text-white">
{value}
</span>
</div>
<span className="w-10 text-right text-xs text-[#94A3B8]">{pct.toFixed(0)}%</span>
</div>
)
}
// ---------- Componente principal ----------
export default function DashboardVendedor() {
const [tablero, setTablero] = useState<MiTablero | null>(null)
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
const cargar = useCallback(async () => {
setCargando(true)
try {
setTablero(await fetchMiTablero())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar tu tablero')
} finally {
setCargando(false)
}
}, [])
useEffect(() => { void cargar() }, [cargar])
if (cargando && !tablero) return <SkeletonChart />
if (error) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900/50 dark:bg-red-950/30">
<p className="text-sm font-medium text-red-700 dark:text-red-400">{error}</p>
<button
type="button"
onClick={() => void cargar()}
className="mt-3 rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-accent-hover"
>
Reintentar
</button>
</div>
)
}
if (!tablero) return null
const totalFunnel = tablero.conversion.leads || 1
// Datos para gráfica de origen (solo top 6)
const datosOrigen = tablero.porOrigen.slice(0, 6)
// Datos para ranking de desarrollos
const datosDesarrollo = tablero.porDesarrollo.slice(0, 5)
return (
<div className="space-y-6">
{/* KPIs */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
titulo="Ventas del mes"
valor={`${tablero.ventasMes.unidades}`}
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN`}
/>
<KpiCard
titulo="Actividades de hoy"
valor={tablero.actividadesHoy}
detalle={`${tablero.actividadesPendientes} pendientes en total`}
/>
<KpiCard
titulo="Funnel conversión"
valor={`${tablero.conversion.vendidos}`}
detalle={`${tablero.conversion.leads} leads → ${tablero.conversion.oportunidades} ops → ${tablero.conversion.vendidos} vendidos`}
/>
<KpiCard
titulo="Leads totales"
valor={tablero.conversion.leads}
detalle={`${tablero.conversion.apartados} apartados`}
/>
</div>
{/* Gráficas */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Leads por día (barras) */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<TituloSeccion>Leads por día (últimos 30 días)</TituloSeccion>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={tablero.leadsPorDia} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_LIGHT} vertical={false} />
<XAxis
dataKey="fecha"
tickFormatter={(f: string) => f.slice(8)}
tick={{ fontSize: 10, fill: LIGHT_TEXT }}
interval="preserveStartEnd"
/>
<YAxis tick={{ fontSize: 10, fill: LIGHT_TEXT }} allowDecimals={false} />
<Tooltip
contentStyle={{ fontSize: 12, borderRadius: 8 }}
labelFormatter={(f: unknown) => `Fecha: ${String(f)}`}
formatter={(value: unknown) => [`${Number(value)} leads`, "Leads"]}
/>
<Bar dataKey="cantidad" fill="#3B82F6" radius={[4, 4, 0, 0]} maxBarSize={20} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Funnel de conversión */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<TituloSeccion>Funnel de conversión</TituloSeccion>
<div className="mt-2 space-y-3">
<FunnelBar label="Leads" value={tablero.conversion.leads} total={totalFunnel} />
<FunnelBar label="Oportunidades" value={tablero.conversion.oportunidades} total={totalFunnel} />
<FunnelBar label="Apartados" value={tablero.conversion.apartados} total={totalFunnel} />
<FunnelBar label="Vendidos" value={tablero.conversion.vendidos} total={totalFunnel} />
</div>
</div>
{/* Rendimiento por origen (barras horizontales) */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<TituloSeccion>Rendimiento por origen</TituloSeccion>
{datosOrigen.length === 0 ? (
<p className="mt-8 text-center text-sm text-[#94A3B8]">Sin datos de origen</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={datosOrigen} layout="vertical" margin={{ top: 8, right: 16, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_LIGHT} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: LIGHT_TEXT }} allowDecimals={false} />
<YAxis
type="category"
dataKey="origen"
tick={{ fontSize: 10, fill: LIGHT_TEXT }}
width={100}
/>
<Tooltip
contentStyle={{ fontSize: 12, borderRadius: 8 }}
formatter={(value: unknown, name: unknown) => [`${Number(value)}`, String(name) === "total" ? "Total" : "Convertidos"]}
/>
<Bar dataKey="total" fill="#3B82F6" radius={[0, 4, 4, 0]} maxBarSize={18} />
<Bar dataKey="convertidos" fill="#10B981" radius={[0, 4, 4, 0]} maxBarSize={18} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Ranking de desarrollos (barras horizontales) */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<TituloSeccion>Ranking de desarrollos</TituloSeccion>
{datosDesarrollo.length === 0 ? (
<p className="mt-8 text-center text-sm text-[#94A3B8]">Sin ventas registradas</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={datosDesarrollo} layout="vertical" margin={{ top: 8, right: 16, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_LIGHT} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: LIGHT_TEXT }} allowDecimals={false} />
<YAxis
type="category"
dataKey="desarrollo"
tick={{ fontSize: 10, fill: LIGHT_TEXT }}
width={90}
/>
<Tooltip
contentStyle={{ fontSize: 12, borderRadius: 8 }}
formatter={(value: unknown) => [`${Number(value)} ventas`, "Ventas"]}
/>
<Bar dataKey="ventas" fill="#8B5CF6" radius={[0, 4, 4, 0]} maxBarSize={18}>
{datosDesarrollo.map((_, idx) => (
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,151 @@
import { DESARROLLOS_LEAD, ORIGENES_LEAD } from '../lib/mi-dia'
/** Filtros de la barra avanzada (se aplican localmente sobre datos ya cargados) */
export interface FiltrosCRM {
texto: string
desarrollo: string
origen: string
fechaDesde: string
fechaHasta: string
}
const CLASE_SELECT =
'rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
interface Props {
valor: FiltrosCRM
onChange: (f: FiltrosCRM) => void
}
export function FiltrosAvanzados({ valor, onChange }: Props) {
const actualizar = (campo: keyof FiltrosCRM, v: string) =>
onChange({ ...valor, [campo]: v })
const hayActivos =
valor.texto !== '' || valor.desarrollo !== '' || valor.origen !== '' || valor.fechaDesde !== '' || valor.fechaHasta !== ''
return (
<div className="mb-4 flex flex-wrap items-end gap-3 rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
{/* Búsqueda por texto */}
<label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Buscar</span>
<input
type="text"
placeholder="Nombre, teléfono, email…"
value={valor.texto}
onChange={(e) => actualizar('texto', e.target.value)}
className="w-48 rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
</label>
{/* Desarrollo */}
<label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Desarrollo</span>
<select value={valor.desarrollo} onChange={(e) => actualizar('desarrollo', e.target.value)} className={CLASE_SELECT}>
<option value="">Todos</option>
{DESARROLLOS_LEAD.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</label>
{/* Origen */}
<label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Origen</span>
<select value={valor.origen} onChange={(e) => actualizar('origen', e.target.value)} className={CLASE_SELECT}>
<option value="">Todos</option>
{ORIGENES_LEAD.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
</label>
{/* Fecha desde */}
<label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Desde</span>
<input
type="date"
value={valor.fechaDesde}
onChange={(e) => actualizar('fechaDesde', e.target.value)}
className={CLASE_SELECT}
/>
</label>
{/* Fecha hasta */}
<label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Hasta</span>
<input
type="date"
value={valor.fechaHasta}
onChange={(e) => actualizar('fechaHasta', e.target.value)}
className={CLASE_SELECT}
/>
</label>
{/* Limpiar */}
{hayActivos && (
<button
type="button"
onClick={() => onChange(EMPTY)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] text-xs font-medium text-[#64748B] dark:text-[#94A3B8] shadow-sm transition-colors hover:bg-[#F9FAFB]"
>
Limpiar filtros
</button>
)}
</div>
)
}
const EMPTY: FiltrosCRM = { texto: '', desarrollo: '', origen: '', fechaDesde: '', fechaHasta: '' }
/**
* Filtra un array de leads/opportunidades/apartados según los filtros activos.
* Para desarrollo: oportunidades y apartados usan su campo propio; leads de
* origen oportunidad usan desarrollo de la oportunidad fuente (no disponible en
* LeadMiDia, se omite para esos items).
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function aplicarFiltros(
items: any[],
filtros: FiltrosCRM,
accessor: {
texto?: (item: any) => string
desarrollo?: (item: any) => string
origen?: (item: any) => string
fecha?: (item: any) => string
},
): typeof items {
return items.filter((item) => {
// Búsqueda por texto (case-insensitive)
if (filtros.texto !== '' && accessor.texto) {
const busqueda = filtros.texto.toLowerCase()
const valor = accessor.texto(item).toLowerCase()
if (!valor.includes(busqueda)) return false
}
// Desarrollo
if (filtros.desarrollo !== '' && accessor.desarrollo) {
if (accessor.desarrollo(item) !== filtros.desarrollo) return false
}
// Origen
if (filtros.origen !== '' && accessor.origen) {
if (accessor.origen(item) !== filtros.origen) return false
}
// Fecha desde
if (filtros.fechaDesde !== '' && accessor.fecha) {
const fecha = accessor.fecha(item)
if (fecha && fecha < filtros.fechaDesde) return false
}
// Fecha hasta
if (filtros.fechaHasta !== '' && accessor.fecha) {
const fecha = accessor.fecha(item)
if (fecha && fecha > filtros.fechaHasta) return false
}
return true
})
}

View File

@@ -2,10 +2,10 @@ import type { ReactNode } from 'react'
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-shadow duration-200 hover:shadow-md">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
<p className="mt-2 text-3xl font-semibold text-[#111827]">{valor}</p>
{detalle && <p className="mt-2 text-xs text-[#64748B]">{detalle}</p>}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<p className="mt-2 text-3xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
{detalle && <p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">{detalle}</p>}
</div>
)
}

View File

@@ -25,21 +25,21 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4">
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<p className="text-lg font-bold tracking-widest text-[#111827]">
<p className="text-lg font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">
FRASA<span aria-hidden="true" className="text-[#F97316]">.</span>
</p>
<p className="mt-2 text-xs font-medium uppercase tracking-wider text-[#64748B]">
<p className="mt-2 text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Acceso al ecosistema
</p>
</div>
<form
onSubmit={enviar}
className="space-y-6 rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm"
className="space-y-6 rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
>
<div>
<label
htmlFor="login-usuario"
className="mb-2 block text-xs font-medium text-[#4B5563]"
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Usuario
</label>
@@ -51,13 +51,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
autoComplete="username"
autoFocus
required
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
</div>
<div>
<label
htmlFor="login-password"
className="mb-2 block text-xs font-medium text-[#4B5563]"
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Contraseña
</label>
@@ -68,7 +68,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
</div>
{error && (

View File

@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { NotasApartado } from './NotasApartado'
import type { ApartadoMiDia } from '../lib/mi-dia'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
function crearStorageStub(): Storage {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => void mapa.set(k, v),
removeItem: (k: string) => void mapa.delete(k),
clear: () => mapa.clear(),
key: () => null,
get length() { return mapa.size },
} as Storage
}
const sesionFake = { token: 'jwt-prueba', usuario: { nombre: 'Test', rol: 'vendedor', asignacion: null } }
const apartadoFake: ApartadoMiDia = {
id: 'apt-1',
nombre: 'Juan Pérez',
unidadCodigo: 'D-101',
estado: 'En proceso',
fechaLimite: '2026-09-15',
ultimoCambio: null,
}
const notasFake = [
{ id: 'n1', apartadoId: 'apt-1', usuarioId: 'admin', contenido: 'Nota de prueba', createdAt: '2026-09-01T10:00:00Z' },
{ id: 'n2', apartadoId: 'apt-1', usuarioId: 'vendedor', contenido: 'Segunda nota', createdAt: '2026-09-02T12:00:00Z' },
]
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('localStorage', crearStorageStub())
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
fetchMock.mockResolvedValue(respuestaOk(notasFake))
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('NotasApartado', () => {
it('renderiza el modal con info del apartado', async () => {
render(<NotasApartado apartado={apartadoFake} onCerrar={() => {}} />)
expect(screen.getByText('Notas del apartado')).toBeTruthy()
expect(screen.getByText('Juan Pérez')).toBeTruthy()
expect(screen.getByText(/Unidad: D-101/)).toBeTruthy()
})
it('lista las notas al cargar', async () => {
render(<NotasApartado apartado={apartadoFake} onCerrar={() => {}} />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
expect(screen.getByText('Nota de prueba')).toBeTruthy()
expect(screen.getByText('Segunda nota')).toBeTruthy()
})
it('muestra error cuando falla el listado de notas', async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 500 }))
render(<NotasApartado apartado={apartadoFake} onCerrar={() => {}} />)
expect(await screen.findByText(/No se pudieron cargar las notas/)).toBeTruthy()
})
it('permite crear una nota', async () => {
const nuevaNota = { id: 'n3', apartadoId: 'apt-1', usuarioId: 'vendedor', contenido: 'Nueva nota', createdAt: '2026-09-03T14:00:00Z' }
// GET notas, POST nota, GET notas (reload)
fetchMock.mockResolvedValueOnce(respuestaOk(notasFake))
fetchMock.mockResolvedValueOnce(respuestaOk(nuevaNota))
fetchMock.mockResolvedValueOnce(respuestaOk([...notasFake, nuevaNota]))
render(<NotasApartado apartado={apartadoFake} onCerrar={() => {}} />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
fireEvent.change(screen.getByPlaceholderText(/Escribe una nota/), { target: { value: 'Nueva nota' } })
fireEvent.click(screen.getByText('Guardar nota'))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
expect(fetchMock.mock.calls[1]?.[0]).toContain('/apartados/apt-1/notas')
})
it('ejecuta onCerrar al hacer clic en Cerrar', () => {
const cerrar = vi.fn()
render(<NotasApartado apartado={apartadoFake} onCerrar={cerrar} />)
fireEvent.click(screen.getByText('Cerrar'))
expect(cerrar).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,126 @@
import { useCallback, useEffect, useState } from 'react'
import { listarNotas, crearNota, type NotaApartado } from '../services/notas'
import { formatoFechaCorta, type ApartadoMiDia } from '../lib/mi-dia'
const CLASE_INPUT =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
export function NotasApartado({
apartado,
onCerrar,
}: {
apartado: ApartadoMiDia
onCerrar: () => void
}) {
const [notas, setNotas] = useState<NotaApartado[]>([])
const [cargando, setCargando] = useState(true)
const [nuevaNota, setNuevaNota] = useState('')
const [guardando, setGuardando] = useState(false)
const [error, setError] = useState<string | null>(null)
const cargar = useCallback(async () => {
try {
setNotas(await listarNotas(apartado.id))
setError(null)
} catch {
setError('No se pudieron cargar las notas.')
} finally {
setCargando(false)
}
}, [apartado.id])
useEffect(() => {
void cargar()
}, [cargar])
async function guardar() {
const texto = nuevaNota.trim()
if (!texto) return
setGuardando(true)
try {
await crearNota(apartado.id, texto)
setNuevaNota('')
await cargar()
} catch {
setError('No se pudo guardar la nota.')
} finally {
setGuardando(false)
}
}
return (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl dark:border-[#1E293B] dark:bg-[#1E293B]">
{/* Encabezado */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Notas del apartado
</p>
<p className="mt-1 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">
{apartado.nombre}
</p>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">
{apartado.unidadCodigo ? `Unidad: ${apartado.unidadCodigo}` : ''}
{apartado.unidadCodigo && apartado.estado ? ' · ' : ''}
{apartado.estado || ''}
{apartado.fechaLimite !== '' ? ` · Límite: ${formatoFechaCorta(apartado.fechaLimite)}` : ''}
</p>
</div>
<button
type="button"
onClick={onCerrar}
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-[#64748B] transition-colors hover:bg-[#F9FAFB]"
>
Cerrar
</button>
</div>
{/* Lista de notas */}
<div className="mt-4 max-h-60 space-y-2 overflow-y-auto">
{cargando && <p className="text-xs text-[#94A3B8]">Cargando notas…</p>}
{!cargando && notas.length === 0 && (
<p className="py-4 text-center text-xs text-[#94A3B8]">Sin notas aún</p>
)}
{[...notas].reverse().map((n) => (
<div
key={n.id}
className="rounded-lg border border-[#E5E7EB] bg-[#F8FAFC] p-3 dark:border-[#334155] dark:bg-[#0F172A]"
>
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.contenido}</p>
<p className="mt-1 text-[11px] text-[#94A3B8]">
{n.usuarioId} · {formatoFechaCorta(n.createdAt.slice(0, 10))}
</p>
</div>
))}
</div>
{/* Error */}
{error && (
<p role="alert" className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
</p>
)}
{/* Input nueva nota */}
<div className="mt-4">
<textarea
value={nuevaNota}
onChange={(e) => setNuevaNota(e.target.value)}
rows={3}
placeholder="Escribe una nota…"
className={CLASE_INPUT}
/>
<button
type="button"
disabled={guardando || nuevaNota.trim() === ''}
onClick={() => void guardar()}
className="mt-2 w-full rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{guardando ? 'Guardando…' : 'Guardar nota'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
export default function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-lg bg-[#E2E8F0] dark:bg-[#334155] ${className}`} />
}
export function SkeletonCard() {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<Skeleton className="h-4 w-1/3 mb-3" />
<Skeleton className="h-3 w-2/3 mb-2" />
<Skeleton className="h-3 w-1/2" />
</div>
)
}
export function SkeletonList({ count = 5 }: { count?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
</div>
)
}

View File

@@ -1,3 +1,5 @@
@custom-variant dark (&:where(.dark, .dark *));
@import "tailwindcss";
@theme {

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Toaster } from 'react-hot-toast'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
@@ -13,5 +14,6 @@ createRoot(document.getElementById('root')!).render(
<AuthGate>
{(accionesSesion) => <MiDiaPage accionesSesion={accionesSesion} />}
</AuthGate>
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</StrictMode>,
)

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import KpiCard from '../components/KpiCard'
import BannerModoLocal from '../components/BannerModoLocal'
import DarkModeToggle from '../components/DarkModeToggle'
import { SkeletonList } from "../components/Skeleton"
import toast from "react-hot-toast"
import ChipPendienteSync from '../components/ChipPendienteSync'
import type { AccionesSesion } from '../components/AuthGate'
import {
@@ -42,27 +45,33 @@ import {
subirDocumentoApartado,
type DocumentoApartado,
} from '../services/mi-dia'
import { guardarResumen, cargarResumenLocal } from '../services/offline-db'
import { suscribirPush } from '../services/push'
import { exportarLeads, exportarOportunidades, exportarApartados } from '../services/exportar'
import { NotasApartado } from '../components/NotasApartado'
import { FiltrosAvanzados, aplicarFiltros, type FiltrosCRM } from '../components/FiltrosAvanzados'
import { getSesion } from '../services/auth'
import DashboardVendedor from '../components/DashboardVendedor'
function TituloSeccion({ children }: { children: string }) {
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{children}</h2>
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
}
function EstadoVacio({ mensaje }: { mensaje: string }) {
return (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white">
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#1E293B]">
<p className="text-xs font-semibold uppercase tracking-wider text-[#94A3B8]">{mensaje}</p>
</div>
)
}
const CLASE_BOTON_ACCION =
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50'
'rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50'
const CLASE_INPUT =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
function ModalDescartar({
@@ -82,9 +91,9 @@ function ModalDescartar({
const subcategorias = SUBCATEGORIAS_NO_COMPRA[motivo]
return (
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">Descartar lead</p>
<p className="mt-2 text-lg font-medium text-[#111827]">{lead.nombre}</p>
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Descartar lead</p>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{lead.nombre}</p>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
@@ -147,7 +156,7 @@ function ModalDescartar({
type="button"
onClick={onCancelar}
disabled={enviando}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
>
Cancelar
</button>
@@ -188,21 +197,21 @@ function FilaOportunidad({
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
const fases = ['Agendo Cita', 'Aparto'] as const
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
{op.nombre}
{op.pendienteSync === true && <ChipPendienteSync />}
</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{op.telefono || 'Sin teléfono'}
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
</p>
{op.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
</p>
@@ -212,7 +221,7 @@ function FilaOportunidad({
{op.etapa || 'Sin etapa'}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{/* Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
<button
type="button"
@@ -248,7 +257,7 @@ function FilaOportunidad({
aria-label="Fecha de la cita"
value={fechaCita}
onChange={(e) => setFechaCita(e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
)}
</span>
@@ -289,7 +298,7 @@ function FilaApartado({
}) {
const [docsAbiertos, setDocsAbiertos] = useState(false)
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
@@ -299,11 +308,11 @@ function FilaApartado({
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
) : null}
</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
</p>
{apartado.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {apartado.ultimoCambio.detalle} · {apartado.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
</p>
@@ -313,7 +322,7 @@ function FilaApartado({
{apartado.estado || 'Sin estado'}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{FASES_APARTADO.map((fase) => (
<button
key={fase}
@@ -345,7 +354,7 @@ function FilaApartado({
type="button"
onClick={() => setDocsAbiertos((v) => !v)}
aria-expanded={docsAbiertos}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{docsAbiertos ? 'Cerrar documentos' : 'Documentos'}
</button>
@@ -393,8 +402,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
}
return (
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Expediente del cliente
</p>
{error && (
@@ -408,7 +417,7 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
return (
<li key={slot.tipo}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium text-[#374151]">
<p className="text-xs font-medium text-[#374151] dark:text-[#CBD5E1]">
{slot.etiqueta}
{slot.obligatorio && <span className="ml-1 text-red-600">*</span>}
</p>
@@ -437,7 +446,7 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
) : (
<ul className="mt-1 space-y-0.5">
{delSlot.map((d) => (
<li key={d.id} className="text-[11px] text-[#64748B]">
<li key={d.id} className="text-[11px] text-[#64748B] dark:text-[#94A3B8]">
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
</li>
))}
@@ -454,8 +463,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
)
}
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados */
type TabCrm = 'leads' | 'oportunidades' | 'apartados'
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'tablero'
function TabsCrm({
activa,
@@ -470,9 +479,10 @@ function TabsCrm({
{ id: 'leads', titulo: 'Mis leads' },
{ id: 'oportunidades', titulo: 'Mis oportunidades' },
{ id: 'apartados', titulo: 'Mis apartados' },
{ id: 'tablero', titulo: 'Mi tablero' },
]
return (
<div className="mb-4 flex flex-wrap gap-1 rounded-xl border border-[#E5E7EB] bg-white p-1 shadow-sm" role="tablist">
<div className="mb-4 flex flex-wrap gap-1 rounded-xl border border-[#E5E7EB] bg-white p-1 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm md:flex-nowrap md:gap-2" role="tablist">
{tabs.map((tab) => {
const activo = tab.id === activa
return (
@@ -482,14 +492,16 @@ function TabsCrm({
role="tab"
aria-selected={activo}
onClick={() => onCambiar(tab.id)}
className={`rounded-lg px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 ${
activo ? 'bg-accent text-white' : 'text-[#64748B] hover:bg-[#F9FAFB] hover:text-accent'
className={`rounded-lg px-3 py-1.5 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 md:px-4 md:py-2 ${
activo ? 'bg-accent text-white' : 'text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] hover:text-accent'
}`}
>
{tab.titulo}
<span className={`ml-1.5 font-normal ${activo ? 'text-blue-100' : 'text-[#94A3B8]'}`}>
{conteos[tab.id]}
</span>
{conteos[tab.id] != null && (
<span className={`ml-1.5 font-normal ${activo ? 'text-blue-100' : 'text-[#94A3B8]'}`}>
{conteos[tab.id]}
</span>
)}
</button>
)
})}
@@ -515,7 +527,7 @@ function FilaLead({
acciones?: string[]
}) {
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
@@ -532,12 +544,12 @@ function FilaLead({
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
</span>
</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{lead.telefono || 'Sin teléfono'}
{lead.origen ? ` · ${lead.origen}` : ''}
</p>
{lead.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {lead.ultimoCambio.detalle} · {lead.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))}
</p>
@@ -547,7 +559,7 @@ function FilaLead({
{lead.estado || 'Sin estado'}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<button type="button" disabled={enviando} onClick={() => onAccion('CONTACTADO')} className={CLASE_BOTON_ACCION}>
Contactado
</button>
@@ -595,6 +607,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
} | null>(null)
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
const [nota, setNota] = useState<string | null>(null)
const [apartadoSeleccionado, setApartadoSeleccionado] = useState<ApartadoMiDia | null>(null)
const [filtros, setFiltros] = useState<FiltrosCRM>({ texto: '', desarrollo: '', origen: '', fechaDesde: '', fechaHasta: '' })
const [offline, setOffline] = useState(!navigator.onLine)
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
// Captura rápida
const [capNombre, setCapNombre] = useState('')
@@ -610,10 +626,22 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const cargar = useCallback(async () => {
setCargando(true)
try {
setResumen(await fetchResumenMiDia())
const datos = await fetchResumenMiDia()
setResumen(datos)
setError(null)
setOfflineFecha(null)
// Fire-and-forget: guardar en IDB para uso offline
void guardarResumen(datos)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar tu día')
// Si falla el fetch, intentar datos cacheados
const cached = await cargarResumenLocal()
if (cached) {
setResumen(cached)
setOfflineFecha(new Date().toLocaleDateString('es-MX'))
setError(null)
} else {
setError(err instanceof Error ? err.message : 'No se pudo cargar tu día')
}
} finally {
setCargando(false)
}
@@ -623,6 +651,29 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
void cargar()
}, [cargar])
// Detectar online/offline
useEffect(() => {
const onOnline = () => { setOffline(false); void cargar() }
const onOffline = () => setOffline(true)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [cargar])
// Suscribir a push notifications (fire-and-forget)
useEffect(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then((perm) => {
if (perm === 'granted') void suscribirPush()
})
} else if ('Notification' in window && Notification.permission === 'granted') {
void suscribirPush()
}
}, [])
/** Ejecuta una acción sobre un lead y refresca el resumen */
async function ejecutar(accion: () => Promise<unknown>, mensajeError: string) {
setEnviando(true)
@@ -631,7 +682,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
await accion()
await cargar()
} catch (err) {
setAviso(err instanceof Error ? err.message : mensajeError)
toast.error(mensajeError); setAviso(err instanceof Error ? err.message : mensajeError)
} finally {
setEnviando(false)
}
@@ -694,6 +745,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
? 'Lead registrado y asignado a ti'
: 'Lead registrado, se asignará por round-robin',
})
toast.success('Lead registrado correctamente')
setCapNombre('')
setCapTelefono('')
setCapEmail('')
@@ -719,12 +771,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
return (
<>
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<a
href="/"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-colors hover:bg-[#F9FAFB] hover:text-accent"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1] transition-colors hover:bg-[#F9FAFB] hover:text-accent"
>
← Menú
</a>
@@ -739,14 +791,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
>
{iniciales}
</span>
<span className="hidden truncate text-sm font-medium text-[#111827] sm:block">{nombreVendedor}</span>
<span className="hidden truncate text-sm font-medium text-[#111827] dark:text-[#E2E8F0] sm:block">{nombreVendedor}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
<DarkModeToggle />
<button
type="button"
onClick={accionesSesion.onCambiarPassword}
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-all duration-100 ease-out hover:text-[#111827]"
className="hidden rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0] md:inline-block"
>
Contraseña
</button>
@@ -761,7 +814,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
type="button"
onClick={() => void cargar()}
disabled={cargando}
className="rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50 sm:text-sm"
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50 md:px-4 md:py-2 md:text-sm"
>
{cargando ? 'Actualizando…' : 'Actualizar'}
</button>
@@ -771,10 +824,18 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<BannerModoLocal />
{offline && (
<div className="mx-auto max-w-[1280px] px-4 pt-4 sm:px-6">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Modo offline{offlineFecha ? ` — datos de ${offlineFecha}` : ''}
</div>
</div>
)}
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
<div className="mb-6">
<h1 className="text-xl font-semibold text-[#111827]">{nombreVendedor}</h1>
<p className="mt-1 text-sm text-[#64748B]">{formatoFechaCorta(hoy)}</p>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{nombreVendedor}</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">{formatoFechaCorta(hoy)}</p>
</div>
{error && (
@@ -793,16 +854,16 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</p>
)}
{resumen?.sinMapeoMonday && (
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] shadow-sm">
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] dark:text-[#94A3B8] shadow-sm">
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
</p>
)}
{!resumen && cargando && <EstadoVacio mensaje="Cargando…" />}
{!resumen && cargando && <SkeletonList count={4} />}
{resumen && (
<>
<section aria-label="Indicadores del día" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<section aria-label="Indicadores del día" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
<KpiCard
titulo="Actividades de hoy"
@@ -813,79 +874,161 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} />
</section>
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-3 lg:gap-6">
<div className="order-2 flex flex-col gap-6 lg:order-1 lg:col-span-2">
<div className="mt-6 flex flex-col gap-6 md:grid md:grid-cols-2 md:gap-6 lg:grid-cols-3">
<div className="order-2 flex flex-col gap-6 md:order-1 md:col-span-2 lg:col-span-2">
<section aria-label="CRM personal">
<TabsCrm
activa={tabCrm}
conteos={{
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
oportunidades: resumen.misOportunidades.items.length,
apartados: resumen.misApartados.length,
apartados: resumen.misApartados.length, tablero: 0,
}}
onCambiar={setTabCrm}
/>
{tabCrm === 'leads' &&
(resumen.misLeads.filter((l) => l.fuente === 'lead').length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
) : (
<ul className="space-y-2">
{resumen.misLeads
.filter((l) => l.fuente === 'lead')
.map((lead) => (
<FilaLead
key={`${lead.fuente}-${lead.id}`}
lead={lead}
{tabCrm !== 'tablero' && (
<FiltrosAvanzados valor={filtros} onChange={setFiltros} />
)}
{tabCrm === 'leads' && (() => {
const leadsFiltrados = aplicarFiltros(
resumen.misLeads.filter((l) => l.fuente === 'lead'),
filtros,
{
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
origen: (l) => l.origen,
fecha: (l) => l.updatedAt.slice(0, 10),
},
)
return (
<>
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => exportarLeads(leadsFiltrados)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Exportar
</button>
</div>
{leadsFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
) : (
<ul className="space-y-2">
{leadsFiltrados.map((lead) => (
<FilaLead
key={`${lead.fuente}-${lead.id}`}
lead={lead}
enviando={enviando}
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
onConvertir={() => void convertir(lead)}
onDescartar={() => setDescartando(lead)}
acciones={acciones}
/>
))}
</ul>
)}
</>
)
})()}
{tabCrm === 'oportunidades' && (() => {
const opsFiltradas = aplicarFiltros(
resumen.misOportunidades.items,
filtros,
{
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
desarrollo: (o) => o.desarrollo,
fecha: (o) => o.updatedAt.slice(0, 10),
},
)
return (
<>
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => exportarOportunidades(opsFiltradas)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Exportar
</button>
</div>
{opsFiltradas.length === 0 ? (
<EstadoVacio mensaje="Sin oportunidades asignadas" />
) : (
<ul className="space-y-2">
{opsFiltradas.map((op) => (
<FilaOportunidad
key={op.id}
op={op}
enviando={enviando}
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
onConvertir={() => void convertir(lead)}
onDescartar={() => setDescartando(lead)}
onEtapa={(etapa, fecha) =>
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
}
onCotizar={() => void cotizarOportunidad(op)}
onAfluencia={() => {
window.location.href = urlAfluenciaParaOportunidad(op)
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
acciones={acciones}
/>
))}
</ul>
))}
</ul>
)}
</>
)
})()}
{tabCrm === 'oportunidades' &&
(resumen.misOportunidades.items.length === 0 ? (
<EstadoVacio mensaje="Sin oportunidades asignadas" />
) : (
<ul className="space-y-2">
{resumen.misOportunidades.items.map((op) => (
<FilaOportunidad
key={op.id}
op={op}
enviando={enviando}
onEtapa={(etapa, fecha) =>
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
}
onCotizar={() => void cotizarOportunidad(op)}
onAfluencia={() => {
window.location.href = urlAfluenciaParaOportunidad(op)
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
acciones={acciones}
/>
))}
</ul>
))}
{tabCrm === 'apartados' && (() => {
const apartadosFiltrados = aplicarFiltros(
resumen.misApartados,
filtros,
{
texto: (a) => a.nombre,
fecha: (a) => a.fechaLimite,
},
)
return (
<>
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => exportarApartados(apartadosFiltrados)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Exportar
</button>
</div>
{apartadosFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin apartados asignados" />
) : (
<ul className="space-y-2">
{apartadosFiltrados.map((a) => (
<li
key={a.id}
className="cursor-pointer"
onClick={() => setApartadoSeleccionado(a)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setApartadoSeleccionado(a) }}
>
<FilaApartado
apartado={a}
enviando={enviando}
onFase={(fase) => void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')}
/>
</li>
))}
</ul>
)}
</>
)
})()}
{tabCrm === 'apartados' &&
(resumen.misApartados.length === 0 ? (
<EstadoVacio mensaje="Sin apartados asignados" />
) : (
<ul className="space-y-2">
{resumen.misApartados.map((a) => (
<FilaApartado
key={a.id}
apartado={a}
enviando={enviando}
onFase={(fase) => void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')}
/>
))}
</ul>
))}
{tabCrm === 'tablero' && (
<DashboardVendedor />
)}
</section>
<section aria-label="Mis capturas de afluencia">
@@ -904,7 +1047,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{c.nombre}
{c.pendienteSync === true && <ChipPendienteSync />}
</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{c.telefono || 'Sin teléfono'}
{c.origen ? ` · ${c.origen}` : ''}
</p>
@@ -913,7 +1056,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
{c.estado || '—'}
</span>
<span className="text-xs font-medium text-[#64748B]">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
@@ -936,7 +1079,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
>
<div>
<p className="text-sm font-medium">{c.nombre}</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
</p>
</div>
@@ -956,7 +1099,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
>
{c.completado ? 'Cliente' : 'En captura'}
</span>
<span className="text-xs font-medium text-[#64748B]">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
@@ -967,7 +1110,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</section>
</div>
<aside className="order-1 flex flex-col gap-6 lg:order-2 lg:sticky lg:top-20 lg:self-start">
<aside className="order-1 flex flex-col gap-6 md:order-2 lg:sticky lg:top-20 lg:self-start">
<section aria-label="Mis actividades">
<TituloSeccion>Mis actividades</TituloSeccion>
{resumen.misActividadesHoy.length === 0 ? (
@@ -985,7 +1128,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</p>
<span
className={`text-xs font-medium ${
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B]'
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B] dark:text-[#94A3B8]'
}`}
>
{formatoFechaCorta(a.fecha)}
@@ -1026,7 +1169,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<TituloSeccion>Captura rápida de lead</TituloSeccion>
<form
onSubmit={enviarCaptura}
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
>
<div>
<label htmlFor="cap-nombre" className={CLASE_LABEL}>
@@ -1050,7 +1193,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
aria-label="Lada"
value={capLada}
onChange={(e) => setCapLada(e.target.value as ClaveLada)}
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
>
{LADAS.map((l) => (
<option key={l.clave} value={l.clave}>
@@ -1156,7 +1299,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{esRolConEquipo(rol) && resumen.equipo && (
<section aria-label="Equipo" className="mt-6">
<TituloSeccion>Equipo</TituloSeccion>
<p className="mb-4 text-sm text-[#64748B]">
<p className="mb-4 text-sm text-[#64748B] dark:text-[#94A3B8]">
Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong>
</p>
{resumen.equipo.porVendedor.length === 0 ? (
@@ -1197,6 +1340,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
}}
/>
)}
{apartadoSeleccionado && (
<NotasApartado
apartado={apartadoSeleccionado}
onCerrar={() => setApartadoSeleccionado(null)}
/>
)}
</>
)
}

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')
}