Oleada UX 7: Chat (presencia, busqueda en historial) y WACRM (metricas para gerencia, notas internas y etiquetas que sobreviven al sync) + fixes de concurrencia

This commit is contained in:
2026-09-17 23:46:40 +00:00
parent 31c92ac419
commit c38783eb31
13 changed files with 1589 additions and 36 deletions

View File

@@ -0,0 +1,214 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Tag, Plus, Trash2, Check, Settings2 } from 'lucide-react';
import { Card, Button, Input, Skeleton, toast } from '../ui';
import { odooApi, type WacrmTag } from '../../services/odoo';
interface Props {
conversationId: string;
/** Se avisa al contenedor para refrescar las etiquetas de la lista. */
onTagsChange: (tags: { id: number; name: string; color: string }[]) => void;
/** Se avisa cuando cambia el catálogo (el filtro de la bandeja se recarga). */
onCatalogoChange?: () => void;
}
const COLORES = ['#605ca8', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#a855f7'];
/** Etiquetas de la conversación + catálogo editable. Sobreviven al sync. */
const ConversationEtiquetas: FC<Props> = ({ conversationId, onTagsChange, onCatalogoChange }) => {
const [catalogo, setCatalogo] = useState<WacrmTag[]>([]);
const [asignadas, setAsignadas] = useState<{ id: number; name: string; color: string }[]>([]);
const [loading, setLoading] = useState(true);
const [nueva, setNueva] = useState('');
const [color, setColor] = useState(COLORES[0]);
const [creando, setCreando] = useState(false);
const [gestionOpen, setGestionOpen] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const [tagsRes, convRes] = await Promise.all([
odooApi.getWacrmTags(),
odooApi.getWacrmConversations(),
]);
if (tagsRes.status === 'success') setCatalogo(tagsRes.tags);
if (convRes.status === 'success') {
const conv = convRes.conversations.find((c) => c.external_id === conversationId);
setAsignadas(conv?.tags || []);
}
} catch {
toast.error('No se pudieron cargar las etiquetas');
} finally {
setLoading(false);
}
}, [conversationId]);
useEffect(() => { load(); }, [load]);
const alternar = async (tagId: number) => {
const tiene = asignadas.some((t) => t.id === tagId);
try {
const res = await odooApi.assignWacrmTag(conversationId, tagId, tiene ? 'remove' : 'add');
if (res.status === 'success') {
setAsignadas(res.tags);
onTagsChange(res.tags);
}
} catch {
toast.error('No se pudo actualizar la etiqueta');
}
};
const crear = async () => {
const name = nueva.trim();
if (!name) return;
try {
setCreando(true);
const res = await odooApi.createWacrmTag({ name, color });
if (res.status === 'success') {
setCatalogo((prev) => (prev.some((t) => t.id === res.tag.id) ? prev : [...prev, res.tag]));
setNueva('');
toast.success('Etiqueta creada');
onCatalogoChange?.();
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo crear la etiqueta');
} finally {
setCreando(false);
}
};
const desactivar = async (t: WacrmTag) => {
if (!window.confirm(`¿Desactivar la etiqueta "${t.name}"? Dejará de estar disponible.`)) return;
try {
await odooApi.deleteWacrmTag(t.id);
setCatalogo((prev) => prev.filter((x) => x.id !== t.id));
setAsignadas((prev) => prev.filter((x) => x.id !== t.id));
onTagsChange(asignadas.filter((x) => x.id !== t.id));
onCatalogoChange?.();
toast.success('Etiqueta desactivada');
} catch {
toast.error('No se pudo desactivar la etiqueta');
}
};
return (
<Card className="flex-1 overflow-hidden flex flex-col">
<div className="p-3 border-b border-theme-border bg-theme-bg flex items-center justify-between gap-2">
<p className="text-sm font-medium text-theme-heading inline-flex items-center gap-1.5">
<Tag size={15} /> Etiquetas de la conversación
</p>
<Button variant="ghost" size="sm" onClick={() => setGestionOpen((v) => !v)} title="Gestionar catálogo">
<Settings2 size={14} className="mr-1.5 text-theme-muted" /> Catálogo
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{loading ? (
<Skeleton count={3} className="h-10 w-full" />
) : (
<>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Asignadas</p>
{asignadas.length === 0 ? (
<p className="text-sm text-theme-muted">Sin etiquetas. Marca alguna abajo.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{asignadas.map((t) => (
<button
key={t.id}
type="button"
onClick={() => alternar(t.id)}
title="Quitar etiqueta"
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium text-white"
style={{ backgroundColor: t.color }}
>
{t.name}
<Trash2 size={11} />
</button>
))}
</div>
)}
</div>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Disponibles</p>
{catalogo.filter((t) => !asignadas.some((a) => a.id === t.id)).length === 0 ? (
<p className="text-sm text-theme-muted">Todas las etiquetas activas ya están asignadas.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{catalogo
.filter((t) => !asignadas.some((a) => a.id === t.id))
.map((t) => (
<button
key={t.id}
type="button"
onClick={() => alternar(t.id)}
title="Asignar etiqueta"
className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition hover:bg-theme-bg"
style={{ borderColor: t.color, color: t.color }}
>
<Plus size={11} /> {t.name}
</button>
))}
</div>
)}
</div>
{gestionOpen && (
<div className="rounded-xl border border-theme-border p-3 space-y-3">
<p className="text-xs font-medium text-theme-muted uppercase">Catálogo</p>
<div className="flex items-end gap-2">
<Input
label="Nueva etiqueta"
placeholder="Ej. urgente, cotización, seguimiento"
value={nueva}
onChange={(e) => setNueva(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') crear(); }}
/>
<select
value={color}
onChange={(e) => setColor(e.target.value)}
aria-label="Color de la etiqueta"
className="rounded-xl border border-theme-border-strong bg-theme-surface px-2 py-2.5 text-sm"
>
{COLORES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<Button
size="sm"
onClick={crear}
loading={creando}
disabled={!nueva.trim()}
aria-label="Crear etiqueta"
title="Crear etiqueta"
>
<Plus size={14} />
</Button>
</div>
<div className="divide-y divide-theme-border">
{catalogo.map((t) => (
<div key={t.id} className="flex items-center justify-between py-1.5">
<span className="inline-flex items-center gap-2 text-sm text-theme-heading">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: t.color }} />
{t.name}
{asignadas.some((a) => a.id === t.id) && <Check size={12} className="text-emerald-600" />}
</span>
<Button variant="ghost" size="sm" onClick={() => desactivar(t)} title="Desactivar etiqueta">
<Trash2 size={13} className="text-theme-muted" />
</Button>
</div>
))}
{catalogo.length === 0 && <p className="text-sm text-theme-muted py-3">El catálogo está vacío.</p>}
</div>
</div>
)}
</>
)}
</div>
</Card>
);
};
export default ConversationEtiquetas;

View File

@@ -0,0 +1,142 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { StickyNote, Check, Trash2, Plus } from 'lucide-react';
import { Card, Button, Skeleton, TextArea, toast } from '../ui';
import { odooApi, type WacrmNote } from '../../services/odoo';
interface Props {
conversationId: string;
contactName: string;
}
/** Notas internas de la conversación: nunca se envían al paciente. */
const ConversationNotas: FC<Props> = ({ conversationId, contactName }) => {
const [notes, setNotes] = useState<WacrmNote[]>([]);
const [loading, setLoading] = useState(true);
const [draft, setDraft] = useState('');
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getWacrmNotes(conversationId);
if (res.status === 'success') setNotes(res.notes);
} catch {
toast.error('No se pudieron cargar las notas');
} finally {
setLoading(false);
}
}, [conversationId]);
useEffect(() => { load(); }, [load]);
const agregar = async () => {
const body = draft.trim();
if (!body) return;
try {
setSaving(true);
const res = await odooApi.createWacrmNote(conversationId, body);
if (res.status === 'success') {
setNotes((prev) => [res.note, ...prev]);
setDraft('');
toast.success('Nota guardada');
}
} catch {
toast.error('No se pudo guardar la nota');
} finally {
setSaving(false);
}
};
const alternar = async (n: WacrmNote) => {
try {
const res = await odooApi.updateWacrmNote(n.id, { resolved: !n.resolved });
if (res.status === 'success') setNotes((prev) => prev.map((x) => (x.id === n.id ? res.note : x)));
} catch {
toast.error('No se pudo actualizar la nota');
}
};
const borrar = async (n: WacrmNote) => {
if (!window.confirm('¿Eliminar esta nota interna?')) return;
try {
await odooApi.deleteWacrmNote(n.id);
setNotes((prev) => prev.filter((x) => x.id !== n.id));
toast.success('Nota eliminada');
} catch {
toast.error('No se pudo eliminar la nota');
}
};
const pendientes = notes.filter((n) => !n.resolved).length;
return (
<Card className="flex-1 overflow-hidden flex flex-col">
<div className="p-3 border-b border-theme-border bg-theme-bg flex items-center justify-between gap-2">
<p className="text-sm font-medium text-theme-heading inline-flex items-center gap-1.5">
<StickyNote size={15} /> Notas internas
{pendientes > 0 && (
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-amber-100 text-amber-800 text-[10px] font-bold">
{pendientes}
</span>
)}
</p>
<p className="text-[11px] text-theme-muted">Solo las ve el equipo · no se envían a {contactName || 'el paciente'}</p>
</div>
<div className="p-3 border-b border-theme-border">
<TextArea
rows={2}
placeholder="Escribe una nota interna (no se envía al paciente)..."
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<div className="flex justify-end mt-2">
<Button size="sm" onClick={agregar} loading={saving} disabled={!draft.trim()}>
<Plus size={14} className="mr-1.5" /> Agregar nota
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{loading ? (
<Skeleton count={3} className="h-16 w-full" />
) : notes.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-8">Sin notas internas todavía.</p>
) : (
notes.map((n) => (
<div
key={n.id}
className={`rounded-xl border p-3 ${n.resolved ? 'border-emerald-200 bg-emerald-50/50' : 'border-theme-border bg-theme-surface'}`}
>
<p className={`text-sm whitespace-pre-wrap break-words ${n.resolved ? 'text-theme-muted line-through' : 'text-theme-heading'}`}>
{n.body}
</p>
<div className="flex items-center justify-between gap-2 mt-2">
<p className="text-[11px] text-theme-muted">
{n.author || 'Alguien'} · {n.date}
{n.resolved && n.resolved_by ? ` · atendida por ${n.resolved_by}` : ''}
</p>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => alternar(n)}
title={n.resolved ? 'Marcar como pendiente' : 'Marcar como atendida'}
>
<Check size={14} className={n.resolved ? 'text-emerald-600' : 'text-theme-muted'} />
</Button>
<Button variant="ghost" size="sm" onClick={() => borrar(n)} title="Eliminar nota">
<Trash2 size={14} className="text-theme-muted" />
</Button>
</div>
</div>
</div>
))
)}
</div>
</Card>
);
};
export default ConversationNotas;

View File

@@ -0,0 +1,183 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { RefreshCw, TrendingUp, TrendingDown, Clock, MessageSquare, Inbox, AlertTriangle } from 'lucide-react';
import { Card, Button, Skeleton, EmptyState, toast } from '../ui';
import { odooApi, type WacrmMetrics as Metrics } from '../../services/odoo';
const num = (n: number | null | undefined, d = 0) =>
(n ?? 0).toLocaleString('es-MX', { maximumFractionDigits: d });
const minutos = (m: number | null) => {
if (m == null) return '—';
if (m < 1) return `${Math.round(m * 60)} s`;
if (m < 60) return `${num(m, 1)} min`;
const h = Math.floor(m / 60);
return `${h} h ${num(m - h * 60, 0)} min`;
};
const OPTS_HORAS = [2, 4, 8, 24];
/** Tablero de atención de WhatsApp (sólo gerencia/admin). */
const WacrmMetrics: FC = () => {
const [data, setData] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(true);
const [horas, setHoras] = useState(4);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getWacrmMetrics({ sin_respuesta_horas: horas });
if (res.status === 'success') setData(res);
else toast.error(res.message || 'No se pudieron cargar las métricas');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudieron cargar las métricas');
} finally {
setLoading(false);
}
}, [horas]);
useEffect(() => { load(); }, [load]);
if (loading && !data) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
);
}
if (!data) {
return <EmptyState title="Sin métricas" subtitle="No se pudieron leer los datos del WACRM." icon={<Inbox size={28} />} />;
}
const Kpi: FC<{ label: string; value: string; sub?: string; tone?: 'ok' | 'warn' }> = ({ label, value, sub, tone }) => (
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">{label}</p>
<p className={`text-xl font-heading font-semibold ${
tone === 'warn' ? 'text-rose-600' : tone === 'ok' ? 'text-emerald-700' : 'text-theme-heading'
}`}>{value}</p>
{sub && <p className="text-[11px] text-theme-muted mt-0.5">{sub}</p>}
</Card>
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-theme-muted uppercase">Sin responder más de</span>
{OPTS_HORAS.map((h) => (
<button
key={h}
type="button"
onClick={() => setHoras(h)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition ${
horas === h ? 'bg-theme-accent text-theme-inverse border-theme-accent' : 'bg-theme-bg text-theme-muted border-theme-border'
}`}
>
{h} h
</button>
))}
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-theme-muted">Actualizado {data.generado}</span>
<Button variant="outline" size="sm" onClick={load} loading={loading}>
<RefreshCw size={14} className="mr-1.5" /> Actualizar
</Button>
</div>
</div>
{/* Conversaciones y mensajes */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="Conversaciones hoy" value={num(data.conversaciones.hoy)}
sub={`${num(data.conversaciones.semana)} esta semana · ${num(data.conversaciones.mes)} este mes`} />
<Kpi label="Mensajes entrantes (mes)" value={num(data.mensajes.mes.entrantes)}
sub={`${num(data.mensajes.hoy.entrantes)} hoy`} />
<Kpi label="Mensajes salientes (mes)" value={num(data.mensajes.mes.salientes)}
sub={`${num(data.mensajes.hoy.salientes)} hoy`} />
<Kpi label="Conversaciones totales" value={num(data.conversaciones.total)}
sub="caché sincronizado del WACRM" />
</div>
{/* Primera respuesta + leads */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card>
<Card.Body>
<p className="text-xs font-medium text-theme-muted uppercase inline-flex items-center gap-1.5 mb-3">
<Clock size={13} /> Tiempo de primera respuesta
</p>
<div className="flex items-end gap-6">
<div>
<p className="text-2xl font-heading font-semibold text-theme-heading">{minutos(data.primera_respuesta.promedio_min)}</p>
<p className="text-[11px] text-theme-muted">promedio</p>
</div>
<div>
<p className="text-2xl font-heading font-semibold text-theme-heading">{minutos(data.primera_respuesta.mediana_min)}</p>
<p className="text-[11px] text-theme-muted">mediana</p>
</div>
<div>
<p className="text-2xl font-heading font-semibold text-theme-muted">{num(data.primera_respuesta.muestras)}</p>
<p className="text-[11px] text-theme-muted">conversaciones</p>
</div>
</div>
<p className="mt-3 text-[11px] text-theme-muted leading-snug">{data.criterios.primera_respuesta}</p>
</Card.Body>
</Card>
<Card>
<Card.Body>
<p className="text-xs font-medium text-theme-muted uppercase inline-flex items-center gap-1.5 mb-3">
<AlertTriangle size={13} /> Sin responder &gt; {num(data.sin_responder.horas_minimas)} h
</p>
<p className={`text-3xl font-heading font-semibold ${data.sin_responder.total > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
{num(data.sin_responder.total)}
</p>
<p className="mt-2 text-[11px] text-theme-muted leading-snug">{data.criterios.sin_responder}</p>
{data.sin_responder.detalle.length > 0 && (
<div className="mt-3 space-y-1.5 max-h-32 overflow-y-auto">
{data.sin_responder.detalle.slice(0, 5).map((d) => (
<div key={d.conversation_id} className="text-[11px] text-theme-muted truncate">
<span className="font-medium text-theme-heading">{num(d.horas, 1)} h</span> · {d.ultimo_mensaje || 'Sin texto'}
</div>
))}
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<p className="text-xs font-medium text-theme-muted uppercase inline-flex items-center gap-1.5 mb-3">
<TrendingUp size={13} /> Leads del mes
</p>
<div className="flex items-end gap-6">
<div>
<p className="text-2xl font-heading font-semibold text-emerald-700 inline-flex items-center gap-1.5">
<TrendingUp size={18} /> {num(data.leads.ganados)}
</p>
<p className="text-[11px] text-theme-muted">ganados</p>
</div>
<div>
<p className="text-2xl font-heading font-semibold text-rose-600 inline-flex items-center gap-1.5">
<TrendingDown size={18} /> {num(data.leads.perdidos)}
</p>
<p className="text-[11px] text-theme-muted">perdidos</p>
</div>
<div>
<p className="text-2xl font-heading font-semibold text-theme-muted">{num(data.leads.abiertos)}</p>
<p className="text-[11px] text-theme-muted">abiertos</p>
</div>
</div>
<p className="mt-3 text-[11px] text-theme-muted leading-snug">{data.criterios.leads}</p>
</Card.Body>
</Card>
</div>
<p className="text-[11px] text-theme-muted inline-flex items-center gap-1.5">
<MessageSquare size={12} />
Datos leídos en vivo de la base del WACRM (Supabase), no del caché local. Periodo del mes: {data.periodo.desde} a {data.periodo.hasta}.
</p>
</div>
);
};
export default WacrmMetrics;

View File

@@ -65,6 +65,9 @@ const playChatBeep = () => {
}; };
const POLL_MS = 15000; const POLL_MS = 15000;
// Presencia: latido explícito cada 30 s mientras la app está abierta (el
// backend marca last_seen y "en línea" = actividad en los últimos 2 minutos).
const HEARTBEAT_MS = 30000;
/** Polling global de mensajes nuevos: toast + beep por remitente. Montar una sola vez. */ /** Polling global de mensajes nuevos: toast + beep por remitente. Montar una sola vez. */
export const useChatNotifications = (intervalMs = POLL_MS) => { export const useChatNotifications = (intervalMs = POLL_MS) => {
@@ -126,9 +129,16 @@ export const useChatNotifications = (intervalMs = POLL_MS) => {
.catch(() => {}); .catch(() => {});
}; };
const latido = () => {
if (!alive) return;
odooApi.chatHeartbeat().catch(() => {});
};
fetchUsers(); fetchUsers();
latido();
const id = setInterval(fetchUsers, intervalMs); const id = setInterval(fetchUsers, intervalMs);
return () => { alive = false; clearInterval(id); }; const hb = setInterval(latido, HEARTBEAT_MS);
return () => { alive = false; clearInterval(id); clearInterval(hb); };
}, [user, intervalMs, navigate]); }, [user, intervalMs, navigate]);
}; };

View File

@@ -1,7 +1,7 @@
import type { FC, FormEvent, KeyboardEvent } from 'react'; import type { FC, FormEvent, KeyboardEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Send, Search, ArrowLeft } from 'lucide-react'; import { Send, Search, ArrowLeft, X, History } from 'lucide-react';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { Card, Button, EmptyState, Skeleton, toast } from '../components/ui'; import { Card, Button, EmptyState, Skeleton, toast } from '../components/ui';
import { ROLE_LABELS } from '../lib/auth';import { odooApi, type ChatUser, type ChatMessage } from '../services/odoo'; import { ROLE_LABELS } from '../lib/auth';import { odooApi, type ChatUser, type ChatMessage } from '../services/odoo';
@@ -9,6 +9,22 @@ import { setActiveChatUser } from '../lib/chat-notifications';
const POLL_MS = 5000; const POLL_MS = 5000;
/** Resalta las coincidencias de `q` dentro del texto. */
const resaltar = (texto: string, q: string) => {
if (!q || q.length < 2) return texto;
const idx = texto.toLowerCase().indexOf(q.toLowerCase());
if (idx < 0) return texto;
return (
<>
{texto.slice(0, idx)}
<mark className="bg-amber-200 text-theme-heading rounded px-0.5">{texto.slice(idx, idx + q.length)}</mark>
{texto.slice(idx + q.length)}
</>
);
};
const PRESENCIA_CLS = 'inline-block h-2.5 w-2.5 rounded-full shrink-0';
// Las fechas llegan en UTC ("YYYY-MM-DD HH:MM:SS"); se muestran en hora local // Las fechas llegan en UTC ("YYYY-MM-DD HH:MM:SS"); se muestran en hora local
const fmtTime = (d: string | null): string => { const fmtTime = (d: string | null): string => {
if (!d) return ''; if (!d) return '';
@@ -42,6 +58,14 @@ const Chat: FC = () => {
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Búsqueda dentro del historial del hilo abierto
const [histOpen, setHistOpen] = useState(false);
const [histQuery, setHistQuery] = useState('');
const [histResults, setHistResults] = useState<ChatMessage[]>([]);
const [histLoading, setHistLoading] = useState(false);
const [highlightId, setHighlightId] = useState<number | null>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const activeUser = users.find((u) => u.id === activeId) || null; const activeUser = users.find((u) => u.id === activeId) || null;
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
@@ -55,9 +79,13 @@ const Chat: FC = () => {
} }
}, []); }, []);
const fetchThread = useCallback(async (otherId: number, incremental: boolean) => { const fetchThread = useCallback(async (otherId: number, incremental: boolean, aroundId?: number) => {
try { try {
const res = await odooApi.getChatMessages(otherId, incremental ? lastIdRef.current : undefined); const res = await odooApi.getChatMessages(
otherId,
incremental ? lastIdRef.current : undefined,
aroundId,
);
if (res.status !== 'success') return; if (res.status !== 'success') return;
if (incremental) { if (incremental) {
if (res.messages.length === 0) return; if (res.messages.length === 0) return;
@@ -69,7 +97,13 @@ const Chat: FC = () => {
setMessages(res.messages); setMessages(res.messages);
} }
lastIdRef.current = Math.max(...res.messages.map((m) => m.id), lastIdRef.current); lastIdRef.current = Math.max(...res.messages.map((m) => m.id), lastIdRef.current);
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50); if (aroundId) {
// Salto a un mensaje antiguo: centrarlo en vez de ir al final
setHighlightId(aroundId);
setTimeout(() => highlightRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }), 80);
} else {
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50);
}
} catch { } catch {
// silencioso en polling // silencioso en polling
} }
@@ -79,6 +113,10 @@ const Chat: FC = () => {
setActiveId(id); setActiveId(id);
setMessages([]); setMessages([]);
lastIdRef.current = 0; lastIdRef.current = 0;
setHistOpen(false);
setHistQuery('');
setHistResults([]);
setHighlightId(null);
setLoadingThread(true); setLoadingThread(true);
await fetchThread(id, false); await fetchThread(id, false);
setLoadingThread(false); setLoadingThread(false);
@@ -86,6 +124,36 @@ const Chat: FC = () => {
fetchUsers(); fetchUsers();
}, [fetchThread, fetchUsers]); }, [fetchThread, fetchUsers]);
// Buscar en el historial (servidor)
const buscarHistorial = useCallback(async () => {
if (!activeId || histQuery.trim().length < 2) return;
try {
setHistLoading(true);
const res = await odooApi.searchChatMessages(activeId, histQuery.trim());
if (res.status === 'success') setHistResults(res.results);
} catch {
toast.error('No se pudo buscar en el historial');
} finally {
setHistLoading(false);
}
}, [activeId, histQuery]);
// Saltar a un resultado: si ya está cargado se centra; si es antiguo se
// pide la ventana de historial que termina en ese mensaje.
const irAlMensaje = useCallback(async (m: ChatMessage) => {
if (!activeId) return;
const cargado = messages.some((x) => x.id === m.id);
setHistOpen(false);
if (cargado) {
setHighlightId(m.id);
setTimeout(() => highlightRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }), 80);
} else {
setLoadingThread(true);
await fetchThread(activeId, false, m.id);
setLoadingThread(false);
}
}, [activeId, messages, fetchThread]);
// Carga inicial + polling de lista y hilo abierto // Carga inicial + polling de lista y hilo abierto
useEffect(() => { useEffect(() => {
fetchUsers(); fetchUsers();
@@ -186,8 +254,19 @@ const Chat: FC = () => {
onClick={() => openConversation(u.id)} onClick={() => openConversation(u.id)}
className={`w-full flex items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-theme-accent-bg ${activeId === u.id ? 'bg-theme-accent-bg' : ''}`} className={`w-full flex items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-theme-accent-bg ${activeId === u.id ? 'bg-theme-accent-bg' : ''}`}
> >
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading"> <span className="relative shrink-0">
{initials(u.name)} <span className="flex h-10 w-10 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(u.name)}
</span>
<span
className={`absolute -bottom-0 -right-0 ${PRESENCIA_CLS} border-2 border-theme-surface ${
u.online ? 'bg-emerald-500' : 'bg-theme-muted/40'
}`}
data-presencia={u.online ? 'online' : 'offline'}
title={u.online
? 'En línea'
: u.last_seen ? `Visto ${fmtDay(u.last_seen)} ${fmtTime(u.last_seen)}` : 'Sin actividad'}
/>
</span> </span>
<span className="flex-1 min-w-0"> <span className="flex-1 min-w-0">
<span className="flex items-center justify-between gap-2"> <span className="flex items-center justify-between gap-2">
@@ -237,15 +316,93 @@ const Chat: FC = () => {
> >
<ArrowLeft size={18} /> <ArrowLeft size={18} />
</button> </button>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading"> <span className="relative shrink-0">
{initials(activeUser.name)} <span className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(activeUser.name)}
</span>
<span
className={`absolute -bottom-0 -right-0 h-2.5 w-2.5 rounded-full border-2 border-theme-surface ${
activeUser.online ? 'bg-emerald-500' : 'bg-theme-muted/40'
}`}
data-presencia={activeUser.online ? 'online' : 'offline'}
/>
</span> </span>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-theme-heading">{activeUser.name}</p> <p className="truncate text-sm font-semibold text-theme-heading">{activeUser.name}</p>
<p className="text-xs text-theme-muted">{ROLE_LABELS[activeUser.role] || activeUser.role}</p> <p className="text-xs text-theme-muted">
{activeUser.online
? 'En línea'
: activeUser.last_seen
? `Visto ${fmtDay(activeUser.last_seen)} ${fmtTime(activeUser.last_seen)}`
: ROLE_LABELS[activeUser.role] || activeUser.role}
</p>
</div> </div>
<button
type="button"
onClick={() => { setHistOpen((v) => !v); setHistResults([]); }}
className={`shrink-0 inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${
histOpen ? 'border-theme-accent bg-theme-accent-bg text-theme-heading' : 'border-theme-border text-theme-muted hover:border-theme-border-strong'
}`}
title="Buscar en el historial de esta conversación"
>
<History size={13} />
Buscar
</button>
</div> </div>
{histOpen && (
<div className="px-4 py-3 border-b border-theme-border bg-theme-surface">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<input
type="text"
autoFocus
placeholder="Buscar en el historial..."
value={histQuery}
onChange={(e) => setHistQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') buscarHistorial(); }}
className="w-full rounded-xl border border-theme-border-strong bg-theme-surface pl-9 pr-3 py-2 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent"
/>
</div>
<Button size="sm" onClick={buscarHistorial} loading={histLoading}>Buscar</Button>
<button
type="button"
onClick={() => { setHistOpen(false); setHistResults([]); setHistQuery(''); }}
className="p-1.5 rounded-lg text-theme-muted hover:bg-theme-bg"
aria-label="Cerrar búsqueda"
>
<X size={16} />
</button>
</div>
{histResults.length > 0 && (
<div className="mt-2 max-h-56 overflow-y-auto divide-y divide-theme-border rounded-xl border border-theme-border">
<p className="px-3 py-1.5 text-[11px] text-theme-muted bg-theme-bg">
{histResults.length} {histResults.length === 1 ? 'resultado' : 'resultados'} para "{histQuery}"
</p>
{histResults.map((m) => (
<button
key={m.id}
type="button"
onClick={() => irAlMensaje(m)}
className="w-full text-left px-3 py-2 hover:bg-theme-bg transition"
>
<p className="text-xs text-theme-heading break-words">
{resaltar(m.body, histQuery)}
</p>
<p className="text-[10px] text-theme-muted mt-0.5">
{m.mine ? 'Tú' : activeUser.name} · {m.date}
</p>
</button>
))}
</div>
)}
{!histLoading && histQuery.trim().length >= 2 && histResults.length === 0 && (
<p className="mt-2 text-xs text-theme-muted">Sin coincidencias en esta conversación.</p>
)}
</div>
)}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-2 bg-theme-bg"> <div className="flex-1 overflow-y-auto px-4 py-4 space-y-2 bg-theme-bg">
{loadingThread ? ( {loadingThread ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -261,9 +418,16 @@ const Chat: FC = () => {
</p> </p>
) : ( ) : (
messages.map((m) => ( messages.map((m) => (
<div key={m.id} className={`flex ${m.mine ? 'justify-end' : 'justify-start'}`}> <div
key={m.id}
ref={highlightId === m.id ? highlightRef : undefined}
data-msg-id={m.id}
className={`flex ${m.mine ? 'justify-end' : 'justify-start'}`}
>
<div <div
className={`max-w-[75%] rounded-2xl px-3.5 py-2 text-sm shadow-card ${ className={`max-w-[75%] rounded-2xl px-3.5 py-2 text-sm shadow-card transition-shadow ${
highlightId === m.id ? 'ring-2 ring-amber-400' : ''
} ${
m.mine m.mine
? 'bg-theme-accent text-theme-inverse rounded-br-md' ? 'bg-theme-accent text-theme-inverse rounded-br-md'
: 'bg-theme-surface text-theme-heading border border-theme-border rounded-bl-md' : 'bg-theme-surface text-theme-heading border border-theme-border rounded-bl-md'

View File

@@ -1,10 +1,14 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown, Heart, Calendar } from 'lucide-react'; import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown, Heart, Calendar, StickyNote, Tag, BarChart3 } from 'lucide-react';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { Card, Button, Input, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui'; import { Card, Button, Input, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
import { odooApi, type WacrmConversation, type WacrmMessage, type Patient } from '../services/odoo'; import { odooApi, type WacrmConversation, type WacrmMessage, type Patient, type WacrmTag } from '../services/odoo';
import { useAuth } from '../lib/auth';
import WacrmMetrics from '../components/wacrm/WacrmMetrics';
import ConversationNotas from '../components/wacrm/ConversationNotas';
import ConversationEtiquetas from '../components/wacrm/ConversationEtiquetas';
interface Member { interface Member {
id: string; id: string;
@@ -39,6 +43,14 @@ const WacrmMessages: FC = () => {
return ''; return '';
}); });
const [filtro, setFiltro] = useState<'todas' | 'mias' | 'sin_asignar' | 'no_leidas'>('todas'); const [filtro, setFiltro] = useState<'todas' | 'mias' | 'sin_asignar' | 'no_leidas'>('todas');
// Vista de la página: bandeja o tablero de métricas (sólo admin/gerencia)
const [vista, setVista] = useState<'conversaciones' | 'metricas'>('conversaciones');
// Panel del detalle: mensajes, notas internas o etiquetas
const [detalleTab, setDetalleTab] = useState<'mensajes' | 'notas' | 'etiquetas'>('mensajes');
const [tagsCatalogo, setTagsCatalogo] = useState<WacrmTag[]>([]);
const [filtroTag, setFiltroTag] = useState<number | ''>('');
const { user } = useAuth();
const puedeVerMetricas = !!user && (user.role === 'admin' || user.role === 'gerencia');
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const convParam = searchParams.get('conv'); const convParam = searchParams.get('conv');
@@ -154,6 +166,10 @@ const WacrmMessages: FC = () => {
if (res.status === 'success') { if (res.status === 'success') {
toast.success(`Sincronizado: ${JSON.stringify(res.result)}`); toast.success(`Sincronizado: ${JSON.stringify(res.result)}`);
await loadConversations(); await loadConversations();
} else if (res.status === 'busy') {
// La auto-sincronización cada 15 s ya está corriendo
toast.info('Ya hay una sincronización en curso');
await loadConversations();
} }
} catch (err) { } catch (err) {
toast.error('Error al sincronizar con WACRM'); toast.error('Error al sincronizar con WACRM');
@@ -163,12 +179,21 @@ const WacrmMessages: FC = () => {
} }
}; };
const loadTags = useCallback(async () => {
try {
const res = await odooApi.getWacrmTags();
if (res.status === 'success') setTagsCatalogo(res.tags);
} catch {
// el catálogo de etiquetas es opcional para la bandeja
}
}, []);
// Carga inicial // Carga inicial
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const init = async () => { const init = async () => {
setLoading(true); setLoading(true);
await Promise.all([loadConversations(), loadMembers()]); await Promise.all([loadConversations(), loadMembers(), loadTags()]);
if (mounted) setLoading(false); if (mounted) setLoading(false);
}; };
init(); init();
@@ -261,6 +286,15 @@ const WacrmMessages: FC = () => {
const handleSelect = useCallback((c: WacrmConversation) => { const handleSelect = useCallback((c: WacrmConversation) => {
userSelectedRef.current = true; userSelectedRef.current = true;
setSelected(c); setSelected(c);
setDetalleTab('mensajes');
}, []);
/** Refresca la conversación en la lista cuando cambian sus etiquetas. */
const actualizarTagsSeleccion = useCallback((tags: { id: number; name: string; color: string }[]) => {
setSelected((prev) => (prev ? { ...prev, tags } : prev));
setConversations((prev) => prev.map((c) => (
c.external_id === selectedRef.current?.external_id ? { ...c, tags } : c
)));
}, []); }, []);
const handleSendReply = async () => { const handleSendReply = async () => {
@@ -320,6 +354,7 @@ const WacrmMessages: FC = () => {
const agentName = (id?: string) => (id ? members.find((m) => m.id === id)?.name : undefined); const agentName = (id?: string) => (id ? members.find((m) => m.id === id)?.name : undefined);
const conversationsFiltradas = conversations.filter((c) => { const conversationsFiltradas = conversations.filter((c) => {
if (filtroTag !== '' && !(c.tags || []).some((t) => t.id === filtroTag)) return false;
if (filtro === 'mias') return !!currentAgentId && c.assigned_agent_id === currentAgentId; if (filtro === 'mias') return !!currentAgentId && c.assigned_agent_id === currentAgentId;
if (filtro === 'sin_asignar') return !c.assigned_agent_id; if (filtro === 'sin_asignar') return !c.assigned_agent_id;
if (filtro === 'no_leidas') return c.unread_count > 0; if (filtro === 'no_leidas') return c.unread_count > 0;
@@ -366,6 +401,36 @@ const WacrmMessages: FC = () => {
</div> </div>
</div> </div>
{/* Vista: bandeja o métricas de gerencia */}
<div className="flex flex-wrap gap-1.5 mb-4">
<button
type="button"
onClick={() => setVista('conversaciones')}
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition ${
vista === 'conversaciones' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
<MessageSquare size={15} /> Conversaciones
</button>
{puedeVerMetricas && (
<button
type="button"
onClick={() => setVista('metricas')}
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition ${
vista === 'metricas' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
title="Tablero de atención de WhatsApp (gerencia)"
>
<BarChart3 size={15} /> Métricas
</button>
)}
</div>
{vista === 'metricas' && puedeVerMetricas ? (
<WacrmMetrics />
) : (
<>
{loading ? ( {loading ? (
<Skeleton count={6} className="h-20 w-full" /> <Skeleton count={6} className="h-20 w-full" />
) : conversations.length === 0 ? ( ) : conversations.length === 0 ? (
@@ -394,6 +459,21 @@ const WacrmMessages: FC = () => {
</button> </button>
))} ))}
</div> </div>
{tagsCatalogo.length > 0 && (
<div className="px-2.5 py-2 border-b border-theme-border shrink-0">
<select
value={filtroTag}
onChange={(e) => setFiltroTag(e.target.value === '' ? '' : parseInt(e.target.value, 10))}
aria-label="Filtrar por etiqueta"
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-1.5 text-xs text-theme-heading"
>
<option value="">Todas las etiquetas</option>
{tagsCatalogo.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
)}
<Card.Body className="p-0 overflow-y-auto flex-1"> <Card.Body className="p-0 overflow-y-auto flex-1">
<div className="divide-y"> <div className="divide-y">
{conversationsFiltradas.length === 0 && ( {conversationsFiltradas.length === 0 && (
@@ -420,6 +500,19 @@ const WacrmMessages: FC = () => {
)} )}
</div> </div>
<p className="text-xs text-theme-muted mt-1 truncate">{c.last_message_text || 'Sin mensajes'}</p> <p className="text-xs text-theme-muted mt-1 truncate">{c.last_message_text || 'Sin mensajes'}</p>
{(c.tags || []).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{(c.tags || []).map((t) => (
<span
key={t.id}
className="rounded-full px-1.5 py-0.5 text-[9px] font-medium text-white"
style={{ backgroundColor: t.color }}
>
{t.name}
</span>
))}
</div>
)}
<div className="flex items-center justify-between mt-1"> <div className="flex items-center justify-between mt-1">
<p className="text-[10px] text-theme-muted">{c.last_message_at || c.created_at || ''}</p> <p className="text-[10px] text-theme-muted">{c.last_message_at || c.created_at || ''}</p>
{c.assigned_agent_id && ( {c.assigned_agent_id && (
@@ -500,6 +593,49 @@ const WacrmMessages: FC = () => {
)} )}
</div> </div>
)} )}
{/* Pestañas del detalle: mensajes, notas internas y etiquetas */}
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-theme-border shrink-0">
{([
{ key: 'mensajes', label: 'Mensajes', icon: MessageSquare },
{ key: 'notas', label: 'Notas internas', icon: StickyNote },
{ key: 'etiquetas', label: 'Etiquetas', icon: Tag },
] as const).map((t) => {
const Icono = t.icon;
return (
<button
key={t.key}
type="button"
onClick={() => setDetalleTab(t.key)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition ${
detalleTab === t.key
? 'bg-theme-accent text-theme-inverse'
: 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
<Icono size={13} /> {t.label}
{t.key === 'etiquetas' && (selected.tags || []).length > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-theme-surface/40 text-[10px]">
{(selected.tags || []).length}
</span>
)}
</button>
);
})}
</div>
{detalleTab === 'notas' ? (
<ConversationNotas
conversationId={selected.external_id}
contactName={selected.contact_name}
/>
) : detalleTab === 'etiquetas' ? (
<ConversationEtiquetas
conversationId={selected.external_id}
onTagsChange={actualizarTagsSeleccion}
onCatalogoChange={loadTags}
/>
) : (
<>
<div ref={threadRef} className="flex-1 overflow-y-auto p-4 space-y-3"> <div ref={threadRef} className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length > 0 && msgHasMore && ( {messages.length > 0 && msgHasMore && (
<div className="text-center"> <div className="text-center">
@@ -583,6 +719,8 @@ const WacrmMessages: FC = () => {
<p className="text-xs text-amber-600 mt-2">Selecciona "Responder como..." para poder responder mensajes.</p> <p className="text-xs text-amber-600 mt-2">Selecciona "Responder como..." para poder responder mensajes.</p>
)} )}
</div> </div>
</>
)}
</> </>
) : ( ) : (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
@@ -638,6 +776,8 @@ const WacrmMessages: FC = () => {
)} )}
</div> </div>
</Modal> </Modal>
</>
)}
</Layout> </Layout>
); );
}; };

View File

@@ -626,6 +626,43 @@ export interface WacrmConversation {
last_message_at: string | null; last_message_at: string | null;
unread_count: number; unread_count: number;
created_at: string | null; created_at: string | null;
tags?: { id: number; name: string; color: string }[];
}
export interface WacrmTag {
id: number;
name: string;
color: string;
sequence: number;
active: boolean;
}
export interface WacrmNote {
id: number;
body: string;
author: string;
date: string | null;
resolved: boolean;
resolved_at: string | null;
resolved_by: string;
}
export interface WacrmMetrics {
status: string;
generado: string;
periodo: { desde: string; hasta: string };
sin_respuesta_horas: number;
conversaciones: { hoy: number; semana: number; mes: number; total: number };
mensajes: {
hoy: { entrantes: number; salientes: number };
semana: { entrantes: number; salientes: number };
mes: { entrantes: number; salientes: number };
};
primera_respuesta: { muestras: number; promedio_min: number | null; mediana_min: number | null };
sin_responder: { total: number; horas_minimas: number; detalle: { conversation_id: string; horas: number; ultimo_mensaje: string }[] };
leads: { ganados: number; perdidos: number; abiertos: number; valor_ganado: number };
criterios: Record<string, string>;
message?: string;
} }
export interface WacrmMessage { export interface WacrmMessage {
@@ -713,6 +750,8 @@ export interface ChatUser {
last_message: string | null; last_message: string | null;
last_date: string | null; last_date: string | null;
last_mine: boolean | null; last_mine: boolean | null;
online: boolean;
last_seen: string | null;
} }
export interface ChatMessage { export interface ChatMessage {
@@ -1180,8 +1219,24 @@ export const odooApi = {
return data; return data;
}, },
async getChatMessages(userId: number, sinceId?: number): Promise<{ status: string; messages: ChatMessage[] }> { async getChatMessages(userId: number, sinceId?: number, aroundId?: number): Promise<{ status: string; messages: ChatMessage[]; around_id?: number | null }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/messages`, { user_id: userId, since_id: sinceId || undefined }); const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/messages`, {
user_id: userId,
since_id: sinceId || undefined,
around_id: aroundId || undefined,
});
return data;
},
/** Búsqueda en el historial con otro usuario (servidor). */
async searchChatMessages(userId: number, q: string, limit = 50): Promise<{ status: string; query: string; count: number; results: ChatMessage[] }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/search`, { user_id: userId, q, limit });
return data;
},
/** Latido de presencia (se manda cada ~30 s con la app abierta). */
async chatHeartbeat(): Promise<{ status: string; online_count: number; online_window_seconds: number }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/heartbeat`);
return data; return data;
}, },
@@ -1860,6 +1915,59 @@ export const odooApi = {
return data; return data;
}, },
// ---- Notas internas ----
async getWacrmNotes(conversationId: string): Promise<{ status: string; notes: WacrmNote[]; pendientes: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations/${conversationId}/notes`);
return data;
},
async createWacrmNote(conversationId: string, body: string): Promise<{ status: string; note: WacrmNote }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations/${conversationId}/notes`, { body });
return data;
},
async updateWacrmNote(noteId: number, payload: { body?: string; resolved?: boolean }): Promise<{ status: string; note: WacrmNote }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/wacrm/notes/${noteId}`, payload);
return data;
},
async deleteWacrmNote(noteId: number): Promise<{ status: string; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/wacrm/notes/${noteId}`);
return data;
},
// ---- Etiquetas ----
async getWacrmTags(): Promise<{ status: string; tags: WacrmTag[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/tags`);
return data;
},
async createWacrmTag(payload: { name: string; color?: string; sequence?: number }): Promise<{ status: string; tag: WacrmTag }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/tags`, payload);
return data;
},
async updateWacrmTag(tagId: number, payload: Partial<WacrmTag>): Promise<{ status: string; tag: WacrmTag }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/wacrm/tags/${tagId}`, payload);
return data;
},
async deleteWacrmTag(tagId: number): Promise<{ status: string; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/wacrm/tags/${tagId}`);
return data;
},
async assignWacrmTag(conversationId: string, tagId: number, action: 'add' | 'remove' = 'add'): Promise<{ status: string; tags: { id: number; name: string; color: string }[] }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations/${conversationId}/tags`, { tag_id: tagId, action });
return data;
},
// ---- Métricas para gerencia ----
async getWacrmMetrics(params?: { sin_respuesta_horas?: number }): Promise<WacrmMetrics> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/metrics`, { params });
return data;
},
async sendWacrmMessage(payload: { conversation_id: string; text: string; assigned_agent_id: string }): Promise<{ status: string; message: string }> { async sendWacrmMessage(payload: { conversation_id: string; text: string; assigned_agent_id: string }): Promise<{ status: string; message: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/messages`, payload); const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/messages`, payload);
return data; return data;

View File

@@ -6,7 +6,7 @@ restringidos al usuario autenticado: solo puede listar/leer conversaciones
donde él es sender o recipient, y solo puede enviar como él mismo. donde él es sender o recipient, y solo puede enviar como él mismo.
""" """
from odoo import http from odoo import http, fields
from odoo.http import request from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import require_role from odoo.addons.skeen_whatsapp.controllers.auth import require_role
@@ -15,10 +15,44 @@ from odoo.addons.skeen_whatsapp.controllers.frontend import json_response, _pars
MAX_BODY = 2000 MAX_BODY = 2000
HISTORY_LIMIT = 50 HISTORY_LIMIT = 50
LIST_SCAN_LIMIT = 500 LIST_SCAN_LIMIT = 500
# Presencia: actividad en los últimos 2 minutos = "en línea"
ONLINE_SECONDS = 120
# No reescribir last_seen más seguido que esto (varias pestañas del mismo
# usuario laten a la vez)
TOUCH_INTERVAL = 20
class SkeenChatController(http.Controller): class SkeenChatController(http.Controller):
def _touch(self, me):
"""Marca actividad del usuario (presencia). Nunca debe romper la petición.
Se hace con SQL directo dentro de un savepoint: el frontend consulta
varios endpoints de chat en paralelo (bandeja, badge, heartbeat) y dos
UPDATE concurrentes sobre la misma fila abortan la transacción en
REPEATABLE READ ("could not serialize access due to concurrent update").
Con el savepoint ese fallo se descarta y la petición sigue; además sólo
se escribe si el último registro tiene más de TOUCH_INTERVAL segundos,
que es lo que evita la mayor parte de los choques.
"""
try:
ahora = fields.Datetime.now()
limite = fields.Datetime.subtract(ahora, seconds=TOUCH_INTERVAL)
cr = request.env.cr
with cr.savepoint():
cr.execute(
"UPDATE skeen_frontend_user SET last_seen = %s, write_date = %s "
"WHERE id = %s AND (last_seen IS NULL OR last_seen < %s)",
(ahora, ahora, me.id, limite))
except Exception:
pass
def _online(self, user):
if not user.last_seen:
return False
delta = (fields.Datetime.now() - user.last_seen).total_seconds()
return delta <= ONLINE_SECONDS
def _message_to_dict(self, msg, me_id): def _message_to_dict(self, msg, me_id):
return { return {
'id': msg.id, 'id': msg.id,
@@ -30,6 +64,36 @@ class SkeenChatController(http.Controller):
'date': msg.create_date.strftime('%Y-%m-%d %H:%M:%S') if msg.create_date else None, 'date': msg.create_date.strftime('%Y-%m-%d %H:%M:%S') if msg.create_date else None,
} }
def _pair_domain(self, me_id, other_id):
return [
'|', '&', ('sender_id', '=', me_id), ('recipient_id', '=', other_id),
'&', ('sender_id', '=', other_id), ('recipient_id', '=', me_id),
]
@http.route('/skeen/frontend/v1/chat/heartbeat', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_heartbeat(self, **kw):
"""Latido de presencia: el cliente lo manda cada ~30 s con la app abierta.
Es la ÚNICA señal que escribe last_seen: así no compite con las demás
llamadas de chat que el frontend lanza en paralelo.
"""
try:
me = request.frontend_user
self._touch(me)
online = request.env['skeen.frontend.user'].sudo().search_count([
('active', '=', True),
('last_seen', '>=', fields.Datetime.subtract(fields.Datetime.now(), seconds=ONLINE_SECONDS)),
])
return json_response({
'status': 'success',
'server_time': fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'online_count': online,
'online_window_seconds': ONLINE_SECONDS,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @http.route('/skeen/frontend/v1/chat/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura') @require_role('lectura')
def chat_users(self, **kw): def chat_users(self, **kw):
@@ -79,6 +143,8 @@ class SkeenChatController(http.Controller):
'last_message': last.get(u.id, {}).get('body'), 'last_message': last.get(u.id, {}).get('body'),
'last_date': last.get(u.id, {}).get('date'), 'last_date': last.get(u.id, {}).get('date'),
'last_mine': last.get(u.id, {}).get('mine'), 'last_mine': last.get(u.id, {}).get('mine'),
'online': self._online(u),
'last_seen': u.last_seen.strftime('%Y-%m-%d %H:%M:%S') if u.last_seen else None,
} for u in conversations + rest], } for u in conversations + rest],
}) })
except Exception as e: except Exception as e:
@@ -87,7 +153,8 @@ class SkeenChatController(http.Controller):
@http.route('/skeen/frontend/v1/chat/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @http.route('/skeen/frontend/v1/chat/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura') @require_role('lectura')
def chat_messages(self, **kw): def chat_messages(self, **kw):
"""Historial con otro usuario (últimos 50; since_id para incremental). """Historial con otro usuario (últimos 50; since_id para incremental,
around_id para abrir la ventana que termina en un mensaje concreto).
Marca como leídos los mensajes recibidos de ese usuario. Marca como leídos los mensajes recibidos de ese usuario.
""" """
@@ -100,15 +167,19 @@ class SkeenChatController(http.Controller):
User = request.env['skeen.frontend.user'].sudo() User = request.env['skeen.frontend.user'].sudo()
if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1): if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404) return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
domain = [ domain = self._pair_domain(me.id, other_id)
'|', '&', ('sender_id', '=', me.id), ('recipient_id', '=', other_id),
'&', ('sender_id', '=', other_id), ('recipient_id', '=', me.id),
]
since_id = int(data.get('since_id') or 0) since_id = int(data.get('since_id') or 0)
around_id = int(data.get('around_id') or 0)
Msg = request.env['skeen.chat.message'].sudo()
if since_id: if since_id:
domain.append(('id', '>', since_id)) domain.append(('id', '>', since_id))
Msg = request.env['skeen.chat.message'].sudo() msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id')
msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id') elif around_id:
# Ventana de historial que TERMINA en ese mensaje (contexto hacia atrás)
msgs = Msg.search(domain + [('id', '<=', around_id)],
order='id desc', limit=HISTORY_LIMIT).sorted('id')
else:
msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id')
# Marcar leídos los recibidos en esta conversación # Marcar leídos los recibidos en esta conversación
incoming = msgs.filtered(lambda m: m.recipient_id.id == me.id and not m.is_read) incoming = msgs.filtered(lambda m: m.recipient_id.id == me.id and not m.is_read)
if incoming: if incoming:
@@ -116,6 +187,41 @@ class SkeenChatController(http.Controller):
return json_response({ return json_response({
'status': 'success', 'status': 'success',
'messages': [self._message_to_dict(m, me.id) for m in msgs], 'messages': [self._message_to_dict(m, me.id) for m in msgs],
'around_id': around_id or None,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/search', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_search(self, **kw):
"""Busca texto en el historial con otro usuario.
Devuelve los mensajes que coinciden (más recientes primero) con el
texto y el fragmento resaltado del lado del cliente.
"""
try:
me = request.frontend_user
data = _parse_json_body()
other_id = int(data.get('user_id') or 0)
q = (data.get('q') or '').strip()
limit = min(max(int(data.get('limit', 50) or 50), 1), 100)
if not other_id or other_id == me.id:
return json_response({'status': 'error', 'message': 'user_id inválido'}, 400)
if len(q) < 2:
return json_response({'status': 'error', 'message': 'Escribe al menos 2 caracteres'}, 400)
User = request.env['skeen.frontend.user'].sudo()
if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
Msg = request.env['skeen.chat.message'].sudo()
msgs = Msg.search(self._pair_domain(me.id, other_id) + [('body', 'ilike', q)],
order='id desc', limit=limit)
results = [self._message_to_dict(m, me.id) for m in msgs]
return json_response({
'status': 'success',
'query': q,
'count': len(results),
'results': results,
}) })
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -154,11 +260,18 @@ class SkeenChatController(http.Controller):
@http.route('/skeen/frontend/v1/chat/unread_count', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @http.route('/skeen/frontend/v1/chat/unread_count', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura') @require_role('lectura')
def chat_unread_count(self, **kw): def chat_unread_count(self, **kw):
"""Total global de mensajes no leídos del usuario (badge del menú).""" """Total global de mensajes no leídos del usuario (badge del menú).
Mismo universo que /chat/users: sólo remitentes ACTIVOS, para que el
badge cuadre con la suma de los contadores de la bandeja.
"""
try: try:
me = request.frontend_user me = request.frontend_user
activos = request.env['skeen.frontend.user'].sudo().search([('active', '=', True)]).ids
total = request.env['skeen.chat.message'].sudo().search_count([ total = request.env['skeen.chat.message'].sudo().search_count([
('recipient_id', '=', me.id), ('is_read', '=', False), ('recipient_id', '=', me.id),
('is_read', '=', False),
('sender_id', 'in', activos),
]) ])
return json_response({'status': 'success', 'unread': total}) return json_response({'status': 'success', 'unread': total})
except Exception as e: except Exception as e:

View File

@@ -4,15 +4,17 @@ Proxy/caché entre WACRM (Supabase) y Odoo.
Expone endpoints REST para el frontend SKEEN. Expone endpoints REST para el frontend SKEEN.
""" """
from odoo import http from odoo import http, fields
from odoo.http import request from odoo.http import request
from .frontend import json_response, _parse_json_body from .frontend import json_response, _parse_json_body
from .auth import require_role
import os import os
import logging import logging
import requests import requests
import re import re
import time
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -126,13 +128,42 @@ class SkeenWacrmProxyController(http.Controller):
# ============================================================ # ============================================================
@http.route('/skeen/frontend/v1/wacrm/sync', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @http.route('/skeen/frontend/v1/wacrm/sync', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
def sync_wacrm(self, **kw): def sync_wacrm(self, **kw):
"""Sincroniza contactos, conversaciones, mensajes, pipelines y deals desde Supabase hacia Odoo.""" """Sincroniza contactos, conversaciones, mensajes, pipelines y deals desde Supabase hacia Odoo.
El frontend tiene auto-sync cada 15 s y un botón manual: si ambos
coinciden, dos transacciones escriben las mismas filas del caché y
Postgres aborta una con "could not serialize access" (500). Se
reintenta con la transacción limpia, que es lo que hace Odoo en sus
propios reintentos.
"""
# Un solo sync a la vez: el frontend auto-sincroniza cada 15 s y tiene
# botón manual; dos syncs simultáneos chocan en las mismas filas del
# caché y Postgres aborta uno. El lock de transacción se libera solo.
try: try:
result = self._do_sync() request.env.cr.execute(
return json_response({'status': 'success', 'result': result}) "SELECT pg_try_advisory_xact_lock(hashtext('skeen_wacrm_sync'))")
except Exception as e: libre = request.env.cr.fetchone()[0]
_logger.exception('Error sincronizando WACRM') except Exception:
return json_response({'status': 'error', 'message': str(e)}, 500) libre = True
if not libre:
return json_response({
'status': 'busy',
'message': 'Ya hay una sincronización en curso; se actualizará en unos segundos',
})
ultimo = None
for intento in range(3):
try:
result = self._do_sync()
return json_response({'status': 'success', 'result': result})
except Exception as e:
ultimo = e
if 'could not serialize' not in str(e).lower():
break
request.env.cr.rollback()
_logger.warning('Sync WACRM en conflicto (intento %s/3), reintentando', intento + 1)
time.sleep(0.8 * (intento + 1))
_logger.exception('Error sincronizando WACRM')
return json_response({'status': 'error', 'message': str(ultimo)}, 500)
def _do_sync(self): def _do_sync(self):
account_id = None account_id = None
@@ -729,8 +760,388 @@ class SkeenWacrmProxyController(http.Controller):
'last_message_at': c.last_message_at.strftime('%Y-%m-%d %H:%M:%S') if c.last_message_at else None, 'last_message_at': c.last_message_at.strftime('%Y-%m-%d %H:%M:%S') if c.last_message_at else None,
'unread_count': c.unread_count, 'unread_count': c.unread_count,
'created_at': c.created_at.strftime('%Y-%m-%d %H:%M:%S') if c.created_at else None, 'created_at': c.created_at.strftime('%Y-%m-%d %H:%M:%S') if c.created_at else None,
'tags': self._tags_de(c.external_id),
} }
# ============================================================
# Notas internas y etiquetas (viven en Odoo, sobreviven al sync)
# ============================================================
def _tags_de(self, external_id):
rows = request.env['skeen.wacrm.conversation.tag'].sudo().search([
('conversation_external_id', '=', external_id)])
return [{'id': r.tag_id.id, 'name': r.tag_id.name, 'color': r.tag_id.color}
for r in rows if r.tag_id.active]
def _note_to_dict(self, n):
return {
'id': n.id,
'body': n.body,
'author': n.author_name or n.author_login or '',
'date': n.create_date.strftime('%Y-%m-%d %H:%M') if n.create_date else None,
'resolved': n.resolved,
'resolved_at': n.resolved_at.strftime('%Y-%m-%d %H:%M') if n.resolved_at else None,
'resolved_by': n.resolved_by or '',
}
def _tag_to_dict(self, t):
return {'id': t.id, 'name': t.name, 'color': t.color, 'sequence': t.sequence,
'active': t.active}
# ---- Notas internas ----
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/notes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_notes(self, external_id, **kw):
"""Notas internas de la conversación. Nunca se envían al paciente."""
try:
notes = request.env['skeen.wacrm.note'].sudo().search([
('conversation_external_id', '=', external_id)])
return json_response({
'status': 'success',
'notes': [self._note_to_dict(n) for n in notes],
'pendientes': notes.filtered(lambda n: not n.resolved).__len__(),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/notes', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def create_note(self, external_id, **kw):
try:
data = _parse_json_body()
body = (data.get('body') or '').strip()
if not body:
return json_response({'status': 'error', 'message': 'La nota no puede estar vacía'}, 400)
me = getattr(request, 'frontend_user', None)
conv = request.env['skeen.wacrm.conversation'].sudo().search(
[('external_id', '=', external_id)], limit=1)
note = request.env['skeen.wacrm.note'].sudo().create({
'conversation_external_id': external_id,
'contact_name': conv.contact_name if conv else '',
'body': body,
'author_name': getattr(me, 'name', '') or '',
'author_login': getattr(me, 'login', '') or '',
})
return json_response({'status': 'success', 'note': self._note_to_dict(note)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def update_note(self, note_id, **kw):
"""Marca/desmarca una nota como atendida, o edita su texto."""
try:
note = request.env['skeen.wacrm.note'].sudo().browse(note_id)
if not note.exists():
return json_response({'status': 'error', 'message': 'Nota no encontrada'}, 404)
data = _parse_json_body()
vals = {}
if 'body' in data:
body = (data.get('body') or '').strip()
if not body:
return json_response({'status': 'error', 'message': 'La nota no puede estar vacía'}, 400)
vals['body'] = body
if 'resolved' in data:
me = getattr(request, 'frontend_user', None)
resolved = bool(data.get('resolved'))
vals['resolved'] = resolved
vals['resolved_at'] = fields.Datetime.now() if resolved else False
vals['resolved_by'] = (getattr(me, 'name', '') or getattr(me, 'login', '')) if resolved else ''
if vals:
note.write(vals)
return json_response({'status': 'success', 'note': self._note_to_dict(note)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_note(self, note_id, **kw):
try:
note = request.env['skeen.wacrm.note'].sudo().browse(note_id)
if not note.exists():
return json_response({'status': 'error', 'message': 'Nota no encontrada'}, 404)
note.unlink()
return json_response({'status': 'success', 'message': 'Nota eliminada'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ---- Etiquetas ----
@http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_tags(self, **kw):
try:
tags = request.env['skeen.wacrm.tag'].sudo().search([('active', '=', True)])
return json_response({'status': 'success', 'tags': [self._tag_to_dict(t) for t in tags]})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def create_tag(self, **kw):
try:
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
# active_test=False: una etiqueta desactivada debe poder reactivarse
Tag = request.env['skeen.wacrm.tag'].sudo().with_context(active_test=False)
existente = Tag.search([('name', '=ilike', name)], limit=1)
if existente and existente.active:
return json_response({'status': 'error', 'message': 'Ya existe una etiqueta con ese nombre'}, 409)
if existente:
# Estaba desactivada: se reactiva en vez de fallar por el nombre
existente.write({
'active': True,
'color': data.get('color') or existente.color or '#605ca8',
'sequence': int(data.get('sequence') or existente.sequence or 10),
})
return json_response({'status': 'success', 'tag': self._tag_to_dict(existente)})
tag = Tag.create({
'name': name,
'color': data.get('color') or '#605ca8',
'sequence': int(data.get('sequence') or 10),
})
return json_response({'status': 'success', 'tag': self._tag_to_dict(tag)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def update_tag(self, tag_id, **kw):
try:
tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id)
if not tag.exists():
return json_response({'status': 'error', 'message': 'Etiqueta no encontrada'}, 404)
data = _parse_json_body()
vals = {}
if 'name' in data:
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
dup = request.env['skeen.wacrm.tag'].sudo().with_context(active_test=False).search(
[('name', '=ilike', name), ('id', '!=', tag_id), ('active', '=', True)], limit=1)
if dup:
return json_response({'status': 'error', 'message': 'Ya existe una etiqueta con ese nombre'}, 409)
vals['name'] = name
if 'color' in data:
vals['color'] = data.get('color') or '#605ca8'
if 'sequence' in data:
vals['sequence'] = int(data.get('sequence') or 10)
if 'active' in data:
vals['active'] = bool(data.get('active'))
if vals:
tag.write(vals)
return json_response({'status': 'success', 'tag': self._tag_to_dict(tag)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_tag(self, tag_id, **kw):
"""Desactiva la etiqueta (conserva el histórico de asignaciones)."""
try:
tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id)
if not tag.exists():
return json_response({'status': 'error', 'message': 'Etiqueta no encontrada'}, 404)
tag.write({'active': False})
return json_response({'status': 'success', 'message': 'Etiqueta desactivada'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def assign_tag(self, external_id, **kw):
"""Asigna o quita una etiqueta de la conversación.
Body: {"tag_id": 3, "action": "add" | "remove"} (action por defecto add).
"""
try:
data = _parse_json_body()
tag_id = int(data.get('tag_id') or 0)
tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id)
if not tag.exists():
return json_response({'status': 'error', 'message': 'Etiqueta no encontrada'}, 404)
Rel = request.env['skeen.wacrm.conversation.tag'].sudo()
action = (data.get('action') or 'add').lower()
if action == 'remove':
Rel.search([('conversation_external_id', '=', external_id),
('tag_id', '=', tag_id)]).unlink()
else:
if not Rel.search([('conversation_external_id', '=', external_id),
('tag_id', '=', tag_id)], limit=1):
me = getattr(request, 'frontend_user', None)
Rel.create({
'conversation_external_id': external_id,
'tag_id': tag_id,
'assigned_by': getattr(me, 'login', '') or '',
})
return json_response({'status': 'success', 'tags': self._tags_de(external_id)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Métricas para gerencia
# ============================================================
@http.route('/skeen/frontend/v1/wacrm/metrics', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def wacrm_metrics(self, **kw):
"""Tablero de atención de WhatsApp para gerencia.
Criterios (documentados también en la respuesta):
- "primera respuesta": minutos entre el PRIMER mensaje entrante del
cliente y el PRIMER mensaje saliente posterior en la misma
conversación. En WACRM el bot se registra como sender_type='agent',
así que cuenta como respuesta del equipo.
- "sin responder": conversaciones cuyo ÚLTIMO mensaje es del cliente y
lleva más de N horas (parámetro `sin_respuesta_horas`, 4 por defecto).
- "leads ganados/perdidos del mes": deals con status won/lost y
updated_at dentro del mes en curso.
"""
try:
# Sólo admin/gerencia: el rango de roles no distingue gerencia de
# medico (ambos 50), así que se comprueba el rol explícitamente.
if request.frontend_user.role not in ('admin', 'gerencia'):
return json_response({'status': 'error', 'message': 'Sólo gerencia puede ver las métricas'}, 403)
from statistics import median
hoy = datetime.utcnow()
horas = float(kw.get('sin_respuesta_horas') or 4)
convs = _supabase_request_paginated('conversations', {'select': '*'})
msgs = _supabase_request_paginated(
'messages', {'select': 'conversation_id,sender_type,created_at', 'order': 'created_at.asc'})
def en_rango(dt, ini, fin=None):
return dt is not None and dt >= ini and (fin is None or dt <= fin)
ini_hoy = hoy.replace(hour=0, minute=0, second=0, microsecond=0)
ini_semana = ini_hoy - timedelta(days=ini_hoy.weekday())
ini_mes = ini_hoy.replace(day=1)
creadas_hoy = creadas_semana = creadas_mes = 0
for c in convs:
dt = _parse_datetime(c.get('created_at'))
if not dt:
continue
if dt >= ini_hoy:
creadas_hoy += 1
if dt >= ini_semana:
creadas_semana += 1
if dt >= ini_mes:
creadas_mes += 1
entrantes_hoy = salientes_hoy = 0
entrantes_semana = salientes_semana = 0
entrantes_mes = salientes_mes = 0
for m in msgs:
dt = _parse_datetime(m.get('created_at'))
if not dt:
continue
es_in = m.get('sender_type') == 'customer'
if dt >= ini_hoy:
entrantes_hoy += 1 if es_in else 0
salientes_hoy += 0 if es_in else 1
if dt >= ini_semana:
entrantes_semana += 1 if es_in else 0
salientes_semana += 0 if es_in else 1
if dt >= ini_mes:
entrantes_mes += 1 if es_in else 0
salientes_mes += 0 if es_in else 1
# Primera respuesta por conversación
por_conv = {}
for m in msgs:
dt = _parse_datetime(m.get('created_at'))
if not dt:
continue
por_conv.setdefault(m.get('conversation_id'), []).append((dt, m.get('sender_type')))
tiempos = []
for cid, lista in por_conv.items():
lista.sort(key=lambda x: x[0])
primera_in = next((dt for dt, st in lista if st == 'customer'), None)
if not primera_in:
continue
respuesta = next((dt for dt, st in lista if st != 'customer' and dt >= primera_in), None)
if respuesta:
tiempos.append((respuesta - primera_in).total_seconds() / 60.0)
sin_responder = 0
detalle_sin_responder = []
for c in convs:
lista = por_conv.get(c.get('id'))
if not lista:
continue
ultimo_dt, ultimo_st = max(lista, key=lambda x: x[0])
if ultimo_st != 'customer':
continue
horas_esperando = (hoy - ultimo_dt).total_seconds() / 3600.0
if horas_esperando >= horas:
sin_responder += 1
detalle_sin_responder.append({
'conversation_id': c.get('id'),
'horas': round(horas_esperando, 1),
'ultimo_mensaje': (c.get('last_message_text') or '')[:120],
})
detalle_sin_responder.sort(key=lambda d: -d['horas'])
# Leads del mes
deals = _supabase_request_paginated('deals', {'select': 'id,status,created_at,updated_at,title,value'})
ganados = perdidos = abiertos = 0
valor_ganado = 0.0
for d in deals:
dt = _parse_datetime(d.get('updated_at')) or _parse_datetime(d.get('created_at'))
if not dt or dt < ini_mes:
continue
st = d.get('status')
if st == 'won':
ganados += 1
valor_ganado += float(d.get('value') or 0)
elif st == 'lost':
perdidos += 1
elif st == 'open':
abiertos += 1
return json_response({
'status': 'success',
'generado': hoy.strftime('%Y-%m-%d %H:%M'),
'periodo': {'desde': ini_mes.strftime('%Y-%m-%d'), 'hasta': hoy.strftime('%Y-%m-%d')},
'sin_respuesta_horas': horas,
'conversaciones': {
'hoy': creadas_hoy,
'semana': creadas_semana,
'mes': creadas_mes,
'total': len(convs),
},
'mensajes': {
'hoy': {'entrantes': entrantes_hoy, 'salientes': salientes_hoy},
'semana': {'entrantes': entrantes_semana, 'salientes': salientes_semana},
'mes': {'entrantes': entrantes_mes, 'salientes': salientes_mes},
},
'primera_respuesta': {
'muestras': len(tiempos),
'promedio_min': round(sum(tiempos) / len(tiempos), 1) if tiempos else None,
'mediana_min': round(median(tiempos), 1) if tiempos else None,
},
'sin_responder': {
'total': sin_responder,
'horas_minimas': horas,
'detalle': detalle_sin_responder[:20],
},
'leads': {
'ganados': ganados,
'perdidos': perdidos,
'abiertos': abiertos,
'valor_ganado': round(valor_ganado, 2),
},
'criterios': {
'primera_respuesta': 'Minutos entre el primer mensaje entrante del cliente y el primer mensaje saliente posterior en la misma conversación. En WACRM el bot se registra como "agent", así que cuenta como respuesta del equipo.',
'sin_responder': f'Conversaciones cuyo último mensaje es del cliente y lleva más de {horas:g} h sin respuesta.',
'leads': 'Deals con status won/lost y updated_at dentro del mes en curso.',
},
})
except Exception as e:
_logger.exception('Error calculando métricas WACRM')
return json_response({'status': 'error', 'message': str(e)}, 500)
def _deal_to_dict(self, d): def _deal_to_dict(self, d):
return { return {
'id': d.id, 'id': d.id,

View File

@@ -2,3 +2,4 @@ from . import main
from . import wacrm_sync from . import wacrm_sync
from . import frontend_user from . import frontend_user
from . import config_audit from . import config_audit
from . import wacrm_notes

View File

@@ -27,6 +27,10 @@ class SkeenFrontendUser(models.Model):
active = fields.Boolean(string='Activo', default=True) active = fields.Boolean(string='Activo', default=True)
must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False) must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False)
last_login = fields.Datetime(string='Último acceso', readonly=True) last_login = fields.Datetime(string='Último acceso', readonly=True)
# Presencia: se actualiza con cada llamada autenticada del frontend
# (heartbeat explícito cada ~30 s y en cada endpoint de chat).
# "En línea" = actividad en los últimos 2 minutos.
last_seen = fields.Datetime(string='Última actividad', readonly=True, index=True)
notes = fields.Text(string='Notas') notes = fields.Text(string='Notas')
allowed_menus = fields.Text(string='Menús Permitidos', allowed_menus = fields.Text(string='Menús Permitidos',
help='JSON array de hrefs visibles; vacío = defaults por rol') help='JSON array de hrefs visibles; vacío = defaults por rol')

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class SkeenWacrmNote(models.Model):
"""Nota interna de una conversación de WhatsApp.
Se liga por `external_id` de la conversación (NO por many2one al caché
local) a propósito: el sync borra y recrea el caché de conversaciones y
mensajes, y las notas deben sobrevivir. Nunca se envía al paciente.
"""
_name = 'skeen.wacrm.note'
_description = 'Nota interna de conversación WACRM'
_order = 'create_date desc, id desc'
conversation_external_id = fields.Char(string='ID conversación WACRM',
required=True, index=True)
contact_name = fields.Char(string='Contacto', help='Sólo para mostrar en la lista')
body = fields.Text(string='Nota', required=True)
author_name = fields.Char(string='Autor')
author_login = fields.Char(string='Autor (usuario)')
resolved = fields.Boolean(string='Atendida', default=False)
resolved_at = fields.Datetime(string='Fecha de atención')
resolved_by = fields.Char(string='Atendida por')
# create_date (heredado) = fecha de la nota
class SkeenWacrmTag(models.Model):
"""Catálogo de etiquetas de conversación (urgente, cotización, etc.)."""
_name = 'skeen.wacrm.tag'
_description = 'Etiqueta de conversación WACRM'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True)
color = fields.Char(string='Color', default='#605ca8')
sequence = fields.Integer(string='Orden', default=10)
active = fields.Boolean(string='Activa', default=True)
_sql_constraints = [
('unique_name', 'unique(name)', 'Ya existe una etiqueta con ese nombre.'),
]
class SkeenWacrmConversationTag(models.Model):
"""Asignación etiqueta ↔ conversación, por external_id (sobrevive al sync)."""
_name = 'skeen.wacrm.conversation.tag'
_description = 'Etiqueta asignada a una conversación WACRM'
_order = 'id'
conversation_external_id = fields.Char(string='ID conversación WACRM',
required=True, index=True)
tag_id = fields.Many2one('skeen.wacrm.tag', string='Etiqueta',
required=True, ondelete='cascade', index=True)
assigned_by = fields.Char(string='Asignada por')
_sql_constraints = [
('unique_conv_tag', 'unique(conversation_external_id, tag_id)',
'Esa etiqueta ya está asignada a la conversación.'),
]

View File

@@ -7,3 +7,6 @@ access_skeen_wacrm_stage,skeen.wacrm.stage,model_skeen_wacrm_stage,base.group_us
access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1 access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1
access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1 access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1
access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group_user,1,1,1,1 access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group_user,1,1,1,1
access_skeen_wacrm_note,Notas internas WACRM,model_skeen_wacrm_note,base.group_user,1,1,1,1
access_skeen_wacrm_tag,Etiquetas WACRM,model_skeen_wacrm_tag,base.group_user,1,1,1,1
access_skeen_wacrm_conversation_tag,Etiquetas por conversacion,model_skeen_wacrm_conversation_tag,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_skeen_wacrm_deal skeen.wacrm.deal model_skeen_wacrm_deal base.group_user 1 1 1 1
8 access_skeen_frontend_user skeen.frontend.user model_skeen_frontend_user base.group_user 1 1 1 1
9 access_skeen_config_audit skeen.config.audit model_skeen_config_audit base.group_user 1 1 1 1
10 access_skeen_wacrm_note Notas internas WACRM model_skeen_wacrm_note base.group_user 1 1 1 1
11 access_skeen_wacrm_tag Etiquetas WACRM model_skeen_wacrm_tag base.group_user 1 1 1 1
12 access_skeen_wacrm_conversation_tag Etiquetas por conversacion model_skeen_wacrm_conversation_tag base.group_user 1 1 1 1