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

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