feat: modulo WhatsApp (WA CRM) con round-robin de conversaciones

- Servicio wacrm en api-gateway (pg pool lazy): round-robin de conversaciones
  sin agente entre vendedores vinculados y miembro-agentes de la cuenta wacrm,
  cron cada 30 s; bandeja del vendedor; estado/config/reasignar para admin.
- Endpoints /api/wacrm/* con guarda de rol admin/manager.
- Usuario.wacrmUserId en schema Prisma (vinculo CRM<->wacrm).
- App apps/wa-crm (/wa-crm/): bandeja + administracion del round-robin.
- Landing: tarjeta WhatsApp; clave 'whatsapp' en MATRIZ de roles.
- Plan de nuevas reglas de negocio del cotizador (docs/PLAN-REGLAS-COTIZADOR.md).
- WACRM_PG_URL apunta a Postgres de Supabase por 127.0.0.1:5433.
- Tests de integracion wacrm (3) y suite completa 764/764 en verde.
This commit is contained in:
2026-09-30 00:30:17 +00:00
parent cb1c36b669
commit e3b5660769
28 changed files with 5744 additions and 1 deletions

13
apps/wa-crm/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B4332" />
<title>CRM360 FRASA — WhatsApp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3883
apps/wa-crm/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
apps/wa-crm/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "wa-crm",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.3.0",
"lucide-react": "^1.41.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.3",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

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 no disponible (entorno de test)
}
}, [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,27 @@
import type { ReactNode } from 'react'
/**
* Pantalla cuando no hay sesión: se indica que el login vive en la landing
* principal (misma sesión compartida vía localStorage 'frasa_auth').
*/
export default function SinSesion({ children }: { children?: ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4 dark:bg-[#0F172A]">
<div className="w-full max-w-md rounded-xl border border-[#E5E7EB] bg-white p-8 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<h1 className="text-xl font-bold text-[#111827] dark:text-[#E2E8F0]">
Inicia sesión desde la página principal
</h1>
<p className="mt-3 text-base text-[#64748B] dark:text-[#94A3B8]">
Usa el mismo usuario y contraseña del CRM para entrar a tu bandeja de WhatsApp.
</p>
<a
href="/"
className="mt-6 inline-block rounded-lg bg-accent px-6 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover"
>
Ir a la página principal
</a>
{children}
</div>
</div>
)
}

20
apps/wa-crm/src/index.css Normal file
View File

@@ -0,0 +1,20 @@
@custom-variant dark (&:where(.dark, .dark *));
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-accent: #2563EB;
--color-accent-hover: #1D4ED8;
}
@layer base {
body {
@apply bg-[#F8FAFC] text-[#111827] antialiased;
}
*:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.35);
}
}

16
apps/wa-crm/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
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'
import '@fontsource/inter/700.css'
import './index.css'
import WaCrmPage from './pages/WaCrmPage'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<WaCrmPage />
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</StrictMode>,
)

View File

@@ -0,0 +1,525 @@
import { useCallback, useEffect, useState } from 'react'
import { RefreshCw, ExternalLink, MessageSquare, Send, Users } from 'lucide-react'
import toast from 'react-hot-toast'
import SinSesion from '../components/SinSesion'
import DarkModeToggle from '../components/DarkModeToggle'
import { getSesion, logout, type Sesion } from '../services/auth'
import {
dispararRoundRobin,
guardarConfigWacrm,
obtenerEstadoWacrm,
obtenerMiBandeja,
reasignarConversacion,
type EstadoWacrm,
type MiBandeja,
} from '../services/wacrm'
function esAdmin(sesion: Sesion): boolean {
return sesion.usuario.rol === 'admin' || sesion.usuario.rol === 'manager'
}
/** Fecha relativa en español corto: "hace 5 min", "ayer 14:30", "12/sep". */
export function fechaRelativa(iso: string | null): string {
if (!iso) return ''
const fecha = new Date(iso)
if (Number.isNaN(fecha.getTime())) return ''
const ahora = Date.now()
const diffMs = ahora - fecha.getTime()
const min = Math.floor(diffMs / 60000)
if (min < 1) return 'ahora'
if (min < 60) return `hace ${min} min`
const horas = Math.floor(min / 60)
if (horas < 24) return `hace ${horas} h`
const dias = Math.floor(horas / 24)
if (dias === 1) return 'ayer'
if (dias < 7) return `hace ${dias} días`
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })
}
type Tab = 'bandeja' | 'admin'
export default function WaCrmPage() {
const [sesion] = useState<Sesion | null>(() => getSesion())
const [tab, setTab] = useState<Tab>('bandeja')
if (!sesion) {
return <SinSesion />
}
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
<div className="border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-2 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<MessageSquare className="h-6 w-6 shrink-0 text-accent" />
<div className="min-w-0">
<h1 className="truncate text-lg font-bold">WhatsApp</h1>
<p className="truncate text-xs text-[#64748B] dark:text-[#94A3B8]">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<DarkModeToggle />
<button
type="button"
onClick={() => {
logout()
window.location.reload()
}}
className="rounded-md px-3 py-2 text-sm font-medium text-accent transition-colors hover:text-accent-hover"
>
Salir
</button>
</div>
</div>
<div className="mx-auto flex max-w-5xl gap-1 px-4 pb-0">
<TabBoton activo={tab === 'bandeja'} onClick={() => setTab('bandeja')}>
Mi bandeja
</TabBoton>
{esAdmin(sesion) && (
<TabBoton activo={tab === 'admin'} onClick={() => setTab('admin')}>
Administración
</TabBoton>
)}
</div>
</div>
<main className="mx-auto max-w-5xl px-4 py-6">
{tab === 'bandeja' ? <VistaBandeja /> : <VistaAdmin />}
</main>
</div>
)
}
function TabBoton({
activo,
onClick,
children,
}: {
activo: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-t-lg border-b-2 px-4 py-2.5 text-base font-semibold transition-colors ${
activo
? 'border-accent text-accent'
: 'border-transparent text-[#64748B] hover:text-[#111827] dark:text-[#94A3B8] dark:hover:text-[#E2E8F0]'
}`}
>
{children}
</button>
)
}
// ---------------------------------------------------------------------------
// Vista: Mi bandeja (todos los roles)
// ---------------------------------------------------------------------------
function VistaBandeja() {
const [bandeja, setBandeja] = useState<MiBandeja | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null)
const cargar = useCallback(async () => {
setCargando(true)
setError(null)
try {
setBandeja(await obtenerMiBandeja())
} catch (e) {
setError(e instanceof Error ? e.message : 'No se pudo cargar la bandeja')
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
return (
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">Mis conversaciones</h2>
<button
type="button"
onClick={() => void cargar()}
disabled={cargando}
className="inline-flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-white px-4 py-2.5 text-base font-semibold text-[#111827] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#E2E8F0] dark:hover:bg-[#334155]"
>
<RefreshCw className={`h-4 w-4 ${cargando ? 'animate-spin' : ''}`} />
Actualizar
</button>
</div>
{cargando && bandeja === null && (
<p className="py-8 text-center text-base text-[#64748B] dark:text-[#94A3B8]">
Cargando tu bandeja…
</p>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-base text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
)}
{!cargando && bandeja && !bandeja.vinculado && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-5 text-base text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<p className="font-semibold">Tu usuario no está vinculado a WA CRM.</p>
<p className="mt-1">
Para ver tus conversaciones de WhatsApp aquí, un administrador debe vincular tu
usuario del CRM con tu usuario de WA CRM. Avísale a administración.
</p>
</div>
)}
{!cargando && bandeja && bandeja.vinculado && bandeja.conversaciones.length === 0 && (
<p className="rounded-lg border border-[#E5E7EB] bg-white p-6 text-center text-base text-[#64748B] dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#94A3B8]">
No tienes conversaciones asignadas por ahora.
</p>
)}
{bandeja && bandeja.vinculado && bandeja.conversaciones.length > 0 && (
<ul className="space-y-3">
{bandeja.conversaciones.map((conv) => (
<li
key={conv.id}
className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-lg font-semibold">
{conv.contacto.nombre || conv.contacto.telefono}
</p>
<p className="truncate text-sm text-[#64748B] dark:text-[#94A3B8]">
{conv.contacto.telefono}
</p>
</div>
{conv.unreadCount > 0 && (
<span className="rounded-full bg-accent px-2.5 py-0.5 text-sm font-bold text-white">
{conv.unreadCount}
</span>
)}
</div>
{conv.lastMessage && (
<p className="mt-2 line-clamp-2 text-base text-[#374151] dark:text-[#CBD5E1]">
{conv.lastMessage}
</p>
)}
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">
{conv.status} · {fechaRelativa(conv.lastMessageAt)}
</span>
<a
href="/wa/inbox"
className="inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-base font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Send className="h-4 w-4" />
Abrir en WA CRM
</a>
</div>
</li>
))}
</ul>
)}
</section>
)
}
// ---------------------------------------------------------------------------
// Vista: Administración (admin/manager)
// ---------------------------------------------------------------------------
function VistaAdmin() {
const [estado, setEstado] = useState<EstadoWacrm | null>(null)
const [sinPermiso, setSinPermiso] = useState(false)
const [error, setError] = useState<string | null>(null)
const [cuentaId, setCuentaId] = useState('')
const [guardandoCuenta, setGuardandoCuenta] = useState(false)
const [cambiandoToggle, setCambiandoToggle] = useState(false)
const [asignando, setAsignando] = useState(false)
const [convReasignar, setConvReasignar] = useState('')
const [vendedorReasignar, setVendedorReasignar] = useState('')
const [reasignando, setReasignando] = useState(false)
const cargar = useCallback(async () => {
setError(null)
try {
const data = await obtenerEstadoWacrm()
setEstado(data)
setCuentaId(data.cuentaId ?? '')
} catch (e) {
if (e instanceof Error && e.message.includes('403')) {
setSinPermiso(true)
} else {
setError(e instanceof Error ? e.message : 'No se pudo cargar el estado')
}
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
if (sinPermiso) {
return (
<p className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-base text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
Tu rol no tiene permisos de administración de WA CRM.
</p>
)
}
async function toggleRoundRobin(activo: boolean) {
setCambiandoToggle(true)
try {
const res = await guardarConfigWacrm({ activo })
setEstado((prev) => (prev ? { ...prev, roundRobinActivo: res.activo } : prev))
toast.success(activo ? 'Round-robin activado' : 'Round-robin desactivado')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar')
} finally {
setCambiandoToggle(false)
}
}
async function guardarCuenta() {
const valor = cuentaId.trim()
if (!valor) {
toast.error('Escribe el ID de la cuenta (UUID de wacrm)')
return
}
setGuardandoCuenta(true)
try {
const res = await guardarConfigWacrm({ cuentaId: valor })
setEstado((prev) => (prev ? { ...prev, cuentaId: res.cuentaId } : prev))
toast.success('Cuenta guardada')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar la cuenta')
} finally {
setGuardandoCuenta(false)
}
}
async function asignarAhora() {
setAsignando(true)
try {
const res = await dispararRoundRobin()
toast.success(`Asignadas ${res.asignados} conversaciones`)
await cargar()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo ejecutar la asignación')
} finally {
setAsignando(false)
}
}
async function reasignar() {
const conv = convReasignar.trim()
if (!conv || !vendedorReasignar) {
toast.error('Escribe el ID de la conversación y elige un vendedor')
return
}
setReasignando(true)
try {
await reasignarConversacion(conv, vendedorReasignar)
toast.success('Conversación reasignada')
setConvReasignar('')
setVendedorReasignar('')
await cargar()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo reasignar')
} finally {
setReasignando(false)
}
}
if (error) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-base text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
)
}
if (!estado) {
return (
<p className="py-8 text-center text-base text-[#64748B] dark:text-[#94A3B8]">
Cargando estado…
</p>
)
}
return (
<section className="space-y-6">
{/* Resumen */}
<div className="grid gap-3 sm:grid-cols-3">
<TarjetaResumen titulo="Sin asignar" valor={estado.sinAsignar} />
<TarjetaResumen titulo="Total conversaciones" valor={estado.totalConversaciones} />
<TarjetaResumen
titulo="Round-robin"
valor={estado.roundRobinActivo ? 'Activo' : 'Inactivo'}
/>
</div>
{/* Configuración */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]">
<h3 className="flex items-center gap-2 text-lg font-bold">
<Users className="h-5 w-5 text-accent" />
Configuración
</h3>
<div className="mt-4 flex items-center justify-between gap-3">
<div>
<p className="text-base font-semibold">Round-robin automático</p>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Asigna las conversaciones nuevas entre los vendedores de la cuenta.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={estado.roundRobinActivo}
disabled={cambiandoToggle}
onClick={() => void toggleRoundRobin(!estado.roundRobinActivo)}
className={`relative h-8 w-14 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
estado.roundRobinActivo ? 'bg-accent' : 'bg-[#CBD5E1] dark:bg-[#475569]'
}`}
>
<span
className={`absolute top-1 h-6 w-6 rounded-full bg-white shadow transition-all ${
estado.roundRobinActivo ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
<div className="mt-5">
<label htmlFor="cuenta-id" className="text-base font-semibold">
Cuenta de wacrm (ID / UUID)
</label>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Es el identificador de la cuenta de WA CRM donde viven las conversaciones.
</p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<input
id="cuenta-id"
type="text"
value={cuentaId}
onChange={(e) => setCuentaId(e.target.value)}
placeholder="p. ej. 3f2b8c1a-…"
className="w-full flex-1 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-base dark:border-[#475569] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
/>
<button
type="button"
onClick={() => void guardarCuenta()}
disabled={guardandoCuenta}
className="rounded-lg bg-accent px-5 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{guardandoCuenta ? 'Guardando…' : 'Guardar cuenta'}
</button>
</div>
</div>
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Asigna ahora las conversaciones sin vendedor según el round-robin.
</p>
<button
type="button"
onClick={() => void asignarAhora()}
disabled={asignando}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-accent px-5 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
<ExternalLink className="h-4 w-4" />
{asignando ? 'Asignando…' : 'Asignar ahora'}
</button>
</div>
</div>
{/* Vendedores */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]">
<h3 className="text-lg font-bold">Vendedores</h3>
<div className="mt-3 overflow-x-auto">
<table className="w-full min-w-[480px] text-left text-base">
<thead>
<tr className="border-b border-[#E5E7EB] text-sm text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]">
<th className="px-2 py-2 font-semibold">Nombre</th>
<th className="px-2 py-2 font-semibold">En la cuenta</th>
<th className="px-2 py-2 font-semibold">Asignadas</th>
</tr>
</thead>
<tbody>
{estado.vendedores.map((v) => (
<tr
key={v.id}
className="border-b border-[#F1F5F9] last:border-0 dark:border-[#0F172A]"
>
<td className="px-2 py-2.5">{v.nombre}</td>
<td className="px-2 py-2.5">
{v.esMiembro ? (
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-sm font-semibold text-green-700 dark:bg-green-900/50 dark:text-green-300">
Sí
</span>
) : (
<span className="rounded-full bg-[#F1F5F9] px-2.5 py-0.5 text-sm font-semibold text-[#64748B] dark:bg-[#334155] dark:text-[#94A3B8]">
No
</span>
)}
</td>
<td className="px-2 py-2.5">{v.asignadas}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Reasignar */}
<div className="mt-5 rounded-lg border border-[#E5E7EB] p-4 dark:border-[#334155]">
<p className="text-base font-semibold">Reasignar conversación</p>
<div className="mt-2 grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
<input
type="text"
value={convReasignar}
onChange={(e) => setConvReasignar(e.target.value)}
placeholder="ID de la conversación"
className="rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-base dark:border-[#475569] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
/>
<select
value={vendedorReasignar}
onChange={(e) => setVendedorReasignar(e.target.value)}
className="rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-base dark:border-[#475569] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Elige vendedor…</option>
{estado.vendedores.map((v) => (
<option key={v.id} value={v.id}>
{v.nombre}
</option>
))}
</select>
<button
type="button"
onClick={() => void reasignar()}
disabled={reasignando}
className="rounded-lg bg-accent px-5 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{reasignando ? 'Reasignando…' : 'Reasignar'}
</button>
</div>
</div>
</div>
</section>
)
}
function TarjetaResumen({ titulo, valor }: { titulo: string; valor: number | string }) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<p className="mt-1 text-2xl font-bold">{valor}</p>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { conAuth, getSesion, getToken, logout, manejar401 } from './auth'
/** Stub sencillo de localStorage para correr en entorno node */
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 sesionOk = {
token: 'jwt-de-prueba',
usuario: { nombre: 'MARIA LOPEZ', rol: 'vendedor', asignacion: 'MONTEBELLO' },
}
beforeEach(() => {
vi.stubGlobal('localStorage', crearStorageStub())
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('almacenamiento de sesión', () => {
it('sin sesión: getSesion/getToken devuelven null', () => {
expect(getSesion()).toBeNull()
expect(getToken()).toBeNull()
})
it('JSON corrupto en storage se trata como "sin sesión"', () => {
localStorage.setItem('frasa_auth', '{basura')
expect(getSesion()).toBeNull()
})
it('sesión válida: devuelve token y usuario', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
expect(getToken()).toBe('jwt-de-prueba')
expect(getSesion()?.usuario.rol).toBe('vendedor')
})
it('logout limpia la sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
logout()
expect(getSesion()).toBeNull()
})
})
describe('conAuth', () => {
it('sin token devuelve el init tal cual', () => {
const init = { method: 'POST', headers: { 'Content-Type': 'application/json' } }
expect(conAuth(init)).toBe(init)
expect(conAuth()).toEqual({})
})
it('con token agrega Authorization: Bearer conservando headers', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
const resultado = conAuth({ headers: { 'Content-Type': 'application/json' } })
expect(resultado.headers).toEqual({
'Content-Type': 'application/json',
Authorization: 'Bearer jwt-de-prueba',
})
})
})
describe('manejar401', () => {
it('con 401 limpia la sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
manejar401(new Response('', { status: 401 }))
expect(getSesion()).toBeNull()
})
it('con otro status no toca la sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
manejar401(new Response('', { status: 403 }))
expect(getSesion()).not.toBeNull()
})
})

View File

@@ -0,0 +1,52 @@
// Sesión de autenticación contra el API Gateway (JWT guardado en localStorage)
// (la URL base del API la usa services/wacrm.ts vía VITE_API_URL)
const STORAGE_KEY = 'frasa_auth'
export interface UsuarioSesion {
nombre: string
rol: string
asignacion: string | null
acciones?: string[]
}
export interface Sesion {
token: string
usuario: UsuarioSesion
}
export function getSesion(): Sesion | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const sesion = JSON.parse(raw) as Sesion
if (typeof sesion?.token !== 'string' || !sesion.usuario) return null
return sesion
} catch {
return null
}
}
export function getToken(): string | null {
return getSesion()?.token ?? null
}
export function logout(): void {
localStorage.removeItem(STORAGE_KEY)
}
/**
* Agrega Authorization: Bearer a un RequestInit si hay sesión.
* Sin token devuelve el init tal cual.
*/
export function conAuth(init: RequestInit = {}): RequestInit {
const token = getToken()
if (!token) return init
return { ...init, cache: "no-store", headers: { ...(init.headers as Record<string, string>), Authorization: `Bearer ${token}` } }
}
/** Si el API responde 401: cierra sesión y recarga para volver al login */
export function manejar401(res: Response): void {
if (res.status !== 401) return
logout()
if (typeof window !== 'undefined') window.location.reload()
}

View File

@@ -0,0 +1,106 @@
// Llamadas al API Gateway para el módulo WhatsApp (WA CRM)
import { conAuth, manejar401 } from './auth'
const API_URL = import.meta.env.VITE_API_URL || '/api'
export interface ConversacionBandeja {
id: string
status: string
lastMessage: string | null
lastMessageAt: string | null
unreadCount: number
contacto: { nombre: string | null; telefono: string }
}
export interface MiBandeja {
vinculado: boolean
conversaciones: ConversacionBandeja[]
}
export interface VendedorWacrm {
id: string
nombre: string
wacrmUserId: string | null
esMiembro: boolean
asignadas: number
}
export interface EstadoWacrm {
cuentaId: string | null
roundRobinActivo: boolean
sinAsignar: number
totalConversaciones: number
vendedores: VendedorWacrm[]
}
export interface ConfigWacrm {
cuentaId: string | null
activo: boolean
}
function errorDeApi(res: Response, fallback: string): Error {
return new Error(`${fallback} (error ${res.status})`)
}
async function parseError(res: Response, fallback: string): Promise<Error> {
const body = (await res.json().catch(() => null)) as { error?: string; message?: string } | null
return new Error(body?.error ?? body?.message ?? `${fallback} (error ${res.status})`)
}
export async function obtenerMiBandeja(): Promise<MiBandeja> {
const res = await fetch(`${API_URL}/wacrm/mi-bandeja`, conAuth())
manejar401(res)
if (!res.ok) throw await parseError(res, 'No se pudo cargar la bandeja')
return (await res.json()) as MiBandeja
}
export async function obtenerEstadoWacrm(): Promise<EstadoWacrm> {
const res = await fetch(`${API_URL}/wacrm/estado`, conAuth())
manejar401(res)
if (res.status === 403) throw errorDeApi(res, 'Sin permisos de administración')
if (!res.ok) throw await parseError(res, 'No se pudo cargar el estado')
return (await res.json()) as EstadoWacrm
}
export async function guardarConfigWacrm(cambios: {
cuentaId?: string
activo?: boolean
}): Promise<ConfigWacrm> {
const res = await fetch(
`${API_URL}/wacrm/config`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cambios),
}),
)
manejar401(res)
if (!res.ok) throw await parseError(res, 'No se pudo guardar la configuración')
return (await res.json()) as ConfigWacrm
}
export async function dispararRoundRobin(): Promise<{ asignados: number }> {
const res = await fetch(
`${API_URL}/wacrm/round-robin`,
conAuth({ method: 'POST' }),
)
manejar401(res)
if (!res.ok) throw await parseError(res, 'No se pudo ejecutar la asignación')
return (await res.json()) as { asignados: number }
}
export async function reasignarConversacion(
conversacionId: string,
vendedorId: string,
): Promise<void> {
const res = await fetch(
`${API_URL}/wacrm/reasignar`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversacionId, vendedorId }),
}),
)
manejar401(res)
if (!res.ok) throw await parseError(res, 'No se pudo reasignar la conversación')
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// La app se sirve detrás de NGINX bajo el prefijo /wa-crm
export default defineConfig({
base: '/wa-crm/',
plugins: [react(), tailwindcss()],
})

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
// Config de tests separada para no mezclar los tipos de Vite y Vitest
export default defineConfig({
test: {
environment: 'node',
},
})