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;