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:
13
apps/wa-crm/index.html
Normal file
13
apps/wa-crm/index.html
Normal 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
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
29
apps/wa-crm/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
32
apps/wa-crm/src/components/DarkModeToggle.tsx
Normal file
32
apps/wa-crm/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 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>
|
||||
)
|
||||
}
|
||||
27
apps/wa-crm/src/components/SinSesion.tsx
Normal file
27
apps/wa-crm/src/components/SinSesion.tsx
Normal 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
20
apps/wa-crm/src/index.css
Normal 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
16
apps/wa-crm/src/main.tsx
Normal 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>,
|
||||
)
|
||||
525
apps/wa-crm/src/pages/WaCrmPage.tsx
Normal file
525
apps/wa-crm/src/pages/WaCrmPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
apps/wa-crm/src/services/auth.test.ts
Normal file
86
apps/wa-crm/src/services/auth.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
52
apps/wa-crm/src/services/auth.ts
Normal file
52
apps/wa-crm/src/services/auth.ts
Normal 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()
|
||||
}
|
||||
106
apps/wa-crm/src/services/wacrm.ts
Normal file
106
apps/wa-crm/src/services/wacrm.ts
Normal 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')
|
||||
}
|
||||
22
apps/wa-crm/tsconfig.app.json
Normal file
22
apps/wa-crm/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
apps/wa-crm/tsconfig.json
Normal file
7
apps/wa-crm/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
19
apps/wa-crm/tsconfig.node.json
Normal file
19
apps/wa-crm/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
9
apps/wa-crm/vite.config.ts
Normal file
9
apps/wa-crm/vite.config.ts
Normal 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()],
|
||||
})
|
||||
8
apps/wa-crm/vitest.config.ts
Normal file
8
apps/wa-crm/vitest.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user