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

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