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
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
RefreshCw,
|
||||
Target,
|
||||
@@ -13,11 +14,19 @@ import {
|
||||
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' },
|
||||
@@ -66,6 +75,12 @@ const WacrmLeads: FC = () => {
|
||||
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 {
|
||||
@@ -136,6 +151,46 @@ const WacrmLeads: FC = () => {
|
||||
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(() => {
|
||||
@@ -155,9 +210,12 @@ const WacrmLeads: FC = () => {
|
||||
);
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
if (!activePipeline) return leads;
|
||||
return leads.filter((l) => l.pipeline_id === activePipeline.id);
|
||||
}, [leads, activePipeline]);
|
||||
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');
|
||||
@@ -291,6 +349,20 @@ const WacrmLeads: FC = () => {
|
||||
<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">
|
||||
@@ -422,6 +494,24 @@ const WacrmLeads: FC = () => {
|
||||
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
|
||||
@@ -468,31 +558,69 @@ const WacrmLeads: FC = () => {
|
||||
</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 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user