all
This commit is contained in:
1 parent
90125aede9
commit
2d6c29a1f3
404 files changed
+126983
-85
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user