Files
PDV/apps/web/src/app/pos/page.tsx
T
2026-09-30 14:45:10 -05:00

572 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<CartLine[]>([]);
const [query, setQuery] = useState("");
const [barcode, setBarcode] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [discount, setDiscount] = useState(0);
const [customerId, setCustomerId] = useState<string>("");
const [paymentMethod, setPaymentMethod] = useState("cash");
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [checkingOut, setCheckingOut] = useState(false);
const [lastSale, setLastSale] = useState<CompletedSale | null>(null);
const barcodeRef = useRef<HTMLInputElement>(null);
const searchRef = useRef<HTMLInputElement>(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<number, ProductHistoryRow>();
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<number, number>();
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<number, number>();
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 <div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>;
}
if (session === null) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-accent-soft">
<Icon icon={iconWalletMoney} width={32} height={32} className="text-accent-soft-foreground" aria-hidden="true" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">No hay una caja abierta</h1>
<p className="text-sm text-muted">Abrí la caja desde el botón en la parte superior antes de vender.</p>
</div>
</div>
);
}
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_380px]">
<div className="flex flex-col gap-4">
<form onSubmit={handleBarcodeSubmit} className="flex gap-2">
<div className="relative flex-1">
<Icon
icon={iconBarcode}
width={18}
height={18}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<input
ref={barcodeRef}
type="text"
value={barcode}
onChange={(e) => setBarcode(e.target.value)}
placeholder="Escanear o escribir código de barras…"
autoFocus
className="w-full rounded-field border border-border bg-background py-2.5 pl-10 pr-3 text-sm text-foreground placeholder-muted outline-none transition focus:border-focus"
/>
</div>
<button
type="submit"
className="rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover"
>
Agregar
</button>
</form>
<div className="relative">
<TextInput
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar producto por nombre o SKU…"
className="pr-16"
/>
<div className="pointer-events-none absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Kbd className="text-[10px]">
<Kbd.Abbr keyValue="command" />
</Kbd>
<Kbd className="text-[10px]">K</Kbd>
</div>
{results.length > 0 && (
<ul className="absolute left-0 top-full z-20 mt-1.5 w-full overflow-hidden rounded-field border border-border bg-surface shadow-lg">
{results.map((p) => {
const stock = stockByProduct.get(p.id) ?? 0;
const price = activePriceByProduct.get(p.id);
return (
<li key={p.id}>
<button
type="button"
onClick={() => addProduct(p)}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left text-sm transition hover:bg-foreground/5"
>
<span className="flex items-center gap-2.5">
<Icon icon={iconBox} width={16} height={16} className="text-muted" aria-hidden="true" />
<span>
<span className="block text-foreground">{p.name}</span>
<span className="block text-xs text-muted">
{p.sku} · stock: {stock}
</span>
</span>
</span>
<span className="tabular-nums text-foreground">{price ? money(Number(price.unitPrice)) : "Sin precio"}</span>
</button>
</li>
);
})}
</ul>
)}
</div>
{addError && (
<div role="alert" className="rounded-lg border border-danger/30 bg-danger-soft px-4 py-2 text-sm text-danger">
{addError}
</div>
)}
{cart.length === 0 ? (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border p-8 text-center">
<Icon icon={iconCart} width={32} height={32} className="text-muted" aria-hidden="true" />
<p className="text-sm text-muted">El carrito está vacío. Buscá o escaneá un producto.</p>
</div>
) : (
<div className="flex flex-col gap-2">
{cart.map((line) => (
<div key={line.productId} className="flex items-center gap-3 rounded-2xl border border-border bg-surface p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{line.name}</p>
<p className="text-xs text-muted">
{money(line.unitPrice)} c/u · SKU {line.sku}
</p>
</div>
<input
type="number"
min={1}
value={line.quantity}
onChange={(e) => 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"
/>
<p className="w-20 shrink-0 text-right text-sm font-medium tabular-nums text-foreground">
{money(line.unitPrice * line.quantity)}
</p>
<button
type="button"
onClick={() => removeLine(line.productId)}
aria-label={`Quitar ${line.name}`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted transition hover:bg-danger-soft hover:text-danger"
>
<Icon icon={iconCloseSquare} width={16} height={16} aria-hidden="true" />
</button>
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-surface p-4">
<h2 className="text-sm font-semibold text-foreground">Resumen</h2>
<div>
<label htmlFor="pos-customer" className="mb-1.5 block text-sm font-medium text-foreground">
Cliente
</label>
<SelectInput id="pos-customer" value={customerId} onChange={(e) => setCustomerId(e.target.value)}>
<option value="">Cliente mostrador</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</SelectInput>
</div>
<div>
<label htmlFor="pos-payment" className="mb-1.5 block text-sm font-medium text-foreground">
Método de pago
</label>
<SelectInput id="pos-payment" value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}>
{PAYMENT_METHODS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</SelectInput>
</div>
<div>
<label htmlFor="pos-discount" className="mb-1.5 block text-sm font-medium text-foreground">
Descuento
</label>
<TextInput
id="pos-discount"
type="number"
min={0}
step="0.01"
value={discount}
onChange={(e) => setDiscount(Math.max(0, Number(e.target.value)))}
/>
</div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3 text-sm">
<div className="flex justify-between text-muted">
<span>Subtotal</span>
<span className="tabular-nums">{money(subtotal)}</span>
</div>
<div className="flex justify-between text-muted">
<span>Descuento</span>
<span className="tabular-nums">-{money(discount)}</span>
</div>
<div className="flex justify-between text-base font-semibold text-foreground">
<span>Total</span>
<span className="tabular-nums">{money(total)}</span>
</div>
</div>
{checkoutError && (
<p role="alert" className="text-sm text-danger">
{checkoutError}
</p>
)}
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleCheckout}
disabled={cart.length === 0 || checkingOut}
className="rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-accent-foreground transition hover:bg-accent-hover disabled:opacity-50"
>
{checkingOut ? "Cobrando…" : `Cobrar ${money(total)}`}
</button>
<button
type="button"
onClick={clearCart}
disabled={cart.length === 0}
className="flex items-center justify-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground disabled:opacity-50"
>
<Icon icon={iconMinusCircle} width={15} height={15} aria-hidden="true" />
Vaciar carrito
</button>
</div>
</div>
<Dialog
open={lastSale !== null}
onClose={() => setLastSale(null)}
title="Venta completada"
widthClassName="max-w-sm"
>
{lastSale && (
<div className="flex flex-col gap-4">
<div id="pos-ticket" className="rounded-2xl border border-dashed border-border p-4 text-sm">
<p className="text-center font-semibold text-foreground">Ticket de venta #{lastSale.cartId}</p>
<p className="text-center text-xs text-muted">
{new Date(lastSale.createdAt).toLocaleString("es-AR")}
</p>
{lastSale.customerName && (
<p className="mt-2 text-center text-xs text-muted">Cliente: {lastSale.customerName}</p>
)}
<div className="my-3 flex flex-col gap-1 border-y border-dashed border-border py-3">
{lastSale.lines.map((l) => (
<div key={l.productId} className="flex justify-between text-xs">
<span className="text-foreground">
{l.quantity} × {l.name}
</span>
<span className="tabular-nums text-foreground">{money(l.unitPrice * l.quantity)}</span>
</div>
))}
</div>
<div className="flex flex-col gap-1 text-xs text-muted">
<div className="flex justify-between">
<span>Subtotal</span>
<span className="tabular-nums">{money(lastSale.subtotal)}</span>
</div>
<div className="flex justify-between">
<span>Descuento</span>
<span className="tabular-nums">-{money(lastSale.discount)}</span>
</div>
<div className="flex justify-between text-sm font-semibold text-foreground">
<span>Total</span>
<span className="tabular-nums">{money(lastSale.total)}</span>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setLastSale(null)}
className="rounded-lg px-3.5 py-2 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground"
>
Cerrar
</button>
<button
type="button"
onClick={() => window.print()}
className="flex items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-accent-foreground transition hover:bg-accent-hover"
>
<Icon icon={iconPrinter} width={16} height={16} aria-hidden="true" />
Imprimir
</button>
</div>
</div>
)}
</Dialog>
</div>
);
}