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:
214
frontend/src/components/wacrm/ConversationEtiquetas.tsx
Normal file
214
frontend/src/components/wacrm/ConversationEtiquetas.tsx
Normal 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;
|
||||||
142
frontend/src/components/wacrm/ConversationNotas.tsx
Normal file
142
frontend/src/components/wacrm/ConversationNotas.tsx
Normal 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;
|
||||||
183
frontend/src/components/wacrm/WacrmMetrics.tsx
Normal file
183
frontend/src/components/wacrm/WacrmMetrics.tsx
Normal 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 > {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;
|
||||||
@@ -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]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
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);
|
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,9 +254,20 @@ 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">
|
||||||
|
<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)}
|
{initials(u.name)}
|
||||||
</span>
|
</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 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">
|
||||||
<span className="truncate text-sm font-medium text-theme-heading">{u.name}</span>
|
<span className="truncate text-sm font-medium text-theme-heading">{u.name}</span>
|
||||||
@@ -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">
|
||||||
|
<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)}
|
{initials(activeUser.name)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<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>
|
||||||
|
<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
|
<div
|
||||||
className={`max-w-[75%] rounded-2xl px-3.5 py-2 text-sm shadow-card ${
|
key={m.id}
|
||||||
|
ref={highlightId === m.id ? highlightRef : undefined}
|
||||||
|
data-msg-id={m.id}
|
||||||
|
className={`flex ${m.mine ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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'
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -584,6 +720,8 @@ const WacrmMessages: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<p className="text-sm text-theme-muted">Selecciona una conversación</p>
|
<p className="text-sm text-theme-muted">Selecciona una conversación</p>
|
||||||
@@ -638,6 +776,8 @@ const WacrmMessages: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,14 +167,18 @@ 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')
|
||||||
|
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')
|
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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
request.env.cr.execute(
|
||||||
|
"SELECT pg_try_advisory_xact_lock(hashtext('skeen_wacrm_sync'))")
|
||||||
|
libre = request.env.cr.fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
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:
|
try:
|
||||||
result = self._do_sync()
|
result = self._do_sync()
|
||||||
return json_response({'status': 'success', 'result': result})
|
return json_response({'status': 'success', 'result': result})
|
||||||
except Exception as e:
|
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')
|
_logger.exception('Error sincronizando WACRM')
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
60
odoo-addons/skeen_whatsapp/models/wacrm_notes.py
Normal file
60
odoo-addons/skeen_whatsapp/models/wacrm_notes.py
Normal 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.'),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
|
Reference in New Issue
Block a user