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:
601
frontend/src/pages/Pos.tsx
Normal file
601
frontend/src/pages/Pos.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user