252 lines
10 KiB
TypeScript
252 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|