"use client"; import { Icon } from "@iconify/react"; import { Kbd } from "@heroui/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { iconBarcode, iconBox, iconCart, iconCloseSquare, iconMinusCircle, iconPrinter, iconWalletMoney, } from "../_core/icons"; import { apiClient, ApiError } from "../_core/services/api-client"; import { cartProductsResource, cashMovementsResource, customersResource, inventoryMovementsResource, paymentsResource, productHistoryResource, productsResource, shopCartsResource, warehousesResource, } from "../_core/services/entities"; import { useSessionUser } from "../_core/hooks/use-session-user"; import { Dialog } from "../_core/components/dialog"; import { SelectInput, TextInput } from "../_core/components/form-controls"; import type { Product, ProductHistoryRow } from "../_core/types/entities"; import { useCashSession } from "./_components/cash-session-context"; interface CartLine { productId: number; name: string; sku: string; historyId: number; unitPrice: number; quantity: number; } interface CompletedSale { cartId: number; lines: CartLine[]; subtotal: number; discount: number; total: number; paymentMethod: string; customerName: string | null; createdAt: string; } const PAYMENT_METHODS = [ { value: "cash", label: "Efectivo" }, { value: "card", label: "Tarjeta" }, { value: "transfer", label: "Transferencia" }, ]; function money(value: number) { return value.toLocaleString("es-AR", { style: "currency", currency: "USD" }); } export default function PosSalePage() { const { user } = useSessionUser(); const { session, refresh: refreshSession } = useCashSession(); const queryClient = useQueryClient(); const { data: products = [] } = productsResource.useList(); const { data: history = [] } = productHistoryResource.useList(); const { data: warehouses = [] } = warehousesResource.useList(); const { data: customers = [] } = customersResource.useList(); const [cart, setCart] = useState([]); const [query, setQuery] = useState(""); const [barcode, setBarcode] = useState(""); const [addError, setAddError] = useState(null); const [discount, setDiscount] = useState(0); const [customerId, setCustomerId] = useState(""); const [paymentMethod, setPaymentMethod] = useState("cash"); const [checkoutError, setCheckoutError] = useState(null); const [checkingOut, setCheckingOut] = useState(false); const [lastSale, setLastSale] = useState(null); const barcodeRef = useRef(null); const searchRef = useRef(null); useEffect(() => { function onKeyDown(e: KeyboardEvent) { const isShortcut = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; if (!isShortcut) return; e.preventDefault(); searchRef.current?.focus(); searchRef.current?.select(); } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, []); function invalidateSaleData() { queryClient.invalidateQueries({ queryKey: shopCartsResource.key }); queryClient.invalidateQueries({ queryKey: cartProductsResource.key }); queryClient.invalidateQueries({ queryKey: inventoryMovementsResource.key }); queryClient.invalidateQueries({ queryKey: paymentsResource.key }); queryClient.invalidateQueries({ queryKey: cashMovementsResource.key }); queryClient.invalidateQueries({ queryKey: warehousesResource.key }); } const activePriceByProduct = useMemo(() => { const map = new Map(); for (const row of history) { if (row.status !== "active") continue; const existing = map.get(row.productId); if (!existing || row.createdAt > existing.createdAt) map.set(row.productId, row); } return map; }, [history]); const stockByProduct = useMemo(() => { const map = new Map(); if (!session) return map; for (const w of warehouses) { if (w.inventoryId === session.inventoryId) map.set(w.productId, w.stock); } return map; }, [warehouses, session]); const inCartQty = useMemo(() => { const map = new Map(); for (const line of cart) map.set(line.productId, (map.get(line.productId) ?? 0) + line.quantity); return map; }, [cart]); const results = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; return products .filter((p) => p.name.toLowerCase().includes(q) || p.sku.toLowerCase().includes(q)) .slice(0, 8); }, [products, query]); function addProduct(product: Product, quantity = 1) { setAddError(null); const price = activePriceByProduct.get(product.id); if (!price) { setAddError(`"${product.name}" no tiene un precio configurado.`); return; } const stock = stockByProduct.get(product.id) ?? 0; const alreadyInCart = inCartQty.get(product.id) ?? 0; if (alreadyInCart + quantity > stock) { setAddError(`Stock insuficiente de "${product.name}" (disponible: ${stock}).`); return; } setCart((prev) => { const existing = prev.find((l) => l.productId === product.id); if (existing) { return prev.map((l) => (l.productId === product.id ? { ...l, quantity: l.quantity + quantity } : l)); } return [ ...prev, { productId: product.id, name: product.name, sku: product.sku, historyId: price.id, unitPrice: Number(price.unitPrice), quantity, }, ]; }); setQuery(""); } function handleBarcodeSubmit(e: React.FormEvent) { e.preventDefault(); const code = barcode.trim(); if (!code) return; const product = products.find((p) => p.barcode === code); if (!product) { setAddError(`No se encontró ningún producto con el código "${code}".`); } else { addProduct(product); } setBarcode(""); barcodeRef.current?.focus(); } function updateQuantity(productId: number, quantity: number) { if (quantity <= 0) { setCart((prev) => prev.filter((l) => l.productId !== productId)); return; } const stock = stockByProduct.get(productId) ?? 0; if (quantity > stock) { setAddError(`Stock insuficiente (disponible: ${stock}).`); return; } setAddError(null); setCart((prev) => prev.map((l) => (l.productId === productId ? { ...l, quantity } : l))); } function removeLine(productId: number) { setCart((prev) => prev.filter((l) => l.productId !== productId)); } function clearCart() { setCart([]); setDiscount(0); setCustomerId(""); setCheckoutError(null); } const subtotal = cart.reduce((sum, l) => sum + l.unitPrice * l.quantity, 0); const total = Math.max(0, subtotal - discount); async function handleCheckout() { if (!session || !user || cart.length === 0) return; setCheckingOut(true); setCheckoutError(null); try { const cartRecord = await apiClient.post<{ id: number }>("/shop-carts", { totalPrice: total.toFixed(2), discount: discount.toFixed(2), status: "open", userId: user.id, customerId: customerId ? Number(customerId) : null, }); for (const line of cart) { await apiClient.post("/cart-products", { cartId: cartRecord.id, historyId: line.historyId, quantity: line.quantity, }); await apiClient.post("/inventory-movements", { productId: line.productId, inventoryId: session.inventoryId, type: "out", quantity: line.quantity, reason: `Venta #${cartRecord.id}`, }); } await apiClient.post("/payments", { cartId: cartRecord.id, userId: user.id, amount: total.toFixed(2), status: "completed", paid: true, paymentMethod, paidAt: new Date().toISOString(), }); if (paymentMethod === "cash") { await apiClient.post("/cash-movements", { sessionId: session.id, type: "in", amount: total.toFixed(2), reason: `Venta #${cartRecord.id}`, userId: user.id, }); } await apiClient.patch(`/shop-carts/${cartRecord.id}`, { status: "paid" }); setLastSale({ cartId: cartRecord.id, lines: cart, subtotal, discount, total, paymentMethod, customerName: customerId ? customers.find((c) => String(c.id) === customerId)?.name ?? null : null, createdAt: new Date().toISOString(), }); clearCart(); invalidateSaleData(); refreshSession(); } catch (err) { setCheckoutError(err instanceof ApiError ? err.message : "No se pudo completar la venta."); } finally { setCheckingOut(false); } } if (session === undefined) { return
Cargando…
; } if (session === null) { return (

No hay una caja abierta

Abrí la caja desde el botón en la parte superior antes de vender.

); } return (
setQuery(e.target.value)} placeholder="Buscar producto por nombre o SKU…" className="pr-16" />
K
{results.length > 0 && (
    {results.map((p) => { const stock = stockByProduct.get(p.id) ?? 0; const price = activePriceByProduct.get(p.id); return (
  • ); })}
)}
{addError && (
{addError}
)} {cart.length === 0 ? (
) : (
{cart.map((line) => (

{line.name}

{money(line.unitPrice)} c/u · SKU {line.sku}

updateQuantity(line.productId, Number(e.target.value))} className="w-16 rounded-field border border-border bg-background px-2 py-1.5 text-center text-sm tabular-nums text-foreground outline-none focus:border-focus" />

{money(line.unitPrice * line.quantity)}

))}
)}

Resumen

setCustomerId(e.target.value)}> {customers.map((c) => ( ))}
setPaymentMethod(e.target.value)}> {PAYMENT_METHODS.map((m) => ( ))}
setDiscount(Math.max(0, Number(e.target.value)))} />
Subtotal {money(subtotal)}
Descuento -{money(discount)}
Total {money(total)}
{checkoutError && (

{checkoutError}

)}
setLastSale(null)} title="Venta completada" widthClassName="max-w-sm" > {lastSale && (

Ticket de venta #{lastSale.cartId}

{new Date(lastSale.createdAt).toLocaleString("es-AR")}

{lastSale.customerName && (

Cliente: {lastSale.customerName}

)}
{lastSale.lines.map((l) => (
{l.quantity} × {l.name} {money(l.unitPrice * l.quantity)}
))}
Subtotal {money(lastSale.subtotal)}
Descuento -{money(lastSale.discount)}
Total {money(lastSale.total)}
)}
); }