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

252 lines
10 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 { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { iconClockCircle, iconPrinter, iconUndo } from "../../_core/icons";
import { apiClient } from "../../_core/services/api-client";
import {
cartProductsResource,
cashMovementsResource,
cashSessionsResource,
customersResource,
inventoryMovementsResource,
paymentsResource,
productHistoryResource,
productsResource,
shopCartsResource,
usersResource,
} from "../../_core/services/entities";
import { ConfirmDialog } from "../../_core/components/confirm-dialog";
import { Dialog } from "../../_core/components/dialog";
import { TableShell, tdClass, thClass } from "../../_core/components/table-shell";
import type { ShopCart } from "../../_core/types/entities";
const PAYMENT_LABELS: Record<string, string> = { cash: "Efectivo", card: "Tarjeta", transfer: "Transferencia" };
function money(value: string | number) {
return Number(value).toLocaleString("es-AR", { style: "currency", currency: "USD" });
}
export default function PosHistoryPage() {
const queryClient = useQueryClient();
const { data: cartsRaw, isLoading } = shopCartsResource.useList();
const carts = useMemo(
() => (cartsRaw ? [...cartsRaw].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : isLoading ? null : []),
[cartsRaw, isLoading],
);
const { data: cartProducts = [] } = cartProductsResource.useList();
const { data: history = [] } = productHistoryResource.useList();
const { data: products = [] } = productsResource.useList();
const { data: payments = [] } = paymentsResource.useList();
const { data: users = [] } = usersResource.useList();
const { data: customers = [] } = customersResource.useList();
const { data: sessions = [] } = cashSessionsResource.useList();
const [selected, setSelected] = useState<ShopCart | null>(null);
const [returning, setReturning] = useState<ShopCart | null>(null);
function refreshAfterReturn() {
queryClient.invalidateQueries({ queryKey: shopCartsResource.key });
queryClient.invalidateQueries({ queryKey: inventoryMovementsResource.key });
queryClient.invalidateQueries({ queryKey: paymentsResource.key });
queryClient.invalidateQueries({ queryKey: cashMovementsResource.key });
queryClient.invalidateQueries({ queryKey: cartProductsResource.key });
}
function linesFor(cartId: number) {
return cartProducts
.filter((cp) => cp.cartId === cartId)
.map((cp) => {
const h = history.find((row) => row.id === cp.historyId);
const product = h ? products.find((p) => p.id === h.productId) : undefined;
return {
productId: h?.productId ?? 0,
name: product?.name ?? "Producto",
unitPrice: h ? Number(h.unitPrice) : 0,
quantity: cp.quantity,
};
});
}
function cashierName(userId: number | null) {
return users.find((u) => u.id === userId)?.name ?? "—";
}
function customerName(customerId: number | null) {
if (!customerId) return "Cliente mostrador";
return customers.find((c) => c.id === customerId)?.name ?? "Cliente mostrador";
}
function paymentFor(cartId: number) {
return payments.find((p) => p.cartId === cartId) ?? null;
}
const visibleCarts = useMemo(
() => carts?.filter((c) => c.status === "paid" || c.status === "refunded") ?? [],
[carts],
);
async function handleReturn() {
if (!returning) return;
const lines = linesFor(returning.id);
for (const line of lines) {
const warehouses = await apiClient.get<{ inventoryId: number; productId: number }[]>("/warehouses");
const inventoryId = warehouses.find((w) => w.productId === line.productId)?.inventoryId;
if (!inventoryId) continue;
await apiClient.post("/inventory-movements", {
productId: line.productId,
inventoryId,
type: "in",
quantity: line.quantity,
reason: `Devolución venta #${returning.id}`,
});
}
const original = paymentFor(returning.id);
await apiClient.post("/payments", {
cartId: returning.id,
userId: original?.id ?? 0,
amount: `-${returning.totalPrice}`,
status: "refunded",
paid: true,
paymentMethod: original?.paymentMethod ?? "cash",
paidAt: new Date().toISOString(),
});
if ((original?.paymentMethod ?? "cash") === "cash") {
const openSession = sessions.find((s) => s.status === "open");
if (openSession) {
await apiClient.post("/cash-movements", {
sessionId: openSession.id,
type: "out",
amount: returning.totalPrice,
reason: `Devolución venta #${returning.id}`,
});
}
}
await apiClient.patch(`/shop-carts/${returning.id}`, { status: "refunded" });
setReturning(null);
setSelected(null);
refreshAfterReturn();
}
return (
<div>
<h1 className="mb-1 text-xl font-semibold text-foreground">Historial de ventas</h1>
<p className="mb-6 text-sm text-muted">Ventas cobradas, reimpresión de tickets y devoluciones.</p>
{carts === null ? (
<div className="flex min-h-40 items-center justify-center text-sm text-muted">Cargando…</div>
) : visibleCarts.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={iconClockCircle} width={28} height={28} className="text-muted" aria-hidden="true" />
<p className="text-sm text-muted">Todavía no hay ventas registradas.</p>
</div>
) : (
<TableShell>
<thead>
<tr className="border-b border-border">
<th className={thClass}>Venta</th>
<th className={thClass}>Cliente</th>
<th className={thClass}>Cajero</th>
<th className={thClass}>Total</th>
<th className={thClass}>Estado</th>
<th className={thClass}>Fecha</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{visibleCarts.map((cart) => (
<tr
key={cart.id}
onClick={() => setSelected(cart)}
className="cursor-pointer transition hover:bg-foreground/5"
>
<td className={`${tdClass} font-medium`}>#{cart.id}</td>
<td className={`${tdClass} text-muted`}>{customerName(cart.customerId)}</td>
<td className={`${tdClass} text-muted`}>{cashierName(cart.userId)}</td>
<td className={`${tdClass} tabular-nums`}>{money(cart.totalPrice)}</td>
<td className={tdClass}>
<span
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${
cart.status === "refunded" ? "bg-danger-soft text-danger" : "bg-success-soft text-success-soft-foreground"
}`}
>
{cart.status === "refunded" ? "Devuelta" : "Pagada"}
</span>
</td>
<td className={`${tdClass} text-muted`}>
{new Date(cart.createdAt).toLocaleString("es-AR", { dateStyle: "short", timeStyle: "short" })}
</td>
</tr>
))}
</tbody>
</TableShell>
)}
<Dialog open={selected !== null} onClose={() => setSelected(null)} title={`Venta #${selected?.id ?? ""}`} widthClassName="max-w-sm">
{selected && (
<div className="flex flex-col gap-4">
<div className="rounded-2xl border border-dashed border-border p-4 text-sm">
<p className="text-center text-xs text-muted">
{new Date(selected.createdAt).toLocaleString("es-AR")}
</p>
<p className="text-center text-xs text-muted">{customerName(selected.customerId)}</p>
<div className="my-3 flex flex-col gap-1 border-y border-dashed border-border py-3">
{linesFor(selected.id).map((l, i) => (
<div key={i} 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 justify-between text-xs text-muted">
<span>Descuento</span>
<span className="tabular-nums">-{money(selected.discount)}</span>
</div>
<div className="flex justify-between text-sm font-semibold text-foreground">
<span>Total</span>
<span className="tabular-nums">{money(selected.totalPrice)}</span>
</div>
<p className="mt-2 text-center text-xs text-muted">
{paymentFor(selected.id) ? PAYMENT_LABELS[paymentFor(selected.id)!.paymentMethod] : ""}
</p>
</div>
<div className="flex justify-end gap-2">
{selected.status === "paid" && (
<button
type="button"
onClick={() => setReturning(selected)}
className="flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium text-danger transition hover:bg-danger-soft"
>
<Icon icon={iconUndo} width={16} height={16} aria-hidden="true" />
Devolver
</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" />
Reimprimir
</button>
</div>
</div>
)}
</Dialog>
<ConfirmDialog
open={returning !== null}
onClose={() => setReturning(null)}
title="Devolver venta"
description={`Se repondrá el stock de la venta #${returning?.id} y se registrará el reembolso. Esta acción no se puede deshacer.`}
confirmLabel="Confirmar devolución"
onConfirm={handleReturn}
/>
</div>
);
}