From 950c4d622b4cd5dc691c9d0fbc3306a448e641e7 Mon Sep 17 00:00:00 2001 From: chattie Date: Sun, 23 Aug 2026 09:27:35 +0200 Subject: [PATCH] Admin: add Cierres de caja reporting section - New /reporting/closures page listing closed POS sessions - Shows financial summary, sales stats, payments breakdown per session - Sessions filterable by store and last 7/30/90 days - Navigation entry in Reporting layout --- .../(dashboard)/reporting/closures/page.tsx | 282 ++++++++++++++++++ .../src/app/(dashboard)/reporting/layout.tsx | 1 + 2 files changed, 283 insertions(+) create mode 100644 project/apps/admin/src/app/(dashboard)/reporting/closures/page.tsx diff --git a/project/apps/admin/src/app/(dashboard)/reporting/closures/page.tsx b/project/apps/admin/src/app/(dashboard)/reporting/closures/page.tsx new file mode 100644 index 0000000..c47e991 --- /dev/null +++ b/project/apps/admin/src/app/(dashboard)/reporting/closures/page.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { api } from '@/lib/api-client'; + +interface ClosedSession { + id: string; + openedAt: string; + closedAt: string; + userId: string; + status: string; + openingCashCents: number; + closingCashCents: number | null; + actualCashCents: number | null; + differenceCents: number | null; + salesCount: number; + salesTotalCents: number; + salesByState: Record; + paymentsByMethod: Array<{ methodCode: string; methodName: string; totalCents: number; count: number }>; + itemsSold: number; + uniqueProductsSold: number; +} + +function fmt(cents: number): string { + return (cents / 100).toFixed(2) + ' \u20ac'; +} + +function fmtDate(d: string): string { + return new Date(d).toLocaleString('es-ES', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); +} + +interface SessionRow { + id: string; + opened_at: string; + closed_at: string; + user_id: string; + status: string; + opening_cash_cents: number; + closing_cash_cents: number | null; + actual_cash_cents: number | null; + difference_cents: number | null; +} + +export default function ReportingClosuresPage() { + const [sessions, setSessions] = useState([]); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [loadingReport, setLoadingReport] = useState(false); + const [error, setError] = useState(''); + const [selectedStore, setSelectedStore] = useState(''); + const [stores, setStores] = useState>([]); + const [filterDays, setFilterDays] = useState(30); + + const loadSessions = useCallback(async () => { + if (!selectedStore) return; + setLoading(true); + setError(''); + try { + const since = new Date(); + since.setDate(since.getDate() - filterDays); + const params = new URLSearchParams({ + storeId: selectedStore, + status: 'CLOSED', + limit: '100', + dateFrom: since.toISOString().slice(0, 10), + }); + const data = await api.get<{ items: SessionRow[] }>(`/api/pos/sessions?${params}`); + setSessions(data.items ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar sesiones'); + } finally { + setLoading(false); + } + }, [selectedStore, filterDays]); + + const loadReport = async (sessionId: string) => { + setLoadingReport(true); + try { + const data = await api.get(`/api/pos/reports/cash-close/${sessionId}`); + setReport(data); + } catch { + setReport(null); + } finally { + setLoadingReport(false); + } + }; + + useEffect(() => { + api.get<{ stores: Array<{ id: string; name: string }> }>('/api/pos/admin/stores') + .then(d => { + setStores(d.stores ?? []); + if (d.stores?.length) setSelectedStore(d.stores[0].id); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + if (selectedStore) void loadSessions(); + }, [selectedStore, filterDays, loadSessions]); + + const diff = report?.differenceCents ?? 0; + const diffClass = diff > 0 ? 'text-green-600' : diff < 0 ? 'text-red-600' : 'text-gray-600'; + + return ( +
+ {/* Header */} +
+
+ + +
+
+ + +
+ +
+ +
+ {/* Session list */} +
+

Sesiones cerradas ({sessions.length})

+ {loading ? ( +

Cargando…

+ ) : sessions.length === 0 ? ( +

No hay sesiones cerradas en este período.

+ ) : ( +
    + {sessions.map(s => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Report detail */} +
+ {!report ? ( +
+ Selecciona una sesión para ver el reporte +
+ ) : loadingReport ? ( +
+ Cargando reporte… +
+ ) : ( +
+ {/* Financial summary */} +
+

📊 Resumen financiero

+
+ {[ + ['Saldo inicial', fmt(report.openingCashCents)], + ['Ventas', fmt(report.sales.completedTotalCents)], + ['Saldo esperado', fmt(report.openingCashCents + report.sales.completedTotalCents)], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+

Efectivo real

+

{fmt(report.actualCashCents ?? 0)}

+
+
+

Diferencia

+

+ {fmt(diff)} +

+
+
+
+ + {/* Sales */} +
+

🧾 Ventas

+
+ {([ + ['Total', report.sales.totalCount], + ['Completadas', report.sales.completedCount], + ['Pendientes', report.sales.pendingCount], + ['Reembolsadas', report.sales.refundedCount], + ] as [string, number][]).map(([label, count]) => ( +
+

{label}

+

{count}

+
+ ))} +
+

Artículos vendidos

+

{report.itemsSold}

+
+
+

Productos únicos

+

{report.uniqueProductsSold}

+
+
+
+ + {/* By payment method */} + {report.paymentsByMethod.length > 0 && ( +
+

💳 Formas de pago

+ + + + + + + + + + {report.paymentsByMethod.map(p => ( + + + + + + ))} + +
MétodoTransaccionesTotal
{p.methodName}{p.count}{fmt(p.totalCents)}
+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/project/apps/admin/src/app/(dashboard)/reporting/layout.tsx b/project/apps/admin/src/app/(dashboard)/reporting/layout.tsx index e7fbedf..42170c5 100644 --- a/project/apps/admin/src/app/(dashboard)/reporting/layout.tsx +++ b/project/apps/admin/src/app/(dashboard)/reporting/layout.tsx @@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'; const SECTIONS = [ { href: '/reporting/dashboard', label: 'Dashboard', icon: '📊' }, { href: '/reporting/sales', label: 'Ventas', icon: '🧾' }, + { href: '/reporting/closures', label: 'Cierres', icon: '🔒' }, { href: '/reporting/products', label: 'Productos', icon: '📦' }, ] as const;