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

601
frontend/src/pages/Pos.tsx Normal file
View File

@@ -0,0 +1,601 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2,
} from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
Select,
Modal,
EmptyState,
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Service, type Patient, type PosCheckoutResult } from '../services/odoo';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
const METODO_OPTIONS = [
{ value: 'cash', label: 'Efectivo' },
{ value: 'card', label: 'Tarjeta' },
{ value: 'transfer', label: 'Transferencia' },
{ value: 'stripe', label: 'Stripe' },
{ value: 'mercadopago', label: 'MercadoPago' },
];
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
interface TicketLine {
service: Service;
qty: number;
price: number;
}
const Pos: FC = () => {
// Catálogo
const [services, setServices] = useState<Service[]>([]);
const [catalogLoading, setCatalogLoading] = useState(true);
const [search, setSearch] = useState('');
const [categoria, setCategoria] = useState('');
const [soloFavoritos, setSoloFavoritos] = useState(false);
// Ticket
const [lines, setLines] = useState<TicketLine[]>([]);
const [discount, setDiscount] = useState('');
const [paciente, setPaciente] = useState<Patient | null>(null);
const [pacienteQuery, setPacienteQuery] = useState('');
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
const [altaRapida, setAltaRapida] = useState(false);
const [altaForm, setAltaForm] = useState({ name: '', phone: '' });
// Cobro
const [cobroOpen, setCobroOpen] = useState(false);
const [metodo, setMetodo] = useState('cash');
const [recibido, setRecibido] = useState('');
const [conPuntos, setConPuntos] = useState(false);
const [cobrando, setCobrando] = useState(false);
// Resultado
const [resultado, setResultado] = useState<PosCheckoutResult | null>(null);
// Corte de caja
const [cajaAbierta, setCajaAbierta] = useState(true);
// Mobile
const [ticketMovil, setTicketMovil] = useState(false);
// ---- Catálogo ----
const loadServices = useCallback(async () => {
try {
setCatalogLoading(true);
const res = await odooApi.getServices({ search: search || undefined, category: categoria || undefined, page_size: 200 });
if (res.status === 'success') setServices(res.services);
} catch (err) {
toast.error('Error al cargar servicios');
console.error(err);
} finally {
setCatalogLoading(false);
}
}, [search, categoria]);
useEffect(() => {
const t = setTimeout(loadServices, 300);
return () => clearTimeout(t);
}, [loadServices]);
useEffect(() => {
odooApi.getCashClosings(new Date().toISOString().split('T')[0])
.then((res) => {
if (res.status === 'success') setCajaAbierta(res.cash_closings.some((c) => c.state === 'open'));
})
.catch(() => {});
}, []);
// Búsqueda server-side de paciente
useEffect(() => {
if (paciente || altaRapida) return;
if (pacienteQuery.trim().length < 2) {
setPacienteResults([]);
return;
}
const t = setTimeout(() => {
odooApi.getPatients({ search: pacienteQuery.trim(), page_size: 8 })
.then((res) => { if (res.status === 'success') setPacienteResults(res.patients); })
.catch(() => {});
}, 300);
return () => clearTimeout(t);
}, [pacienteQuery, paciente, altaRapida]);
const categorias = useMemo(() => {
const set = new Map<string, string>();
services.forEach((s) => { if (s.category) set.set(s.category, s.category); });
return [...set.keys()];
}, [services]);
const visibles = useMemo(
() => (soloFavoritos ? services.filter((s) => s.is_favorite) : services),
[services, soloFavoritos]
);
// ---- Ticket ----
const addLine = (service: Service) => {
setLines((prev) => {
const found = prev.find((l) => l.service.id === service.id);
if (found) return prev.map((l) => (l.service.id === service.id ? { ...l, qty: l.qty + 1 } : l));
return [...prev, { service, qty: 1, price: service.price }];
});
};
const updateLine = (id: number, patch: Partial<TicketLine>) => {
setLines((prev) => prev.map((l) => (l.service.id === id ? { ...l, ...patch } : l)));
};
const removeLine = (id: number) => setLines((prev) => prev.filter((l) => l.service.id !== id));
const limpiarTicket = () => {
if (lines.length === 0) return;
if (window.confirm('¿Vaciar el ticket?')) setLines([]);
};
const subtotal = lines.reduce((a, l) => a + l.qty * l.price, 0);
const discountNum = Math.min(parseFloat(discount) || 0, subtotal);
const total = Math.max(0, subtotal - discountNum);
const numItems = lines.reduce((a, l) => a + l.qty, 0);
const puntosPaciente = paciente?.wallet_points ?? 0;
const puedePuntos = puntosPaciente >= total && total > 0;
const cambio = metodo === 'cash' && recibido ? Math.max(0, (parseFloat(recibido) || 0) - total) : 0;
const crearPacienteRapido = async () => {
if (!altaForm.name.trim() || !altaForm.phone.trim()) {
toast.error('Nombre y teléfono son obligatorios');
return;
}
try {
const res = await odooApi.createPatient({ name: altaForm.name.trim(), phone: altaForm.phone.trim() });
if (res.status === 'success') {
setPaciente(res.patient);
setAltaRapida(false);
setAltaForm({ name: '', phone: '' });
toast.success('Paciente creado');
}
} catch (err) {
toast.error('Error al crear paciente');
console.error(err);
}
};
const abrirCobro = () => {
if (!paciente) {
toast.error('Selecciona un paciente');
return;
}
if (lines.length === 0) {
toast.error('El ticket está vacío');
return;
}
setMetodo('cash');
setRecibido('');
setConPuntos(false);
setCobroOpen(true);
};
const cobrar = async () => {
if (!paciente) return;
try {
setCobrando(true);
const res = await odooApi.posCheckout({
partner_id: paciente.id,
lines: lines.map((l) => ({ service_id: l.service.id, quantity: l.qty, price_unit: l.price })),
discount: discountNum,
payment_method: metodo,
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
pay_with_points: conPuntos,
});
if (res.status === 'success') {
setResultado(res);
setCobroOpen(false);
toast.success(`Venta ${res.sale.name} cobrada`);
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cobrar');
console.error(err);
} finally {
setCobrando(false);
}
};
const nuevaVenta = () => {
setResultado(null);
setLines([]);
setDiscount('');
setPaciente(null);
setPacienteQuery('');
};
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
const pacienteBlock = paciente ? (
<div className="flex items-center justify-between p-2.5 bg-theme-bg rounded-xl">
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{paciente.name}</p>
<p className="text-xs text-theme-muted flex items-center gap-1.5">
{paciente.phone}
{puntosPaciente > 0 && (
<span className="inline-flex items-center gap-0.5"><Wallet size={11} /> {puntosPaciente} pts</span>
)}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => { setPaciente(null); setPacienteQuery(''); }}>Cambiar</Button>
</div>
) : altaRapida ? (
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
<Input placeholder="Nombre *" value={altaForm.name} onChange={(e) => setAltaForm({ ...altaForm, name: e.target.value })} />
<Input placeholder="Teléfono *" value={altaForm.phone} onChange={(e) => setAltaForm({ ...altaForm, phone: e.target.value })} />
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setAltaRapida(false)}>Cancelar</Button>
<Button size="sm" onClick={crearPacienteRapido}>Crear paciente</Button>
</div>
</div>
) : (
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar paciente por nombre o teléfono..."
value={pacienteQuery}
onChange={(e) => setPacienteQuery(e.target.value)}
className="pl-10"
/>
{pacienteResults.length > 0 && (
<div className="absolute z-20 top-full left-0 right-0 mt-1 rounded-xl border border-theme-border bg-theme-surface shadow-card max-h-56 overflow-y-auto">
{pacienteResults.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { setPaciente(p); setPacienteResults([]); }}
className="w-full text-left px-3 py-2 text-sm hover:bg-theme-bg transition"
>
<span className="font-medium text-theme-heading">{p.name}</span>
<span className="text-theme-muted"> · {p.phone}</span>
</button>
))}
<button
type="button"
onClick={() => { setAltaRapida(true); setPacienteResults([]); }}
className="w-full text-left px-3 py-2 text-sm text-theme-heading hover:bg-theme-bg border-t border-theme-border flex items-center gap-1.5"
>
<Plus size={14} /> Alta rápida de paciente
</button>
</div>
)}
{pacienteQuery.trim().length >= 2 && pacienteResults.length === 0 && (
<button
type="button"
onClick={() => setAltaRapida(true)}
className="mt-1.5 text-xs text-theme-heading underline flex items-center gap-1"
>
<Plus size={12} /> Alta rápida de paciente
</button>
)}
</div>
);
const linesBlock = lines.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-8">Toca un servicio para agregarlo al ticket.</p>
) : (
<div className="divide-y divide-theme-border">
{lines.map((l) => (
<div key={l.service.id} className="flex items-center gap-1.5 py-2">
<button
type="button"
onClick={() => removeLine(l.service.id)}
className="text-theme-muted hover:text-rose-600 shrink-0"
title="Quitar"
>
<X size={14} />
</button>
<p className="flex-1 min-w-0 text-sm font-medium text-theme-heading truncate" title={l.service.name}>
{l.service.name}
</p>
<div className="flex items-center gap-0.5 shrink-0">
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
<span className="w-6 text-center text-sm font-medium text-theme-heading">{l.qty}</span>
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: l.qty + 1 })}><Plus size={12} /></Button>
</div>
<input
type="number"
min={0}
step={50}
value={l.price}
onChange={(e) => updateLine(l.service.id, { price: parseFloat(e.target.value) || 0 })}
className="w-[70px] shrink-0 border border-theme-border-strong rounded-lg px-1.5 py-1 text-xs text-right"
title="Precio unitario"
/>
<span className="w-[72px] shrink-0 text-sm font-semibold text-theme-heading text-right">{fmtMoney(l.qty * l.price)}</span>
</div>
))}
</div>
);
const totalsBlock = (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">Subtotal</span>
<span className="text-theme-heading">{fmtMoney(subtotal)}</span>
</div>
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-theme-muted">Descuento</span>
<input
type="number"
min={0}
step={50}
value={discount}
onChange={(e) => setDiscount(e.target.value)}
placeholder="0"
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-theme-heading">Total</span>
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
</div>
<Button
className="w-full mt-1 !py-3 text-base"
onClick={abrirCobro}
disabled={lines.length === 0 || !paciente}
>
<ShoppingBag size={18} className="mr-2" />
Cobrar {fmtMoney(total)}
</Button>
</>
);
return (
<Layout title="Punto de Venta" subtitle="Arma el ticket y cobra en un solo paso">
{!cajaAbierta && (
<div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-warning text-theme-warning-text text-xs">
<AlertTriangle size={13} className="shrink-0" />
Sin corte de caja abierto hoy
<Link to="/cortes" className="font-semibold underline">Abrir caja</Link>
</span>
</div>
)}
<div className="flex flex-col lg:flex-row gap-4 items-start">
{/* Catálogo */}
<Card className="flex-1 min-w-0 w-full">
<Card.Body>
<div className="relative mb-3">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar servicio..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
<button
type="button"
onClick={() => setSoloFavoritos((v) => !v)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
soloFavoritos ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
<Star size={12} fill={soloFavoritos ? 'currentColor' : 'none'} /> Favoritos
</button>
<button
type="button"
onClick={() => { setCategoria(''); setSoloFavoritos(false); }}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
categoria === '' && !soloFavoritos ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
Todos
</button>
{categorias.map((c) => (
<button
key={c}
type="button"
onClick={() => { setCategoria(c); setSoloFavoritos(false); }}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition capitalize ${
categoria === c ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{c}
</button>
))}
</div>
{catalogLoading ? (
<Skeleton count={8} className="h-16 w-full" />
) : visibles.length === 0 ? (
<EmptyState title="Sin servicios" subtitle="No hay servicios con estos filtros." icon={<Store size={28} />} />
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
{visibles.map((s) => (
<button
key={s.id}
type="button"
onClick={() => addLine(s)}
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
>
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]" title={s.name}>
{s.name}
</p>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-theme-muted">{s.duration_min} min</span>
<span className="text-sm font-bold text-theme-heading">{fmtMoney(s.price)}</span>
</div>
</button>
))}
</div>
)}
</Card.Body>
</Card>
{/* Ticket desktop: panel sticky full-height */}
<Card className="hidden lg:flex lg:flex-col w-[390px] shrink-0 sticky top-6 max-h-[calc(100vh-7rem)]">
<div className="p-4 pb-3 border-b border-theme-border shrink-0">
<div className="flex items-center justify-between mb-2.5">
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
{lines.length > 0 && (
<Button variant="ghost" size="sm" onClick={limpiarTicket} title="Vaciar ticket">
<Trash2 size={15} className="text-theme-muted" />
</Button>
)}
</div>
{pacienteBlock}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-1">
{linesBlock}
</div>
<div className="p-4 pt-3 border-t border-theme-border shrink-0 space-y-2">
{totalsBlock}
</div>
</Card>
</div>
{/* Botón flotante móvil */}
{lines.length > 0 && (
<button
type="button"
onClick={() => setTicketMovil(true)}
className="lg:hidden fixed bottom-4 right-4 z-30 px-5 py-3 rounded-full bg-theme-accent text-theme-inverse shadow-card font-semibold text-sm"
>
Ver ticket ({numItems}) {fmtMoney(total)}
</button>
)}
{/* Ticket móvil */}
<Modal isOpen={ticketMovil} onClose={() => setTicketMovil(false)} title="Ticket" maxWidth="md">
<div className="space-y-3">
{pacienteBlock}
<div className="max-h-[38vh] overflow-y-auto">{linesBlock}</div>
<div className="border-t border-theme-border pt-3 space-y-2">{totalsBlock}</div>
</div>
</Modal>
{/* Modal de cobro */}
<Modal
isOpen={cobroOpen}
onClose={() => setCobroOpen(false)}
title={`Cobrar ${fmtMoney(total)}`}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setCobroOpen(false)}>Cancelar</Button>
<Button onClick={cobrar} loading={cobrando}>Confirmar cobro</Button>
</>
}
>
<div className="space-y-4">
<Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
{metodo === 'cash' && !conPuntos && (
<>
<Input
label="Recibido"
type="number"
min={0}
step={10}
value={recibido}
onChange={(e) => setRecibido(e.target.value)}
placeholder={String(total)}
/>
{recibido && (
<p className="text-sm">
<span className="text-theme-muted">Cambio: </span>
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>{fmtMoney(cambio)}</span>
</p>
)}
</>
)}
{puedePuntos && (
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={conPuntos}
onChange={(e) => setConPuntos(e.target.checked)}
className="w-4 h-4 rounded border-theme-border-strong"
/>
<span className="text-sm text-theme-heading flex items-center gap-1.5">
<Wallet size={15} className="text-theme-muted" />
Pagar con puntos ({puntosPaciente} pts disponibles)
</span>
</label>
)}
{conPuntos && (
<p className="text-xs text-theme-muted">
Se canjearán {Math.ceil(total)} pts (1 pt = $1). El paciente acumula puntos solo por lo pagado en dinero.
</p>
)}
</div>
</Modal>
{/* Ticket de éxito */}
<Modal
isOpen={!!resultado}
onClose={nuevaVenta}
title="Venta cobrada"
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => window.print()}>
<Printer size={16} className="mr-2" /> Imprimir
</Button>
<Button onClick={nuevaVenta}>Nueva venta</Button>
</>
}
>
{resultado && (
<div className="print-ticket text-sm">
<div className="text-center border-b border-dashed border-theme-border pb-3 mb-3">
<p className="font-heading font-bold text-theme-heading">SKEEN Derma Experts</p>
<p className="text-xs text-theme-muted">Playas de Rosarito, B.C.</p>
</div>
<div className="flex justify-between text-xs text-theme-muted mb-2">
<span>{resultado.sale.name}</span>
<span>{resultado.sale.date}</span>
</div>
<p className="text-sm mb-3"><span className="text-theme-muted">Paciente: </span><span className="font-medium text-theme-heading">{resultado.sale.patient}</span></p>
<table className="w-full text-sm mb-3">
<tbody>
{resultado.sale.lines.map((l) => (
<tr key={l.id}>
<td className="py-1 text-theme-heading">{l.quantity} × {l.service || l.description}</td>
<td className="py-1 text-right text-theme-heading">{fmtMoney(l.subtotal)}</td>
</tr>
))}
</tbody>
</table>
<div className="border-t border-dashed border-theme-border pt-2 space-y-1 text-sm">
<div className="flex justify-between"><span className="text-theme-muted">Subtotal</span><span>{fmtMoney(resultado.sale.subtotal)}</span></div>
{resultado.sale.discount > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Descuento</span><span>-{fmtMoney(resultado.sale.discount)}</span></div>
)}
{resultado.puntos_usados > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Puntos canjeados</span><span>-{resultado.puntos_usados} pts</span></div>
)}
<div className="flex justify-between font-bold text-theme-heading"><span>Total</span><span>{fmtMoney(resultado.sale.total)}</span></div>
<div className="flex justify-between"><span className="text-theme-muted">Método</span><span>{resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos'}</span></div>
{resultado.cambio > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Cambio</span><span>{fmtMoney(resultado.cambio)}</span></div>
)}
{resultado.puntos_ganados > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Puntos ganados</span><span>+{resultado.puntos_ganados} pts</span></div>
)}
<div className="flex justify-between"><span className="text-theme-muted">Saldo monedero</span><span>{resultado.wallet_points} pts</span></div>
</div>
<p className="text-center text-xs text-theme-muted mt-4">¡Gracias por su visita!</p>
</div>
)}
</Modal>
</Layout>
);
};
export default Pos;