- 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
630 lines
25 KiB
TypeScript
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;
|