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;
|
||||
Reference in New Issue
Block a user