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:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View 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;