1503 lines
64 KiB
TypeScript
1503 lines
64 KiB
TypeScript
import type { FC } from 'react';
|
||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||
import { Link, useSearchParams } from 'react-router-dom';
|
||
import {
|
||
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
|
||
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List, Layers,
|
||
} from 'lucide-react';
|
||
import Layout from '../components/Layout';
|
||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||
import TicketPrint from '../components/TicketPrint';
|
||
import {
|
||
Card,
|
||
Button,
|
||
Input,
|
||
Select,
|
||
Modal,
|
||
EmptyState,
|
||
Skeleton,
|
||
toast,
|
||
} from '../components/ui';
|
||
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
|
||
import type { PatientPackage } from '../services/odoo';
|
||
import { useAuth } from '../lib/auth';
|
||
import { useClinicSettings } from '../lib/clinic-settings';
|
||
|
||
const fmtMoney = (n: number) =>
|
||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||
|
||
const fmtUsd = (n: number) =>
|
||
`$${(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`;
|
||
|
||
const METODO_OPTIONS = [
|
||
{ value: 'cash', label: 'Efectivo' },
|
||
{ value: 'card', label: 'Tarjeta' },
|
||
{ value: 'transfer', label: 'Transferencia' },
|
||
{ value: 'stripe', label: 'Stripe' },
|
||
{ value: 'mercadopago', label: 'MercadoPago' },
|
||
{ value: 'saldo_favor', label: 'Saldo a favor' },
|
||
];
|
||
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
|
||
|
||
interface TicketLine {
|
||
key: string;
|
||
name: string;
|
||
qty: number;
|
||
price: number;
|
||
service_id?: number;
|
||
item_id?: number;
|
||
paquete_id?: number;
|
||
sesiones?: number;
|
||
stock?: number;
|
||
unit?: string;
|
||
/** Tasa de impuesto (%) de la línea (0 con impuestos apagados) */
|
||
tax_rate?: number;
|
||
}
|
||
|
||
const Pos: FC = () => {
|
||
const { openPatient } = usePatientDrawer();
|
||
const { can } = useAuth();
|
||
const impuestos = useClinicSettings().impuestos;
|
||
const puedeImpuestos = can('ventas.impuestos');
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
// Catálogo
|
||
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
|
||
// Catálogo de paquetes de sesiones (skeen.paquete)
|
||
const [paqueteCatalogo, setPaqueteCatalogo] = useState<Paquete[]>([]);
|
||
const [services, setServices] = useState<Service[]>([]);
|
||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||
const [search, setSearch] = useState('');
|
||
const [categoria, setCategoria] = useState('');
|
||
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
||
const [ordenServicios, setOrdenServicios] = useState<'manual' | 'best_sellers'>('manual');
|
||
// Vista del catálogo: cuadrícula (tarjetas) o lista compacta, para catálogos
|
||
// largos. Se recuerda entre sesiones como el resto de preferencias.
|
||
const [vista, setVista] = useState<'grid' | 'lista'>(() => {
|
||
try {
|
||
return localStorage.getItem('skeen_pos_vista') === 'lista' ? 'lista' : 'grid';
|
||
} catch {
|
||
return 'grid';
|
||
}
|
||
});
|
||
const cambiarVista = (v: 'grid' | 'lista') => {
|
||
setVista(v);
|
||
try {
|
||
localStorage.setItem('skeen_pos_vista', v);
|
||
} catch {
|
||
// localStorage no disponible: queda solo en memoria
|
||
}
|
||
};
|
||
// Orden de la lista: por nombre o por precio (click en el encabezado)
|
||
const [ordenLista, setOrdenLista] = useState<{ campo: 'nombre' | 'precio'; dir: 1 | -1 }>({ campo: 'nombre', dir: 1 });
|
||
const alternarOrdenLista = (campo: 'nombre' | 'precio') =>
|
||
setOrdenLista((prev) => (prev.campo === campo ? { campo, dir: prev.dir === 1 ? -1 : 1 } : { campo, dir: 1 }));
|
||
|
||
const [invItems, setInvItems] = useState<InventoryItem[]>([]);
|
||
|
||
// Ticket
|
||
const [lines, setLines] = useState<TicketLine[]>([]);
|
||
const [discount, setDiscount] = useState('');
|
||
const [paciente, setPaciente] = useState<Patient | null>(null);
|
||
const [pacienteAdeudo, setPacienteAdeudo] = useState(0);
|
||
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 [moneda, setMoneda] = useState<'MXN' | 'USD'>('MXN');
|
||
const [recibido, setRecibido] = useState('');
|
||
const [conPuntos, setConPuntos] = useState(false);
|
||
const [cobrando, setCobrando] = useState(false);
|
||
const [tcRate, setTcRate] = useState<number | null>(null);
|
||
|
||
// Doctor que atendió/recetó: se aplica como prescribed_by_id a todas las
|
||
// líneas al cobrar (alimenta "Mis ventas" y comisiones). Opcional.
|
||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||
const [doctorId, setDoctorId] = useState('');
|
||
|
||
// Resultado
|
||
const [resultado, setResultado] = useState<PosCheckoutResult | null>(null);
|
||
|
||
// Corte de caja
|
||
const [cajaAbierta, setCajaAbierta] = useState(true);
|
||
const [cajaModalOpen, setCajaModalOpen] = useState(false);
|
||
const [cajaFondo, setCajaFondo] = useState('');
|
||
const [cajaAbriendo, setCajaAbriendo] = useState(false);
|
||
|
||
// Repetir última venta
|
||
const [lastSaleLoading, setLastSaleLoading] = useState(false);
|
||
// Paquetes del paciente con sesiones pendientes
|
||
const [paquetes, setPaquetes] = useState<PatientPackage[]>([]);
|
||
|
||
// Atajos de teclado
|
||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||
const [ticketFocused, setTicketFocused] = useState(false);
|
||
const searchWrapRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
// Mobile
|
||
const [ticketMovil, setTicketMovil] = useState(false);
|
||
|
||
// ---- Catálogo ----
|
||
// Orden del catálogo de servicios: manual (sequence definida en Servicios)
|
||
// o por más vendidos de los últimos 90 días.
|
||
const loadServices = useCallback(async () => {
|
||
try {
|
||
setCatalogLoading(true);
|
||
const res = await odooApi.getServices({
|
||
search: search || undefined,
|
||
category: categoria || undefined,
|
||
page_size: 200,
|
||
sort: ordenServicios,
|
||
});
|
||
if (res.status === 'success') setServices(res.services);
|
||
} catch (err) {
|
||
toast.error('Error al cargar servicios');
|
||
console.error(err);
|
||
} finally {
|
||
setCatalogLoading(false);
|
||
}
|
||
}, [search, categoria, ordenServicios]);
|
||
|
||
const loadItems = useCallback(async () => {
|
||
try {
|
||
setCatalogLoading(true);
|
||
const res = await odooApi.getInventarioItems(search || undefined);
|
||
if (res.status === 'success') setInvItems(res.items);
|
||
} catch (err) {
|
||
toast.error('Error al cargar artículos');
|
||
console.error(err);
|
||
} finally {
|
||
setCatalogLoading(false);
|
||
}
|
||
}, [search]);
|
||
|
||
// Paquetes de sesiones: catálogo corto, se carga completo y se filtra en local
|
||
const loadPaquetes = useCallback(async () => {
|
||
try {
|
||
setCatalogLoading(true);
|
||
const res = await odooApi.getPaqueteCatalogo();
|
||
if (res.status === 'success') setPaqueteCatalogo(res.paquetes);
|
||
} catch (err) {
|
||
toast.error('Error al cargar paquetes');
|
||
console.error(err);
|
||
} finally {
|
||
setCatalogLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => {
|
||
if (catMode === 'servicios') loadServices();
|
||
else if (catMode === 'articulos') loadItems();
|
||
}, 300);
|
||
return () => clearTimeout(t);
|
||
}, [loadServices, loadItems, catMode]);
|
||
|
||
// El catálogo de paquetes es corto: se carga una vez y se filtra en local.
|
||
useEffect(() => { loadPaquetes(); }, [loadPaquetes]);
|
||
|
||
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(() => {});
|
||
}, []);
|
||
|
||
// Doctores activos para el selector "Doctor que atendió"
|
||
useEffect(() => {
|
||
odooApi.getDoctors()
|
||
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
// Pre-carga "Cobrar a la cita" desde Agenda:
|
||
// /pos?patient_id=X&service_id=Y&doctor_id=Z&appointment_id=N
|
||
// Se re-ejecuta cuando cambia patient_id (llegar a /pos ya montado desde el
|
||
// drawer no remonta el componente) y los params se consumen tras precargar.
|
||
const preloadPatientId = searchParams.get('patient_id');
|
||
useEffect(() => {
|
||
if (!preloadPatientId) return;
|
||
const sid = searchParams.get('service_id');
|
||
const did = searchParams.get('doctor_id');
|
||
(async () => {
|
||
try {
|
||
const res = await odooApi.getPatient(parseInt(preloadPatientId, 10));
|
||
if (res.status === 'success') selectPaciente(res.patient);
|
||
} catch {
|
||
toast.error('No se pudo cargar el paciente de la cita');
|
||
}
|
||
if (did) setDoctorId(did);
|
||
if (sid) {
|
||
try {
|
||
const sRes = await odooApi.getServices({ page_size: 200 });
|
||
const svc = (sRes.services || []).find((s) => s.id === parseInt(sid, 10));
|
||
if (svc) {
|
||
setLines((prev) => {
|
||
const key = `s-${svc.id}`;
|
||
if (prev.some((l) => l.key === key)) return prev;
|
||
return [...prev, { key, name: svc.name, qty: 1, price: svc.price, service_id: svc.id }];
|
||
});
|
||
} else {
|
||
toast.error('El servicio de la cita no está activo en el catálogo');
|
||
}
|
||
} catch {
|
||
toast.error('No se pudo cargar el servicio de la cita');
|
||
}
|
||
}
|
||
toast.success('Cita precargada en el ticket');
|
||
setSearchParams((prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
['patient_id', 'service_id', 'doctor_id', 'appointment_id'].forEach((k) => next.delete(k));
|
||
return next;
|
||
}, { replace: true });
|
||
})();
|
||
}, [preloadPatientId, searchParams, setSearchParams]);
|
||
|
||
// Tipo de cambio USD/MXN: al montar y refresco cada 5 min
|
||
useEffect(() => {
|
||
const fetchTc = () => {
|
||
odooApi.getExchangeRate()
|
||
.then((res) => { if (res.current) setTcRate(res.current.rate); })
|
||
.catch(() => {});
|
||
};
|
||
fetchTc();
|
||
const interval = setInterval(fetchTc, 5 * 60 * 1000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
// 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]
|
||
);
|
||
|
||
// Los artículos ya vienen filtrados del server-side (search)
|
||
const visiblesItems = invItems;
|
||
|
||
// Lista ordenable por nombre o precio (click en el encabezado)
|
||
const serviciosLista = useMemo(() => {
|
||
const arr = [...visibles];
|
||
arr.sort((a, b) => {
|
||
const v = ordenLista.campo === 'nombre'
|
||
? a.name.localeCompare(b.name, 'es')
|
||
: (a.price || 0) - (b.price || 0);
|
||
return v * ordenLista.dir;
|
||
});
|
||
return arr;
|
||
}, [visibles, ordenLista]);
|
||
|
||
const itemsLista = useMemo(() => {
|
||
const arr = [...visiblesItems];
|
||
arr.sort((a, b) => {
|
||
const v = ordenLista.campo === 'nombre'
|
||
? a.name.localeCompare(b.name, 'es')
|
||
: (a.price || 0) - (b.price || 0);
|
||
return v * ordenLista.dir;
|
||
});
|
||
return arr;
|
||
}, [visiblesItems, ordenLista]);
|
||
|
||
// Paquetes visibles: filtro local por nombre / servicio / código
|
||
const paquetesVisibles = useMemo(() => {
|
||
const q = (search || '').trim().toLowerCase();
|
||
const arr = !q
|
||
? [...paqueteCatalogo]
|
||
: paqueteCatalogo.filter((p) =>
|
||
p.name.toLowerCase().includes(q)
|
||
|| (p.service || '').toLowerCase().includes(q)
|
||
|| (p.code || '').toLowerCase().includes(q));
|
||
arr.sort((a, b) => (a.sequence || 0) - (b.sequence || 0) || a.name.localeCompare(b.name, 'es'));
|
||
return arr;
|
||
}, [paqueteCatalogo, search]);
|
||
|
||
// Un paquete solo puede venderse a un paciente: el saldo vive en él.
|
||
const addPaqueteLine = (p: Paquete) => {
|
||
if (!paciente) {
|
||
toast.error('Selecciona primero el paciente: el saldo del paquete es por paciente');
|
||
return;
|
||
}
|
||
setLines((prev) => {
|
||
const key = `p-${p.id}`;
|
||
const found = prev.find((l) => l.key === key);
|
||
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
|
||
return [...prev, {
|
||
key,
|
||
name: `${p.name} · Paquete (${p.sessions} sesiones)`,
|
||
qty: 1,
|
||
price: p.price,
|
||
service_id: p.service_id ?? undefined,
|
||
paquete_id: p.id,
|
||
sesiones: p.sessions,
|
||
tax_rate: tasaDePaquete(p),
|
||
}];
|
||
});
|
||
};
|
||
|
||
// ---- Ticket ----
|
||
// Tasa de impuesto que corresponde a un platillo del catálogo (0 si apagado).
|
||
const tasaDeServicio = (s: Service) =>
|
||
impuestos.activo ? (s.tax_use_default !== false ? impuestos.tasa_default : (s.tax_rate ?? 0)) : 0;
|
||
const tasaDeItem = (it: InventoryItem) =>
|
||
impuestos.activo ? (it.tax_use_default !== false ? impuestos.tasa_default : (it.tax_rate ?? 0)) : 0;
|
||
const tasaDePaquete = (p: Paquete) => {
|
||
if (!impuestos.activo) return 0;
|
||
const servicio = services.find((s) => s.id === p.service_id);
|
||
return servicio ? tasaDeServicio(servicio) : impuestos.tasa_default;
|
||
};
|
||
|
||
const addLine = (service: Service) => { setLines((prev) => {
|
||
const key = `s-${service.id}`;
|
||
const found = prev.find((l) => l.key === key);
|
||
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
|
||
return [...prev, { key, name: service.name, qty: 1, price: service.price, service_id: service.id, tax_rate: tasaDeServicio(service) }];
|
||
});
|
||
};
|
||
|
||
const addItemLine = (item: InventoryItem) => {
|
||
setLines((prev) => {
|
||
const key = `i-${item.id}`;
|
||
const found = prev.find((l) => l.key === key);
|
||
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
|
||
return [...prev, { key, name: item.name, qty: 1, price: item.price || 0, item_id: item.id, stock: item.qty, unit: item.unit, tax_rate: tasaDeItem(item) }];
|
||
});
|
||
};
|
||
|
||
const updateLine = (key: string, patch: Partial<TicketLine>) => {
|
||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
||
};
|
||
|
||
const removeLine = (key: string) => setLines((prev) => prev.filter((l) => l.key !== key));
|
||
|
||
const limpiarTicket = () => {
|
||
if (lines.length === 0) return;
|
||
if (window.confirm('¿Vaciar el ticket?')) setLines([]);
|
||
};
|
||
|
||
// Desglose del ticket. Con los impuestos apagados: bruto === base, impuesto 0
|
||
// y el total sale exactamente igual que antes.
|
||
const bruto = lines.reduce((a, l) => a + l.qty * l.price, 0);
|
||
const impuestoLinea = (l: TicketLine): number => {
|
||
if (!impuestos.activo) return 0;
|
||
const t = l.tax_rate ?? 0;
|
||
if (t <= 0) return 0;
|
||
const b = l.qty * l.price;
|
||
return impuestos.precios_incluyen_impuesto ? b - b / (1 + t / 100) : (b * t) / 100;
|
||
};
|
||
const impuesto = impuestos.activo ? lines.reduce((a, l) => a + impuestoLinea(l), 0) : 0;
|
||
const subtotal = impuestos.activo && impuestos.precios_incluyen_impuesto ? bruto - impuesto : bruto;
|
||
const discountNum = Math.min(parseFloat(discount) || 0, subtotal);
|
||
const total = Math.max(0, subtotal - discountNum + impuesto);
|
||
const numItems = lines.reduce((a, l) => a + l.qty, 0);
|
||
const puntosPaciente = paciente?.wallet_points ?? 0;
|
||
const puedePuntos = puntosPaciente >= total && total > 0;
|
||
// Equivalentes USD (si hay tipo de cambio configurado)
|
||
const toUsd = (mxn: number): number | null => (tcRate ? mxn / tcRate : null);
|
||
const recibidoMxn = recibido
|
||
? moneda === 'USD' && tcRate
|
||
? (parseFloat(recibido) || 0) * tcRate
|
||
: parseFloat(recibido) || 0
|
||
: 0;
|
||
const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - total) : 0;
|
||
const cambioUsd = moneda === 'USD' && tcRate ? cambio / tcRate : 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');
|
||
setMoneda('MXN');
|
||
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) => ({
|
||
...(l.service_id ? { service_id: l.service_id } : {}),
|
||
...(l.item_id ? { item_id: l.item_id } : {}),
|
||
...(l.paquete_id ? { paquete_id: l.paquete_id, description: l.name } : {}),
|
||
quantity: l.qty,
|
||
price_unit: l.price,
|
||
...(impuestos.activo ? { tax_rate: l.tax_rate ?? 0 } : {}),
|
||
// Asignación a nivel venta: el doctor seleccionado aplica a todas las líneas
|
||
...(doctorId ? { prescribed_by_id: parseInt(doctorId, 10) } : {}),
|
||
})),
|
||
discount: discountNum,
|
||
payment_method: metodo,
|
||
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
|
||
pay_with_points: conPuntos,
|
||
currency: moneda,
|
||
});
|
||
if (res.status === 'success') {
|
||
setResultado(res);
|
||
setCobroOpen(false);
|
||
toast.success(`Venta ${res.sale.name} cobrada`);
|
||
if (res.stock_warnings && res.stock_warnings.length > 0) {
|
||
res.stock_warnings.forEach((w) => toast.error(`Stock: ${w}`));
|
||
}
|
||
}
|
||
} 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);
|
||
}
|
||
};
|
||
|
||
// Selector "Doctor que atendió": se comparte entre el ticket y el modal de cobro
|
||
const doctorBlock = (
|
||
<Select
|
||
label="Doctor que atendió"
|
||
options={[
|
||
{ value: '', label: 'Sin asignar' },
|
||
...doctors.map((d) => ({
|
||
value: String(d.id),
|
||
label: (d.staff_type ?? 'doctor') === 'cosmetologa' ? `${d.name} (Cosmetóloga)` : d.name,
|
||
})),
|
||
]}
|
||
value={doctorId}
|
||
onChange={(e) => setDoctorId(e.target.value)}
|
||
/>
|
||
);
|
||
|
||
// Al seleccionar paciente: traer su adeudo y sus paquetes con sesiones pendientes
|
||
const selectPaciente = (p: Patient) => {
|
||
setPaciente(p);
|
||
setPacienteResults([]);
|
||
setPacienteAdeudo(0);
|
||
setPaquetes([]);
|
||
odooApi.getPatientHistory(p.id)
|
||
.then((res) => { if (res.status === 'success') setPacienteAdeudo(res.estado_cuenta?.adeudo ?? 0); })
|
||
.catch(() => {});
|
||
odooApi.getPatientPackages(p.id)
|
||
.then((res) => { if (res.status === 'success') setPaquetes(res.packages.filter((k) => k.remaining > 0)); })
|
||
.catch(() => {});
|
||
};
|
||
|
||
// ---- Repetir última venta (caso típico: toxina cada 3 meses) ----
|
||
const repetirUltimaVenta = async () => {
|
||
if (!paciente) return;
|
||
if (lines.length > 0 && !window.confirm('El ticket ya tiene líneas. ¿Reemplazarlas por las de la última venta?')) {
|
||
return;
|
||
}
|
||
try {
|
||
setLastSaleLoading(true);
|
||
const res = await odooApi.getLastSale(paciente.id);
|
||
if (res.status !== 'success' || !res.sale) {
|
||
toast.error('Este paciente no tiene ventas previas');
|
||
return;
|
||
}
|
||
// Se rearma con el precio VIGENTE del catálogo y se omiten los artículos
|
||
// que ya no existen o están inactivos (se avisan).
|
||
const nuevas: TicketLine[] = [];
|
||
const omitidas: string[] = [];
|
||
for (const l of res.lines) {
|
||
if (l.missing) { omitidas.push(l.name); continue; }
|
||
const key = l.kind === 'service' ? `s-${l.service_id}` : `i-${l.item_id}`;
|
||
const found = nuevas.find((x) => x.key === key);
|
||
if (found) { found.qty += l.quantity; continue; }
|
||
const tasa = l.kind === 'service'
|
||
? (() => { const s = services.find((x) => x.id === l.service_id); return s ? tasaDeServicio(s) : impuestos.tasa_default; })()
|
||
: (() => { const it = invItems.find((x) => x.id === l.item_id); return it ? tasaDeItem(it) : impuestos.tasa_default; })();
|
||
nuevas.push({
|
||
key,
|
||
name: l.name,
|
||
qty: l.quantity,
|
||
price: l.price_current ?? l.price_last,
|
||
tax_rate: impuestos.activo ? tasa : 0,
|
||
...(l.kind === 'service' ? { service_id: l.service_id ?? undefined } : {}),
|
||
...(l.kind === 'item' ? { item_id: l.item_id ?? undefined, stock: l.stock ?? undefined, unit: l.unit ?? undefined } : {}),
|
||
});
|
||
}
|
||
setLines(nuevas);
|
||
const cambiados = res.lines.filter((l) => l.price_changed && !l.missing).length;
|
||
toast.success(`Ticket cargado desde ${res.sale.name} (${res.sale.date})`);
|
||
res.warnings.forEach((w) => toast.warning(w));
|
||
if (omitidas.length > 0 && cambiados === 0) {
|
||
toast.warning(`No se agregaron: ${omitidas.join(', ')}`);
|
||
}
|
||
} catch (err) {
|
||
toast.error('No se pudo cargar la última venta');
|
||
console.error(err);
|
||
} finally {
|
||
setLastSaleLoading(false);
|
||
}
|
||
};
|
||
|
||
// ---- Caja: abrir desde el POS sin perder el ticket ----
|
||
const abrirCajaDesdePos = async () => {
|
||
const fondo = parseFloat(cajaFondo || '0');
|
||
if (Number.isNaN(fondo) || fondo < 0) {
|
||
toast.error('Ingresa un fondo de caja válido');
|
||
return;
|
||
}
|
||
try {
|
||
setCajaAbriendo(true);
|
||
await odooApi.createCashClosing({ date: new Date().toISOString().split('T')[0], opening_cash: fondo });
|
||
setCajaAbierta(true);
|
||
setCajaModalOpen(false);
|
||
setCajaFondo('');
|
||
toast.success('Caja abierta');
|
||
} catch (err) {
|
||
toast.error('No se pudo abrir la caja');
|
||
console.error(err);
|
||
} finally {
|
||
setCajaAbriendo(false);
|
||
}
|
||
};
|
||
|
||
// ---- Atajos de teclado ----
|
||
// F2 buscador de servicios · F9 cobrar · +/- cantidad de la última línea
|
||
// (con el ticket enfocado) · Esc cierra el modal abierto (lo maneja Modal).
|
||
const ajustarUltimaLinea = (delta: number) => {
|
||
setLines((prev) => {
|
||
if (prev.length === 0) return prev;
|
||
const last = prev[prev.length - 1];
|
||
const qty = Math.max(1, last.qty + delta);
|
||
return prev.map((l, i) => (i === prev.length - 1 ? { ...l, qty } : l));
|
||
});
|
||
};
|
||
|
||
const cobrarConAtajo = () => {
|
||
if (lines.length === 0 || !paciente) {
|
||
toast.error(lines.length === 0 ? 'El ticket está vacío' : 'Selecciona un paciente');
|
||
return;
|
||
}
|
||
abrirCobro();
|
||
};
|
||
|
||
const hotkeyRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
||
hotkeyRef.current = (e: KeyboardEvent) => {
|
||
if (e.key === 'F2') {
|
||
e.preventDefault();
|
||
searchWrapRef.current?.querySelector('input')?.focus();
|
||
return;
|
||
}
|
||
if (e.key === 'F9') {
|
||
e.preventDefault();
|
||
cobrarConAtajo();
|
||
return;
|
||
}
|
||
const target = e.target as HTMLElement | null;
|
||
const tag = target?.tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
|
||
if (!ticketFocused) return;
|
||
if (e.key === '+' || e.key === '=') {
|
||
e.preventDefault();
|
||
ajustarUltimaLinea(1);
|
||
} else if (e.key === '-' || e.key === '_') {
|
||
e.preventDefault();
|
||
ajustarUltimaLinea(-1);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const handler = (e: KeyboardEvent) => hotkeyRef.current(e);
|
||
window.addEventListener('keydown', handler);
|
||
return () => window.removeEventListener('keydown', handler);
|
||
}, []);
|
||
|
||
const nuevaVenta = () => {
|
||
setResultado(null);
|
||
setLines([]);
|
||
setDiscount('');
|
||
setPaciente(null);
|
||
setPacienteAdeudo(0);
|
||
setPacienteQuery('');
|
||
setDoctorId('');
|
||
setPaquetes([]);
|
||
};
|
||
|
||
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
|
||
const pacienteBlock = paciente ? (
|
||
<div className="p-2.5 bg-theme-bg rounded-xl space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<div className="min-w-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => openPatient(paciente.id)}
|
||
className="text-sm font-medium text-theme-heading truncate hover:underline hover:text-theme-accent"
|
||
title="Abrir ficha del paciente"
|
||
>
|
||
{paciente.name}
|
||
</button>
|
||
<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); setPacienteAdeudo(0); setPacienteQuery(''); }}>Cambiar</Button>
|
||
</div>
|
||
{/* Alertas para caja antes de cobrar: VIP / Alergias / Adeudo */}
|
||
{(paciente.is_vip || paciente.allergies || pacienteAdeudo > 0) && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{paciente.is_vip && (
|
||
<span className="inline-flex items-center gap-1 rounded-full border border-theme-accent bg-theme-accent-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading">
|
||
<Star size={11} /> VIP
|
||
</span>
|
||
)}
|
||
{paciente.allergies && (
|
||
<span
|
||
className="inline-flex items-center gap-1 rounded-full border border-rose-300 bg-rose-50 px-2 py-0.5 text-[11px] font-medium text-rose-700"
|
||
title={`Alergias: ${paciente.allergies}`}
|
||
>
|
||
<AlertTriangle size={11} /> Alergias
|
||
</span>
|
||
)}
|
||
{pacienteAdeudo > 0 && (
|
||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
||
Adeudo {fmtMoney(pacienteAdeudo)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Paquetes con sesiones pendientes: aviso informativo para caja.
|
||
No hay consumo automático de sesión (el sistema no guarda contador):
|
||
las sesiones restantes se derivan de las citas del paquete. */}
|
||
{paquetes.length > 0 && (
|
||
<div className="rounded-lg border border-violet-300 bg-violet-50 px-2.5 py-1.5 space-y-0.5">
|
||
{paquetes.map((k) => (
|
||
<p key={k.service_id} className="text-[11px] text-violet-800 flex items-center gap-1.5">
|
||
<Package size={11} className="shrink-0" />
|
||
<span>
|
||
Paquete <span className="font-medium">{k.name}</span>: {k.remaining} de {k.total} sesiones restantes
|
||
{k.next_date ? ` · próxima ${k.next_date}` : ''}
|
||
</span>
|
||
</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* Repetir la última venta del paciente (toxina cada 3 meses, etc.) */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={repetirUltimaVenta}
|
||
loading={lastSaleLoading}
|
||
title="Carga las líneas de la última venta con los precios actuales del catálogo"
|
||
>
|
||
<RotateCcw size={13} className="mr-1.5" />
|
||
Repetir última venta
|
||
</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={() => selectPaciente(p)}
|
||
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.key} className="flex items-center gap-1.5 py-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => removeLine(l.key)}
|
||
className="text-theme-muted hover:text-rose-600 shrink-0"
|
||
title="Quitar"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-theme-heading truncate" title={l.name}>
|
||
{l.item_id && <Package size={12} className="inline mr-1 text-theme-muted" />}
|
||
{l.name}
|
||
{l.item_id && l.stock !== undefined && (
|
||
<span className="block text-[10px] font-normal text-theme-muted">{l.stock} {l.unit || 'pzas'} en existencia</span>
|
||
)}
|
||
</p>
|
||
{impuestos.activo && (
|
||
<span className="flex items-center gap-1.5 text-[11px] text-theme-muted">
|
||
<span>{impuestos.nombre}</span>
|
||
{puedeImpuestos ? (
|
||
<select
|
||
value={String(l.tax_rate ?? 0)}
|
||
onChange={(e) => updateLine(l.key, { tax_rate: parseFloat(e.target.value) || 0 })}
|
||
className="border border-theme-border-strong rounded-md px-1 py-0.5 text-[11px]"
|
||
title={`Tasa de ${impuestos.nombre} de la línea`}
|
||
>
|
||
{[...new Set([...(impuestos.tasa_opciones || []), l.tax_rate ?? 0])]
|
||
.sort((a, b) => a - b)
|
||
.map((o) => <option key={o} value={String(o)}>{o}%</option>)}
|
||
</select>
|
||
) : (
|
||
<span>{l.tax_rate ?? 0}%</span>
|
||
)}
|
||
{impuestoLinea(l) > 0 && (
|
||
<span>{fmtMoney(impuestoLinea(l))}</span>
|
||
)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-0.5 shrink-0">
|
||
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { 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.key, { qty: l.qty + 1 })}><Plus size={12} /></Button>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={50}
|
||
value={l.price}
|
||
onChange={(e) => updateLine(l.key, { 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 className="text-xs text-theme-muted ml-1.5" title={toUsd(subtotal) === null ? 'Configura el tipo de cambio en Configuración' : undefined}>
|
||
{toUsd(subtotal) !== null ? `≈ ${fmtUsd(toUsd(subtotal)!)}` : '—'}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
{impuestos.activo && (
|
||
<div className="flex items-center justify-between text-sm">
|
||
<span className="text-theme-muted">
|
||
{impuestos.nombre}{impuestos.precios_incluyen_impuesto ? ' (incluido)' : ''}
|
||
</span>
|
||
<span className="text-theme-heading">
|
||
{fmtMoney(impuesto)}
|
||
<span className="text-xs text-theme-muted ml-1.5">
|
||
{toUsd(impuesto) !== null ? `≈ ${fmtUsd(toUsd(impuesto)!)}` : '—'}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between text-sm gap-2">
|
||
<span className="text-theme-muted">Descuento</span>
|
||
<span className="flex items-center gap-1.5">
|
||
{discountNum > 0 && toUsd(discountNum) !== null && (
|
||
<span className="text-xs text-theme-muted">≈ {fmtUsd(toUsd(discountNum)!)}</span>
|
||
)}
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={50}
|
||
value={discount}
|
||
onChange={(e) => setDiscount(e.target.value)}
|
||
placeholder="0"
|
||
disabled={!can('ventas.descuentos')}
|
||
title={can('ventas.descuentos') ? undefined : 'Sin permiso para aplicar descuentos'}
|
||
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right disabled:opacity-50 disabled:cursor-not-allowed"
|
||
/>
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-base font-semibold text-theme-heading">Total</span>
|
||
<span className="text-right">
|
||
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
|
||
<span
|
||
className="block text-xs text-theme-muted"
|
||
title={toUsd(total) === null ? 'Configura el tipo de cambio en Configuración' : undefined}
|
||
>
|
||
{toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="border border-theme-border rounded-xl p-2.5 bg-theme-bg">
|
||
{doctorBlock}
|
||
</div>
|
||
<Button
|
||
className="w-full mt-1 !py-3 text-base"
|
||
onClick={abrirCobro}
|
||
disabled={lines.length === 0 || !paciente || !can('ventas.vender')}
|
||
title={can('ventas.vender') ? 'Cobrar (F9)' : 'Sin permiso para vender'}
|
||
>
|
||
<ShoppingBag size={18} className="mr-2" />
|
||
Cobrar {fmtMoney(total)}
|
||
</Button>
|
||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-theme-muted">
|
||
<Keyboard size={11} aria-hidden="true" />
|
||
F2 buscar · F9 cobrar · +/− cantidad · Esc cerrar
|
||
<button
|
||
type="button"
|
||
onClick={() => setShortcutsOpen(true)}
|
||
className="underline hover:text-theme-heading"
|
||
>
|
||
Ver atajos
|
||
</button>
|
||
</p>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<Layout title="Punto de Venta" subtitle="Arma el ticket y cobra en un solo paso">
|
||
{!cajaAbierta ? (
|
||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||
<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
|
||
</span>
|
||
<Button variant="outline" size="sm" onClick={() => setCajaModalOpen(true)}>
|
||
<Lock size={13} className="mr-1.5" />
|
||
Abrir caja aquí
|
||
</Button>
|
||
<Link to="/cortes" className="text-xs font-semibold underline text-theme-heading">Ir a Cortes de caja</Link>
|
||
</div>
|
||
) : (
|
||
<div className="mb-3 flex items-center gap-2 text-xs text-theme-muted">
|
||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-bg text-xs">
|
||
Caja abierta
|
||
</span>
|
||
<Link to="/cortes" className="underline hover:text-theme-heading">Cerrar caja</Link>
|
||
</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" ref={searchWrapRef}>
|
||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||
<Input
|
||
placeholder={catMode === 'servicios' ? 'Buscar servicio... (F2)' : catMode === 'paquetes' ? 'Buscar paquete... (F2)' : 'Buscar artículo... (F2)'}
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
{/* Toggle Servicios / Paquetes / Artículos */}
|
||
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setCatMode('servicios')}
|
||
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
|
||
catMode === 'servicios' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||
}`}
|
||
>
|
||
<Store size={14} /> Servicios
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCatMode('paquetes')}
|
||
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
|
||
catMode === 'paquetes' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||
}`}
|
||
title="Paquetes de sesiones"
|
||
>
|
||
<Layers size={14} /> Paquetes
|
||
{paqueteCatalogo.length > 0 && (
|
||
<span className="text-[10px] opacity-80">({paqueteCatalogo.length})</span>
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCatMode('articulos')}
|
||
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
|
||
catMode === 'articulos' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||
}`}
|
||
>
|
||
<Package size={14} /> Artículos
|
||
</button>
|
||
</div>
|
||
{catMode === 'servicios' && (
|
||
<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>
|
||
))}
|
||
<span className="w-px self-stretch bg-theme-border mx-1" />
|
||
<button
|
||
type="button"
|
||
onClick={() => setOrdenServicios('manual')}
|
||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
|
||
ordenServicios === 'manual' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||
}`}
|
||
title="Orden definido en el catálogo de Servicios"
|
||
>
|
||
Orden manual
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setOrdenServicios('best_sellers')}
|
||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
|
||
ordenServicios === 'best_sellers' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||
}`}
|
||
title="Ordenado por ventas de los últimos 90 días"
|
||
>
|
||
<TrendingUp size={12} /> Más vendidos
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{catMode !== 'paquetes' && (
|
||
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3 ml-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => cambiarVista('grid')}
|
||
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
|
||
vista === 'grid' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||
}`}
|
||
title="Vista de cuadrícula"
|
||
>
|
||
<LayoutGrid size={12} /> Cuadrícula
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => cambiarVista('lista')}
|
||
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
|
||
vista === 'lista' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||
}`}
|
||
title="Vista de lista (catálogos largos)"
|
||
>
|
||
<List size={12} /> Lista
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{catalogLoading ? (
|
||
<Skeleton count={8} className="h-16 w-full" />
|
||
) : catMode === 'paquetes' ? (
|
||
paquetesVisibles.length === 0 ? (
|
||
<EmptyState
|
||
title="Sin paquetes"
|
||
subtitle={paqueteCatalogo.length === 0
|
||
? 'Aún no hay paquetes dados de alta. Créalos en el módulo Paquetes.'
|
||
: 'Ningún paquete coincide con la búsqueda.'}
|
||
icon={<Layers size={28} />}
|
||
/>
|
||
) : (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
|
||
{paquetesVisibles.map((p) => {
|
||
const ahorro = p.session_price && p.price_per_session
|
||
? Math.round((1 - p.price_per_session / p.session_price) * 100)
|
||
: 0;
|
||
return (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
onClick={() => addPaqueteLine(p)}
|
||
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
|
||
title={p.notes || p.name}
|
||
>
|
||
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]">
|
||
{p.name}
|
||
</p>
|
||
<p className="text-[11px] text-theme-muted truncate">{p.service}</p>
|
||
<div className="flex items-center justify-between mt-1">
|
||
<span className="text-xs text-theme-muted">{p.sessions} sesiones</span>
|
||
<span className="text-sm font-bold text-theme-heading">{fmtMoney(p.price)}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between mt-0.5">
|
||
<span className="text-[10px] text-theme-muted">
|
||
{fmtMoney(p.price_per_session)} / sesión
|
||
</span>
|
||
{ahorro > 0 && (
|
||
<span className="text-[10px] font-semibold text-emerald-600">-{ahorro}%</span>
|
||
)}
|
||
</div>
|
||
{p.validity_days > 0 && (
|
||
<p className="text-[10px] text-theme-muted mt-0.5">Vigencia {p.validity_days} días</p>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)
|
||
) : vista === 'lista' ? (
|
||
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto border border-theme-border rounded-xl">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-theme-bg sticky top-0 z-10 text-theme-muted text-[11px] uppercase">
|
||
<tr>
|
||
<th
|
||
className="text-left px-3 py-2 cursor-pointer select-none"
|
||
onClick={() => alternarOrdenLista('nombre')}
|
||
title="Ordenar por nombre"
|
||
>
|
||
{catMode === 'servicios' ? 'Servicio' : 'Artículo'}
|
||
{ordenLista.campo === 'nombre' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
|
||
</th>
|
||
<th className="text-left px-3 py-2 hidden sm:table-cell">Categoría</th>
|
||
{catMode === 'servicios' && <th className="text-left px-3 py-2 hidden md:table-cell">Duración</th>}
|
||
<th
|
||
className="text-right px-3 py-2 cursor-pointer select-none"
|
||
onClick={() => alternarOrdenLista('precio')}
|
||
title="Ordenar por precio"
|
||
>
|
||
Precio
|
||
{ordenLista.campo === 'precio' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
|
||
</th>
|
||
<th className="px-3 py-2" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-theme-border">
|
||
{catMode === 'servicios' ? (
|
||
serviciosLista.length === 0 ? (
|
||
<tr><td colSpan={5} className="px-3 py-6 text-center text-theme-muted">Sin servicios con estos filtros.</td></tr>
|
||
) : serviciosLista.map((sv) => (
|
||
<tr key={sv.id} className="hover:bg-theme-bg">
|
||
<td className="px-3 py-2">
|
||
<p className="font-medium text-theme-heading">{sv.name}</p>
|
||
{sv.code && <p className="text-[11px] text-theme-muted">{sv.code}</p>}
|
||
</td>
|
||
<td className="px-3 py-2 text-theme-muted capitalize hidden sm:table-cell">{sv.category || '—'}</td>
|
||
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">{sv.duration_min} min</td>
|
||
<td className="px-3 py-2 text-right font-semibold text-theme-heading">{fmtMoney(sv.price)}</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<Button size="sm" onClick={() => addLine(sv)} title="Agregar al ticket">
|
||
<Plus size={13} className="mr-1" /> Agregar
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))
|
||
) : (
|
||
itemsLista.length === 0 ? (
|
||
<tr><td colSpan={4} className="px-3 py-6 text-center text-theme-muted">Sin artículos con esta búsqueda.</td></tr>
|
||
) : itemsLista.map((it) => (
|
||
<tr key={it.id} className="hover:bg-theme-bg">
|
||
<td className="px-3 py-2">
|
||
<p className="font-medium text-theme-heading">{it.name}</p>
|
||
<p className={`text-[11px] ${it.qty <= 0 ? 'text-rose-600' : 'text-theme-muted'}`}>
|
||
{it.qty} {it.unit || 'pzas'} en existencia
|
||
</p>
|
||
</td>
|
||
<td className="px-3 py-2 text-theme-muted hidden sm:table-cell">{it.category || '—'}</td>
|
||
<td className="px-3 py-2 text-right font-semibold text-theme-heading">
|
||
{(it.price ?? 0) > 0 ? fmtMoney(it.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<Button size="sm" onClick={() => addItemLine(it)} title="Agregar al ticket">
|
||
<Plus size={13} className="mr-1" /> Agregar
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : catMode === 'articulos' ? (
|
||
visiblesItems.length === 0 ? (
|
||
<EmptyState title="Sin artículos" subtitle="No hay artículos de inventario con esta búsqueda." icon={<Package size={28} />} />
|
||
) : (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
|
||
{visiblesItems.map((i) => (
|
||
<button
|
||
key={i.id}
|
||
type="button"
|
||
onClick={() => addItemLine(i)}
|
||
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={i.name}>
|
||
{i.name}
|
||
</p>
|
||
<div className="flex items-center justify-between mt-1">
|
||
<span className={`text-xs ${i.qty <= 0 ? 'text-rose-600' : 'text-theme-muted'}`}>{i.qty} {i.unit || 'pzas'}</span>
|
||
<span className="text-sm font-bold text-theme-heading">{(i.price ?? 0) > 0 ? fmtMoney(i.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
) : 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. El foco en este panel
|
||
habilita +/− para la cantidad de la última línea. */}
|
||
<Card className="hidden lg:flex lg:flex-col w-[390px] shrink-0 sticky top-6 max-h-[calc(100vh-7rem)]">
|
||
<div
|
||
className="flex flex-col flex-1 min-h-0 outline-none"
|
||
tabIndex={0}
|
||
onFocusCapture={() => setTicketFocused(true)}
|
||
onBlurCapture={() => setTicketFocused(false)}
|
||
title="Atajos: + y − ajustan la cantidad de la última línea cuando el ticket tiene el foco"
|
||
>
|
||
<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>
|
||
</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"
|
||
tabIndex={-1}
|
||
onFocusCapture={() => setTicketFocused(true)}
|
||
onBlurCapture={() => setTicketFocused(false)}
|
||
>
|
||
{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>
|
||
|
||
{/* Ayuda discreta de atajos de teclado */}
|
||
<Modal
|
||
isOpen={shortcutsOpen}
|
||
onClose={() => setShortcutsOpen(false)}
|
||
title="Atajos de teclado"
|
||
maxWidth="sm"
|
||
footer={<Button variant="outline" onClick={() => setShortcutsOpen(false)}>Cerrar</Button>}
|
||
>
|
||
<ul className="space-y-2 text-sm">
|
||
{[
|
||
{ k: 'F2', d: 'Ir al buscador de servicios/artículos' },
|
||
{ k: 'F9', d: 'Cobrar (abre el modal de cobro si hay líneas)' },
|
||
{ k: '+ / −', d: 'Cantidad de la última línea (con el ticket enfocado)' },
|
||
{ k: 'Esc', d: 'Cerrar el modal abierto' },
|
||
].map((s) => (
|
||
<li key={s.k} className="flex items-start gap-3">
|
||
<span className="shrink-0 rounded-md border border-theme-border-strong bg-theme-bg px-2 py-0.5 font-mono text-xs text-theme-heading">{s.k}</span>
|
||
<span className="text-theme-muted">{s.d}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</Modal>
|
||
|
||
{/* Abrir caja sin salir del POS */}
|
||
<Modal
|
||
isOpen={cajaModalOpen}
|
||
onClose={() => setCajaModalOpen(false)}
|
||
title="Abrir caja"
|
||
maxWidth="sm"
|
||
footer={
|
||
<>
|
||
<Button variant="outline" onClick={() => setCajaModalOpen(false)}>Cancelar</Button>
|
||
<Button onClick={abrirCajaDesdePos} loading={cajaAbriendo}>Abrir caja</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-theme-muted">
|
||
Se abre el corte de caja de hoy sin perder el ticket. El cierre guiado (conteo por método)
|
||
se hace en <Link to="/cortes" className="underline font-medium text-theme-heading">Cortes de caja</Link>.
|
||
</p>
|
||
<Input
|
||
label="Efectivo inicial (fondo de caja)"
|
||
type="number"
|
||
min={0}
|
||
step={0.01}
|
||
value={cajaFondo}
|
||
onChange={(e) => setCajaFondo(e.target.value)}
|
||
/>
|
||
</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">
|
||
<div className="border border-theme-border rounded-xl p-3 bg-theme-bg">
|
||
{doctorBlock}
|
||
<p className="text-xs text-theme-muted mt-1">
|
||
Se asigna la venta completa a este doctor para sus comisiones.
|
||
</p>
|
||
</div>
|
||
<Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
|
||
{metodo === 'cash' && !conPuntos && (
|
||
<>
|
||
<div>
|
||
<p className="block text-xs font-medium mb-1.5 text-theme-muted">Moneda</p>
|
||
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMoneda('MXN')}
|
||
className={`px-4 py-1.5 text-sm font-medium transition ${
|
||
moneda === 'MXN' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||
}`}
|
||
>
|
||
MXN
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => tcRate && setMoneda('USD')}
|
||
disabled={!tcRate}
|
||
title={!tcRate ? 'Configura el tipo de cambio en Configuración' : undefined}
|
||
className={`px-4 py-1.5 text-sm font-medium transition ${
|
||
moneda === 'USD' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||
} ${!tcRate ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||
>
|
||
USD
|
||
</button>
|
||
</div>
|
||
{moneda === 'USD' && tcRate && (
|
||
<p className="text-xs text-theme-muted mt-1.5">Tipo de cambio: {tcRate.toFixed(2)} MXN por 1 USD</p>
|
||
)}
|
||
</div>
|
||
<Input
|
||
label={moneda === 'USD' ? 'Recibido (USD)' : 'Recibido'}
|
||
type="number"
|
||
min={0}
|
||
step={10}
|
||
value={recibido}
|
||
onChange={(e) => setRecibido(e.target.value)}
|
||
placeholder={moneda === 'USD' && tcRate ? (total / tcRate).toFixed(2) : String(total)}
|
||
/>
|
||
{moneda === 'USD' && recibido && tcRate && (
|
||
<p className="text-sm text-theme-muted">≈ {fmtMoney(recibidoMxn)} MXN</p>
|
||
)}
|
||
{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)}
|
||
{moneda === 'USD' && cambio > 0 && (
|
||
<span className="text-theme-muted font-normal"> (≈ {fmtUsd(cambioUsd)})</span>
|
||
)}
|
||
</span>
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
{puedePuntos && moneda === 'MXN' && (
|
||
<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 saldo a favor ({puntosPaciente} pts = ${puntosPaciente})
|
||
</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: se ofrece automáticamente tras el cobro (imprimible ~80mm) */}
|
||
<TicketPrint
|
||
isOpen={!!resultado}
|
||
onClose={nuevaVenta}
|
||
footerExtra={<Button variant="outline" onClick={nuevaVenta}>Nueva venta</Button>}
|
||
ticket={resultado && {
|
||
folio: resultado.sale.name,
|
||
fecha: `${resultado.sale.date} ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`,
|
||
paciente: resultado.sale.patient,
|
||
lines: resultado.sale.lines.map((l) => ({
|
||
name: l.service || l.item || l.description,
|
||
qty: l.quantity,
|
||
price: l.price_unit,
|
||
subtotal: l.subtotal,
|
||
})),
|
||
subtotal: resultado.sale.subtotal,
|
||
discount: resultado.sale.discount,
|
||
tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0,
|
||
tax_name: impuestos.nombre,
|
||
total: resultado.sale.total,
|
||
metodo: resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos',
|
||
puntos_usados: resultado.puntos_usados,
|
||
puntos_ganados: resultado.puntos_ganados,
|
||
wallet_points: resultado.wallet_points,
|
||
cambio: resultado.cambio,
|
||
recibido: resultado.recibido,
|
||
cambio_usd: resultado.cambio_usd,
|
||
recibido_usd: resultado.recibido_usd,
|
||
rate_used: resultado.rate_used,
|
||
cajero: resultado.sale.user_login,
|
||
doctor: resultado.sale.doctor,
|
||
telefono_paciente: resultado.sale.patient_phone,
|
||
notas: resultado.sale.notes,
|
||
}}
|
||
/>
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default Pos;
|