421 lines
17 KiB
TypeScript
421 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { Icon } from "@iconify/react";
|
|
import { useMemo, useState } from "react";
|
|
import {
|
|
iconBanknote,
|
|
iconBox,
|
|
iconCard,
|
|
iconChart,
|
|
iconDocumentText,
|
|
iconUsersGroup,
|
|
iconWalletMoney,
|
|
} from "../../_core/icons";
|
|
import {
|
|
cartProductsResource,
|
|
cashSessionsResource,
|
|
inventoriesResource,
|
|
paymentsResource,
|
|
productHistoryResource,
|
|
productsResource,
|
|
shopCartsResource,
|
|
usersResource,
|
|
warehousesResource,
|
|
} from "../../_core/services/entities";
|
|
import { TableShell, tdClass, thClass } from "../../_core/components/table-shell";
|
|
|
|
const PAYMENT_LABELS: Record<string, string> = { cash: "Efectivo", card: "Tarjeta", transfer: "Transferencia" };
|
|
|
|
const TABS = [
|
|
{ id: "dia", label: "Ventas del día", icon: iconBanknote },
|
|
{ id: "producto", label: "Ventas por producto", icon: iconBox },
|
|
{ id: "empleado", label: "Ventas por empleado", icon: iconUsersGroup },
|
|
{ id: "utilidad", label: "Utilidad", icon: iconChart },
|
|
{ id: "inventario", label: "Inventario", icon: iconWalletMoney },
|
|
{ id: "top", label: "Más vendidos", icon: iconBox },
|
|
{ id: "pagos", label: "Métodos de pago", icon: iconCard },
|
|
{ id: "cortes", label: "Cortes de caja", icon: iconDocumentText },
|
|
] as const;
|
|
|
|
type TabId = (typeof TABS)[number]["id"];
|
|
|
|
function money(value: number | string) {
|
|
return Number(value).toLocaleString("es-AR", { style: "currency", currency: "USD" });
|
|
}
|
|
|
|
function isToday(iso: string) {
|
|
const d = new Date(iso);
|
|
const now = new Date();
|
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
|
}
|
|
|
|
export default function AdminReportsPage() {
|
|
const [tab, setTab] = useState<TabId>("dia");
|
|
const { data: carts = [] } = shopCartsResource.useList();
|
|
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: warehouses = [] } = warehousesResource.useList();
|
|
const { data: inventories = [] } = inventoriesResource.useList();
|
|
const { data: sessions = [] } = cashSessionsResource.useList();
|
|
|
|
const paidCarts = useMemo(() => carts.filter((c) => c.status === "paid"), [carts]);
|
|
|
|
function productName(id: number) {
|
|
return products.find((p) => p.id === id)?.name ?? `Producto #${id}`;
|
|
}
|
|
function userName(id: number | null) {
|
|
return users.find((u) => u.id === id)?.name ?? "—";
|
|
}
|
|
|
|
const productAgg = useMemo(() => {
|
|
const map = new Map<number, { productId: number; quantity: number; revenue: number; profit: number }>();
|
|
for (const cp of cartProducts) {
|
|
const cart = paidCarts.find((c) => c.id === cp.cartId);
|
|
if (!cart) continue;
|
|
const h = history.find((row) => row.id === cp.historyId);
|
|
if (!h) continue;
|
|
const entry = map.get(h.productId) ?? { productId: h.productId, quantity: 0, revenue: 0, profit: 0 };
|
|
entry.quantity += cp.quantity;
|
|
entry.revenue += Number(h.unitPrice) * cp.quantity;
|
|
entry.profit += (Number(h.unitPrice) - Number(h.costUnit)) * cp.quantity;
|
|
map.set(h.productId, entry);
|
|
}
|
|
return Array.from(map.values());
|
|
}, [cartProducts, paidCarts, history]);
|
|
|
|
const employeeAgg = useMemo(() => {
|
|
const map = new Map<number, { userId: number; count: number; total: number }>();
|
|
for (const cart of paidCarts) {
|
|
const key = cart.userId ?? 0;
|
|
const entry = map.get(key) ?? { userId: key, count: 0, total: 0 };
|
|
entry.count += 1;
|
|
entry.total += Number(cart.totalPrice);
|
|
map.set(key, entry);
|
|
}
|
|
return Array.from(map.values()).sort((a, b) => b.total - a.total);
|
|
}, [paidCarts]);
|
|
|
|
const paymentAgg = useMemo(() => {
|
|
const map = new Map<string, { method: string; count: number; total: number }>();
|
|
for (const p of payments) {
|
|
if (p.status !== "completed" || Number(p.amount) <= 0) continue;
|
|
const entry = map.get(p.paymentMethod) ?? { method: p.paymentMethod, count: 0, total: 0 };
|
|
entry.count += 1;
|
|
entry.total += Number(p.amount);
|
|
map.set(p.paymentMethod, entry);
|
|
}
|
|
return Array.from(map.values()).sort((a, b) => b.total - a.total);
|
|
}, [payments]);
|
|
|
|
const todayCarts = useMemo(() => paidCarts.filter((c) => isToday(c.createdAt)), [paidCarts]);
|
|
const todayTotal = todayCarts.reduce((sum, c) => sum + Number(c.totalPrice), 0);
|
|
const totalProfit = productAgg.reduce((sum, p) => sum + p.profit, 0);
|
|
const totalRevenue = productAgg.reduce((sum, p) => sum + p.revenue, 0);
|
|
const topProducts = useMemo(() => [...productAgg].sort((a, b) => b.quantity - a.quantity).slice(0, 10), [productAgg]);
|
|
const closedSessions = useMemo(
|
|
() => sessions.filter((s) => s.status === "closed").sort((a, b) => (b.closedAt ?? "").localeCompare(a.closedAt ?? "")),
|
|
[sessions],
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6">
|
|
<h1 className="text-xl font-semibold text-foreground">Reportes</h1>
|
|
<p className="text-sm text-muted">Ventas, utilidad, inventario y cortes de caja.</p>
|
|
</div>
|
|
|
|
<nav aria-label="Reportes" className="mb-6 flex flex-wrap gap-1 border-b border-border">
|
|
{TABS.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => setTab(t.id)}
|
|
aria-current={tab === t.id ? "page" : undefined}
|
|
className={`flex items-center gap-2 border-b-2 px-3 py-2.5 text-sm font-medium transition ${
|
|
tab === t.id ? "border-accent text-foreground" : "border-transparent text-muted hover:text-foreground"
|
|
}`}
|
|
>
|
|
<Icon icon={t.icon} width={15} height={15} aria-hidden="true" />
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{tab === "dia" && (
|
|
<div>
|
|
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
|
<div className="rounded-2xl border border-border bg-surface p-4">
|
|
<p className="text-xs text-muted">Ventas de hoy</p>
|
|
<p className="text-lg font-semibold tabular-nums text-foreground">{todayCarts.length}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-border bg-accent-soft p-4">
|
|
<p className="text-xs text-accent-soft-foreground">Total de hoy</p>
|
|
<p className="text-lg font-semibold tabular-nums text-accent-soft-foreground">{money(todayTotal)}</p>
|
|
</div>
|
|
</div>
|
|
{todayCarts.length === 0 ? (
|
|
<p className="text-sm text-muted">Todavía no hay ventas hoy.</p>
|
|
) : (
|
|
<TableShell>
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className={thClass}>Venta</th>
|
|
<th className={thClass}>Cajero</th>
|
|
<th className={thClass}>Total</th>
|
|
<th className={thClass}>Hora</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{todayCarts.map((c) => (
|
|
<tr key={c.id}>
|
|
<td className={`${tdClass} font-medium`}>#{c.id}</td>
|
|
<td className={`${tdClass} text-muted`}>{userName(c.userId)}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{money(c.totalPrice)}</td>
|
|
<td className={`${tdClass} text-muted`}>
|
|
{new Date(c.createdAt).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" })}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</TableShell>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === "producto" && (
|
|
<TableShell>
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className={thClass}>Producto</th>
|
|
<th className={thClass}>Unidades vendidas</th>
|
|
<th className={thClass}>Ingresos</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{productAgg.length === 0 ? (
|
|
<tr>
|
|
<td className={tdClass} colSpan={3}>
|
|
<span className="text-muted">Sin ventas registradas.</span>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
productAgg
|
|
.sort((a, b) => b.revenue - a.revenue)
|
|
.map((p) => (
|
|
<tr key={p.productId}>
|
|
<td className={`${tdClass} font-medium`}>{productName(p.productId)}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{p.quantity}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{money(p.revenue)}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</TableShell>
|
|
)}
|
|
|
|
{tab === "empleado" && (
|
|
<TableShell>
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className={thClass}>Empleado</th>
|
|
<th className={thClass}>Ventas</th>
|
|
<th className={thClass}>Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{employeeAgg.length === 0 ? (
|
|
<tr>
|
|
<td className={tdClass} colSpan={3}>
|
|
<span className="text-muted">Sin ventas registradas.</span>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
employeeAgg.map((e) => (
|
|
<tr key={e.userId}>
|
|
<td className={`${tdClass} font-medium`}>{userName(e.userId)}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{e.count}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{money(e.total)}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</TableShell>
|
|
)}
|
|
|
|
{tab === "utilidad" && (
|
|
<div>
|
|
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
|
<div className="rounded-2xl border border-border bg-surface p-4">
|
|
<p className="text-xs text-muted">Ingresos totales</p>
|
|
<p className="text-lg font-semibold tabular-nums text-foreground">{money(totalRevenue)}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-border bg-accent-soft p-4">
|
|
<p className="text-xs text-accent-soft-foreground">Utilidad total</p>
|
|
<p className="text-lg font-semibold tabular-nums text-accent-soft-foreground">{money(totalProfit)}</p>
|
|
</div>
|
|
</div>
|
|
<TableShell>
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className={thClass}>Producto</th>
|
|
<th className={thClass}>Ingresos</th>
|
|
<th className={thClass}>Utilidad</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{productAgg
|
|
.sort((a, b) => b.profit - a.profit)
|
|
.map((p) => (
|
|
<tr key={p.productId}>
|
|
<td className={`${tdClass} font-medium`}>{productName(p.productId)}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{money(p.revenue)}</td>
|
|
<td className={`${tdClass} tabular-nums text-success-soft-foreground`}>{money(p.profit)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</TableShell>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "inventario" && (
|
|
<TableShell>
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className={thClass}>Producto</th>
|
|
<th className={thClass}>Almacén</th>
|
|
<th className={thClass}>Stock</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{warehouses.length === 0 ? (
|
|
<tr>
|
|
<td className={tdClass} colSpan={3}>
|
|
<span className="text-muted">No hay stock cargado.</span>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
warehouses.map((w) => {
|
|
const product = products.find((p) => p.id === w.productId);
|
|
const low = product && w.stock <= product.minStock;
|
|
return (
|
|
<tr key={`${w.inventoryId}-${w.productId}`}>
|
|
<td className={`${tdClass} font-medium`}>{productName(w.productId)}</td>
|
|
<td className={`${tdClass} text-muted`}>
|
|
{inventories.find((i) => i.id === w.inventoryId)?.name ?? "—"}
|
|
</td>
|
|
<td className={`${tdClass} tabular-nums ${low ? "text-danger" : ""}`}>{w.stock}</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</TableShell>
|
|
)}
|
|
|
|
{tab === "top" && (
|
|
<TableShell>
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className={thClass}>#</th>
|
|
<th className={thClass}>Producto</th>
|
|
<th className={thClass}>Unidades vendidas</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{topProducts.length === 0 ? (
|
|
<tr>
|
|
<td className={tdClass} colSpan={3}>
|
|
<span className="text-muted">Sin ventas registradas.</span>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
topProducts.map((p, i) => (
|
|
<tr key={p.productId}>
|
|
<td className={`${tdClass} text-muted`}>{i + 1}</td>
|
|
<td className={`${tdClass} font-medium`}>{productName(p.productId)}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{p.quantity}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</TableShell>
|
|
)}
|
|
|
|
{tab === "pagos" && (
|
|
<TableShell>
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className={thClass}>Método</th>
|
|
<th className={thClass}>Cobros</th>
|
|
<th className={thClass}>Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{paymentAgg.length === 0 ? (
|
|
<tr>
|
|
<td className={tdClass} colSpan={3}>
|
|
<span className="text-muted">Sin pagos registrados.</span>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
paymentAgg.map((p) => (
|
|
<tr key={p.method}>
|
|
<td className={`${tdClass} font-medium`}>{PAYMENT_LABELS[p.method] ?? p.method}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{p.count}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{money(p.total)}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</TableShell>
|
|
)}
|
|
|
|
{tab === "cortes" && (
|
|
<TableShell>
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className={thClass}>Almacén</th>
|
|
<th className={thClass}>Apertura</th>
|
|
<th className={thClass}>Cierre</th>
|
|
<th className={thClass}>Esperado</th>
|
|
<th className={thClass}>Contado</th>
|
|
<th className={thClass}>Diferencia</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{closedSessions.length === 0 ? (
|
|
<tr>
|
|
<td className={tdClass} colSpan={6}>
|
|
<span className="text-muted">No hay cortes de caja registrados.</span>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
closedSessions.map((s) => {
|
|
const diff = Number(s.closingCash ?? 0) - Number(s.expectedCash ?? 0);
|
|
return (
|
|
<tr key={s.id}>
|
|
<td className={`${tdClass} font-medium`}>
|
|
{inventories.find((i) => i.id === s.inventoryId)?.name ?? "—"}
|
|
</td>
|
|
<td className={`${tdClass} text-muted`}>{new Date(s.openedAt).toLocaleString("es-AR")}</td>
|
|
<td className={`${tdClass} text-muted`}>{s.closedAt ? new Date(s.closedAt).toLocaleString("es-AR") : "—"}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{money(s.expectedCash ?? 0)}</td>
|
|
<td className={`${tdClass} tabular-nums`}>{money(s.closingCash ?? 0)}</td>
|
|
<td className={`${tdClass} tabular-nums ${diff === 0 ? "" : diff > 0 ? "text-success-soft-foreground" : "text-danger"}`}>
|
|
{diff > 0 ? "+" : ""}
|
|
{money(diff)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</TableShell>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|