Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
501
frontend/src/pages/WacrmLeads.tsx
Normal file
501
frontend/src/pages/WacrmLeads.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
RefreshCw,
|
||||
Target,
|
||||
Search,
|
||||
TrendingUp,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Circle,
|
||||
LayoutGrid,
|
||||
List,
|
||||
MoreHorizontal,
|
||||
User,
|
||||
ArrowRightCircle,
|
||||
} 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';
|
||||
|
||||
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 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]);
|
||||
|
||||
// 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(() => {
|
||||
if (!activePipeline) return leads;
|
||||
return leads.filter((l) => l.pipeline_id === activePipeline.id);
|
||||
}, [leads, activePipeline]);
|
||||
|
||||
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>
|
||||
</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-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 cambiar etapa */}
|
||||
<Modal isOpen={stageModalOpen} onClose={() => setStageModalOpen(false)} title="Mover a etapa">
|
||||
<div className="space-y-2 max-h-96 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>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default WacrmLeads;
|
||||
Reference in New Issue
Block a user