572 lines
21 KiB
TypeScript
572 lines
21 KiB
TypeScript
"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>
|
||
);
|
||
}
|