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