This commit is contained in:
Emmanuel Rojas committed 2026-09-30 14:45:10 -05:00
1 parent 90125aede9
commit 2d6c29a1f3
404 files changed
+126983 -85

No files matched your search

+571
View File
@@ -0,0 +1,571 @@
"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>
);
}