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:
2026-08-13 23:30:08 +00:00
parent a718592291
commit 01f6007e30
109 changed files with 19067 additions and 931 deletions

View File

@@ -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>
);