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:
@@ -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: () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
32
apps/mi-dia/src/components/DarkModeToggle.tsx
Normal file
32
apps/mi-dia/src/components/DarkModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
apps/mi-dia/src/components/DashboardVendedor.test.tsx
Normal file
68
apps/mi-dia/src/components/DashboardVendedor.test.tsx
Normal 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(() => {})
|
||||
}
|
||||
220
apps/mi-dia/src/components/DashboardVendedor.tsx
Normal file
220
apps/mi-dia/src/components/DashboardVendedor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
151
apps/mi-dia/src/components/FiltrosAvanzados.tsx
Normal file
151
apps/mi-dia/src/components/FiltrosAvanzados.tsx
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
99
apps/mi-dia/src/components/NotasApartado.test.tsx
Normal file
99
apps/mi-dia/src/components/NotasApartado.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
126
apps/mi-dia/src/components/NotasApartado.tsx
Normal file
126
apps/mi-dia/src/components/NotasApartado.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
apps/mi-dia/src/components/Skeleton.tsx
Normal file
21
apps/mi-dia/src/components/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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