Files
SKEEN-Proyecto/frontend/src/pages/WacrmLeads.tsx
Consultoría Alcaraz Salazar 01f6007e30 Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- Módulo Visitas completo (auto desde agenda, insumos con descargo de
  inventario, fotos antes/después y documentos, receta imprimible)
- Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con
  puntos monedero, ticket imprimible)
- WACRM: leads automáticos desde WhatsApp, asignación de conversaciones
  y leads a agentes, conversión lead→paciente, ficha del paciente en chat
- Pacientes: completitud de expediente, alertas clínicas, historial
  unificado con detalle, foto, WhatsApp, estado de cuenta, filtros
  rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería)
- Agenda: vistas por médico y por hora, filtros rápidos (libres, primera
  vez, check-in, no-show), modal de acciones, bloqueos por médico,
  drag&drop para mover citas
- Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos,
  comisiones, pagos, devoluciones, top clientes, horas, paquetes,
  vendedores, concentrado, recomendaciones, KPIs) con exportación Excel
- Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo
  treeview, selector de tema; accesos rápidos personalizables con 3
  presentaciones; búsqueda global; notificaciones reales
- Configuración: secciones (clínica, usuarios con permisos por sección,
  recetas, catálogos de diagnósticos y procedimientos)
- Inventario: alertas de caducidad y sugerencia de compra, cron diario
  que descuenta artículos caducados, compras/bajas
- Consultas Médicas, página Expedientes, importadores delta
  (citas/visitas legacy idempotentes), depuración de duplicados
- Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias
  Odoo corregidas (noupdate, company_id), rollback en validaciones
2026-08-13 23:30:08 +00:00

630 lines
25 KiB
TypeScript

import type { FC } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
RefreshCw,
Target,
Search,
TrendingUp,
CheckCircle,
XCircle,
Circle,
LayoutGrid,
List,
MoreHorizontal,
User,
ArrowRightCircle,
UserPlus,
MessageSquare,
} from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, Select, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
import { odooApi, type WacrmLead, type WacrmPipeline } from '../services/odoo';
interface Member {
id: string;
name: string;
role: string;
}
const statusOptions = [
{ value: '', label: 'Todos los estados' },
{ value: 'open', label: 'Abierto' },
{ value: 'won', label: 'Ganado' },
{ value: 'lost', label: 'Perdido' },
];
const statusBadge = (status: string) => {
switch (status) {
case 'won':
return 'success';
case 'lost':
return 'danger';
default:
return 'primary';
}
};
const statusIcon = (status: string) => {
switch (status) {
case 'won':
return <CheckCircle size={14} className="mr-1" />;
case 'lost':
return <XCircle size={14} className="mr-1" />;
default:
return <Circle size={14} className="mr-1" />;
}
};
const currencySymbol = (currency: string) => {
if (currency === 'MXN') return '$';
if (currency === 'USD') return 'US$';
return currency;
};
const WacrmLeads: FC = () => {
const [leads, setLeads] = useState<WacrmLead[]>([]);
const [pipelines, setPipelines] = useState<WacrmPipeline[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
const [pipelineId, setPipelineId] = useState('');
const [view, setView] = useState<'list' | 'kanban'>('kanban');
const [lastSync, setLastSync] = useState<Date | null>(null);
const [selectedLead, setSelectedLead] = useState<WacrmLead | null>(null);
const [stageModalOpen, setStageModalOpen] = useState(false);
const [movingStage, setMovingStage] = useState<string | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [filtroAsign, setFiltroAsign] = useState<'todos' | 'sin_asignar' | 'mios'>('todos');
const [convirtiendo, setConvirtiendo] = useState(false);
const [currentAgentId] = useState(() =>
typeof window !== 'undefined' ? localStorage.getItem('skeen_current_agent_id') || '' : ''
);
const load = useCallback(async () => {
try {
setLoading(true);
const [leadsRes, pipelinesRes] = await Promise.all([
odooApi.getWacrmLeads(search || undefined, status || undefined, pipelineId || undefined),
odooApi.getWacrmPipelines(),
]);
if (leadsRes.status === 'success') setLeads(leadsRes.leads);
if (pipelinesRes.status === 'success') setPipelines(pipelinesRes.pipelines);
setLastSync(new Date());
} catch (err) {
toast.error('Error al cargar leads');
console.error(err);
} finally {
setLoading(false);
}
}, [search, status, pipelineId]);
const sync = async () => {
try {
setSyncing(true);
const res = await odooApi.syncWacrm();
if (res.status === 'success') {
toast.success(`Sincronizado: ${res.result.deals || 0} deals`);
await load();
}
} catch (err) {
toast.error('Error al sincronizar con WACRM');
console.error(err);
} finally {
setSyncing(false);
}
};
const updateStatus = async (lead: WacrmLead, newStatus: 'open' | 'won' | 'lost') => {
try {
const res = await odooApi.updateWacrmLeadStatus(lead.id, { status: newStatus });
if (res.status === 'success') {
toast.success(`Lead marcado como ${newStatus}`);
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
}
} catch (err) {
toast.error('Error al actualizar lead');
console.error(err);
}
};
const changeStage = async (lead: WacrmLead, stageExternalId: string) => {
try {
setMovingStage(stageExternalId);
const res = await odooApi.updateWacrmLeadStatus(lead.id, { stage_id: stageExternalId });
if (res.status === 'success') {
toast.success('Etapa actualizada');
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
setStageModalOpen(false);
setSelectedLead(null);
}
} catch (err) {
toast.error('Error al cambiar etapa');
console.error(err);
} finally {
setMovingStage(null);
}
};
useEffect(() => {
load();
}, [load]);
useEffect(() => {
odooApi.getWacrmMembers()
.then((res) => { if (res.status === 'success') setMembers(res.members); })
.catch(() => {});
}, []);
const assignLead = async (lead: WacrmLead, memberId: string | null) => {
try {
const res = await odooApi.updateWacrmLeadStatus(lead.id, { assigned_to: memberId });
if (res.status === 'success') {
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
setSelectedLead(res.lead);
toast.success(memberId ? 'Lead asignado' : 'Lead desasignado');
}
} catch (err) {
toast.error('Error al asignar lead');
console.error(err);
}
};
const convertirLead = async (lead: WacrmLead) => {
try {
setConvirtiendo(true);
const res = await odooApi.convertWacrmLead(lead.id);
if (res.status === 'success') {
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
setSelectedLead(res.lead);
toast.success(res.created ? 'Paciente creado y ligado' : 'Lead ligado a paciente existente');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al convertir lead');
console.error(err);
} finally {
setConvirtiendo(false);
}
};
const memberName = (id?: string) => (id ? members.find((m) => m.id === id)?.name : undefined);
// Auto-sync cada 15 segundos
useEffect(() => {
const interval = setInterval(() => {
load();
}, 15000);
return () => clearInterval(interval);
}, [load]);
const pipelineOptions = useMemo(
() => [{ value: '', label: 'Todos los pipelines' }, ...pipelines.map((p) => ({ value: p.external_id, label: p.name }))],
[pipelines]
);
const activePipeline = useMemo(
() => pipelines.find((p) => p.external_id === pipelineId) || pipelines[0],
[pipelines, pipelineId]
);
const filteredLeads = useMemo(() => {
let out = leads;
if (activePipeline) out = out.filter((l) => l.pipeline_id === activePipeline.id);
if (filtroAsign === 'sin_asignar') out = out.filter((l) => !l.assigned_to);
if (filtroAsign === 'mios') out = out.filter((l) => !!currentAgentId && l.assigned_to === currentAgentId);
return out;
}, [leads, activePipeline, filtroAsign, currentAgentId]);
const stats = useMemo(() => {
const open = leads.filter((l) => l.status === 'open');
const won = leads.filter((l) => l.status === 'won');
const lost = leads.filter((l) => l.status === 'lost');
return {
total: leads.length,
openValue: open.reduce((sum, l) => sum + (l.value || 0), 0),
wonValue: won.reduce((sum, l) => sum + (l.value || 0), 0),
lostValue: lost.reduce((sum, l) => sum + (l.value || 0), 0),
openCount: open.length,
wonCount: won.length,
lostCount: lost.length,
};
}, [leads]);
const renderList = () => (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Lead</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Contacto</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Pipeline / Etapa</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Valor</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{filteredLeads.map((l) => (
<tr key={l.external_id} className="hover:bg-theme-bg">
<td className="p-3">
<p className="text-sm font-medium text-theme-heading">{l.title}</p>
<p className="text-xs text-theme-muted line-clamp-1">{l.notes || 'Sin notas'}</p>
</td>
<td className="p-3 hidden sm:table-cell">
<p className="text-sm text-theme-heading">{l.contact_name || '—'}</p>
<p className="text-xs text-theme-muted">{l.contact_phone || '—'}</p>
</td>
<td className="p-3 hidden md:table-cell">
<p className="text-xs text-theme-muted">{l.pipeline || '—'}</p>
<button
onClick={() => {
setSelectedLead(l);
setStageModalOpen(true);
}}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium hover:opacity-80 transition"
style={{ backgroundColor: `${l.stage_color}20`, color: l.stage_color }}
>
{l.stage || '—'} <ArrowRightCircle size={12} className="ml-1" />
</button>
</td>
<td className="p-3">
<p className="text-sm font-medium text-theme-heading">
{currencySymbol(l.currency)}{l.value.toLocaleString()}
</p>
</td>
<td className="p-3">
<Badge variant={statusBadge(l.status)}>
{statusIcon(l.status)}
{l.status}
</Badge>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
{l.status !== 'won' && (
<button
onClick={() => updateStatus(l, 'won')}
className="p-1.5 text-theme-heading hover:bg-theme-success rounded"
title="Marcar ganado"
>
<CheckCircle size={16} />
</button>
)}
{l.status !== 'lost' && (
<button
onClick={() => updateStatus(l, 'lost')}
className="p-1.5 text-rose-600 hover:bg-rose-50 rounded"
title="Marcar perdido"
>
<XCircle size={16} />
</button>
)}
{l.status !== 'open' && (
<button
onClick={() => updateStatus(l, 'open')}
className="p-1.5 text-theme-muted hover:bg-theme-bg rounded"
title="Reabrir"
>
<Circle size={16} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
const renderKanban = () => {
if (!activePipeline) return <EmptyState title="Sin pipeline" subtitle="Selecciona un pipeline para ver el Kanban." />;
return (
<div className="overflow-x-auto pb-2">
<div className="flex gap-4 min-w-max">
{activePipeline.stages.map((stage) => {
const stageLeads = filteredLeads.filter((l) => l.stage_id === stage.id);
const stageValue = stageLeads.reduce((sum, l) => sum + (l.value || 0), 0);
return (
<div key={stage.external_id} className="w-72 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: stage.color }} />
<h3 className="text-sm font-semibold text-theme-heading">{stage.name}</h3>
<span className="text-xs text-theme-muted bg-theme-bg px-1.5 py-0.5 rounded-full">{stageLeads.length}</span>
</div>
</div>
<div className="space-y-3">
{stageLeads.map((l) => (
<div key={l.external_id} style={{ borderLeftColor: stage.color }}>
<Card className="border-l-4">
<Card.Body className="p-3">
<div className="flex items-start justify-between gap-2 mb-2">
<p className="text-sm font-medium text-theme-heading line-clamp-2">{l.title}</p>
<Badge variant={statusBadge(l.status)} className="text-[10px] px-1.5 py-0">
{statusIcon(l.status)}
</Badge>
</div>
<div className="flex items-center gap-1.5 text-xs text-theme-muted mb-2">
<User size={12} />
<span className="truncate">{l.contact_name || l.contact_phone || '—'}</span>
{l.assigned_to && (
<span className="px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full truncate max-w-[100px]">
{memberName(l.assigned_to) || 'Asignado'}
</span>
)}
{l.partner_id && (
<Link
to={`/pacientes?q=${encodeURIComponent(l.contact_phone || '')}`}
className="px-1.5 py-0.5 bg-theme-success text-theme-heading rounded-full shrink-0"
onClick={(e) => e.stopPropagation()}
>
Paciente ✓
</Link>
)}
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-theme-heading">
{currencySymbol(l.currency)}{l.value.toLocaleString()}
</p>
<button
onClick={() => {
setSelectedLead(l);
setStageModalOpen(true);
}}
className="p-1 text-theme-muted hover:text-theme-heading hover:bg-theme-bg rounded"
>
<MoreHorizontal size={16} />
</button>
</div>
<div className="flex items-center gap-1 mt-2">
{l.status !== 'won' && (
<button
onClick={() => updateStatus(l, 'won')}
className="flex-1 py-1 text-[10px] font-medium text-theme-heading bg-theme-success hover:bg-theme-success rounded"
>
Ganar
</button>
)}
{l.status !== 'lost' && (
<button
onClick={() => updateStatus(l, 'lost')}
className="flex-1 py-1 text-[10px] font-medium text-rose-700 bg-rose-50 hover:bg-rose-100 rounded"
>
Perder
</button>
)}
</div>
</Card.Body>
</Card>
</div>
))}
{stageLeads.length === 0 && (
<div className="text-center py-6 text-xs text-theme-muted border border-dashed border-theme-border-strong rounded-lg">
Sin leads en esta etapa
</div>
)}
</div>
{stageValue > 0 && (
<p className="mt-2 text-xs text-theme-muted text-right">
Total: {currencySymbol(activePipeline.stages[0]?.color ? 'MXN' : 'MXN')}{stageValue.toLocaleString()}
</p>
)}
</div>
);
})}
</div>
</div>
);
};
return (
<Layout title="WACRM — Leads" subtitle="Deals y oportunidades del CRM">
{/* Métricas */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4 sm:mb-6">
<Card>
<Card.Body className="flex items-center gap-3">
<div className="p-2 bg-theme-bg rounded-lg">
<Target size={20} className="text-theme-heading" />
</div>
<div>
<p className="text-xs text-theme-muted">Total leads</p>
<p className="text-lg font-semibold text-theme-heading">{stats.total}</p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<TrendingUp size={20} className="text-blue-600" />
</div>
<div>
<p className="text-xs text-theme-muted">Valor abierto</p>
<p className="text-lg font-semibold text-theme-heading">${stats.openValue.toLocaleString()}</p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body className="flex items-center gap-3">
<div className="p-2 bg-theme-success rounded-lg">
<CheckCircle size={20} className="text-theme-heading" />
</div>
<div>
<p className="text-xs text-theme-muted">Ganados</p>
<p className="text-lg font-semibold text-theme-heading">{stats.wonCount} <span className="text-xs font-normal text-theme-muted">(${stats.wonValue.toLocaleString()})</span></p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body className="flex items-center gap-3">
<div className="p-2 bg-rose-50 rounded-lg">
<XCircle size={20} className="text-rose-600" />
</div>
<div>
<p className="text-xs text-theme-muted">Perdidos</p>
<p className="text-lg font-semibold text-theme-heading">{stats.lostCount}</p>
</div>
</Card.Body>
</Card>
</div>
<Card>
<Card.Body>
{/* Filtros */}
<div className="flex flex-col lg:flex-row gap-3 mb-4">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar lead o contacto..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select
value={status}
onChange={(e) => setStatus(e.target.value)}
options={statusOptions}
className="w-full lg:w-44"
/>
<Select
value={pipelineId}
onChange={(e) => setPipelineId(e.target.value)}
options={pipelineOptions}
className="w-full lg:w-52"
/>
<div className="flex items-center gap-1.5">
{([
{ key: 'todos', label: 'Todos' },
{ key: 'sin_asignar', label: 'Sin asignar' },
{ key: 'mios', label: 'Míos' },
] as const).map((f) => (
<button
key={f.key}
type="button"
onClick={() => setFiltroAsign(f.key)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
filtroAsign === f.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{f.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<div className="inline-flex rounded-lg border border-theme-border-strong overflow-hidden">
<button
onClick={() => setView('kanban')}
className={`px-3 py-2 flex items-center gap-1.5 text-sm ${view === 'kanban' ? 'bg-[#2D2D2D] text-white' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'}`}
>
<LayoutGrid size={16} /> Kanban
</button>
<button
onClick={() => setView('list')}
className={`px-3 py-2 flex items-center gap-1.5 text-sm ${view === 'list' ? 'bg-[#2D2D2D] text-white' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'}`}
>
<List size={16} /> Lista
</button>
</div>
<Button onClick={sync} loading={syncing}>
<RefreshCw size={16} className="mr-2" />
Sincronizar
</Button>
</div>
</div>
{lastSync && (
<p className="text-xs text-theme-muted mb-3">
Última sincronización: {lastSync.toLocaleTimeString()} · se actualiza automáticamente cada 15s
</p>
)}
{loading && leads.length === 0 ? (
<Skeleton count={6} className="h-14 w-full" />
) : filteredLeads.length === 0 ? (
<EmptyState
title="Sin leads"
subtitle="No hay deals sincronizados. Presiona Sincronizar para traer datos de WACRM."
icon={<Target size={28} />}
actionLabel="Sincronizar"
onAction={sync}
/>
) : view === 'list' ? (
renderList()
) : (
renderKanban()
)}
</Card.Body>
</Card>
{/* Modal detalle del lead */}
<Modal isOpen={stageModalOpen} onClose={() => setStageModalOpen(false)} title={selectedLead ? `Lead — ${selectedLead.title}` : 'Lead'}>
{selectedLead && (
<div className="space-y-4">
{/* Asignación y acciones */}
<div className="space-y-3">
<Select
label="Asignado a"
options={[{ value: '', label: 'Sin asignar' }, ...members.map((m) => ({ value: m.id, label: m.name }))]}
value={selectedLead.assigned_to || ''}
onChange={(e) => assignLead(selectedLead, e.target.value || null)}
/>
<div className="flex flex-wrap gap-2">
{selectedLead.partner_id ? (
<Link to={`/pacientes?q=${encodeURIComponent(selectedLead.contact_phone || '')}`}>
<Badge variant="success">Paciente ✓ — ver expediente</Badge>
</Link>
) : (
<Button variant="outline" size="sm" onClick={() => convertirLead(selectedLead)} loading={convirtiendo}>
<UserPlus size={14} className="mr-1.5" />
Convertir en paciente
</Button>
)}
{selectedLead.conversation_id && (
<Link to={`/wacrm/messages?conv=${selectedLead.conversation_id}`}>
<Button variant="outline" size="sm">
<MessageSquare size={14} className="mr-1.5" />
Ver conversación
</Button>
</Link>
)}
</div>
</div>
{/* Mover a etapa */}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Mover a etapa</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{activePipeline?.stages.map((stage) => {
const isCurrent = selectedLead?.stage_id === stage.id;
return (
<button
key={stage.external_id}
disabled={isCurrent || !!movingStage}
onClick={() => selectedLead && changeStage(selectedLead, stage.external_id)}
className={`w-full flex items-center justify-between p-3 rounded-lg border text-left transition ${
isCurrent
? 'bg-theme-bg border-theme-border-strong text-theme-muted cursor-not-allowed'
: 'bg-theme-surface border-theme-border-strong hover:border-[#D6D3D1] hover:bg-theme-bg'
}`}
>
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: stage.color }} />
<span className="text-sm font-medium text-theme-heading">{stage.name}</span>
</div>
{isCurrent && <span className="text-xs text-theme-muted">Actual</span>}
</button>
);
})}
</div>
</div>
</div>
)}
</Modal>
</Layout>
);
};
export default WacrmLeads;