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
This commit is contained in:
@@ -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<string, { count: number; totalCents: number }>;
|
||||||
|
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<SessionRow[]>([]);
|
||||||
|
const [report, setReport] = useState<ClosedSession | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingReport, setLoadingReport] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [selectedStore, setSelectedStore] = useState('');
|
||||||
|
const [stores, setStores] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
|
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<ClosedSession>(`/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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<label className="text-sm font-medium text-gray-600">Tienda</label>
|
||||||
|
<select
|
||||||
|
value={selectedStore}
|
||||||
|
onChange={e => setSelectedStore(e.target.value)}
|
||||||
|
className="rounded-xl border border-gray-300 px-3 py-1.5 text-sm focus:border-[#2D6A4F] focus:outline-none"
|
||||||
|
>
|
||||||
|
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<label className="text-sm font-medium text-gray-600">Últimos</label>
|
||||||
|
<select
|
||||||
|
value={filterDays}
|
||||||
|
onChange={e => setFilterDays(Number(e.target.value))}
|
||||||
|
className="rounded-xl border border-gray-300 px-3 py-1.5 text-sm focus:border-[#2D6A4F] focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value={7}>7 días</option>
|
||||||
|
<option value={30}>30 días</option>
|
||||||
|
<option value={90}>90 días</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadSessions()}
|
||||||
|
disabled={loading}
|
||||||
|
className="ml-auto rounded-xl border border-gray-300 px-4 py-1.5 text-sm font-medium hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Cargando…' : '↻ Actualizar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-5">
|
||||||
|
{/* Session list */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-gray-700">Sesiones cerradas ({sessions.length})</h2>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-gray-400">Cargando…</p>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No hay sesiones cerradas en este período.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{sessions.map(s => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadReport(s.id)}
|
||||||
|
className={`w-full text-left rounded-xl border p-3 text-sm transition-colors ${
|
||||||
|
report?.id === s.id
|
||||||
|
? 'border-[#2D6A4F] bg-[#2D6A4F]/5'
|
||||||
|
: 'border-gray-200 hover:border-[#2D6A4F] hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{fmtDate(s.closed_at ?? s.opened_at)}
|
||||||
|
</span>
|
||||||
|
{s.difference_cents != null && (
|
||||||
|
<span className={`text-xs font-bold ${
|
||||||
|
s.difference_cents > 0 ? 'text-green-600' :
|
||||||
|
s.difference_cents < 0 ? 'text-red-600' : 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{fmt(s.difference_cents)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
Apertura {fmtDate(s.opened_at)} · Cierre {s.closed_at ? fmtDate(s.closed_at) : '—'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Report detail */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
{!report ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-dashed border-gray-300 text-gray-400 text-sm">
|
||||||
|
Selecciona una sesión para ver el reporte
|
||||||
|
</div>
|
||||||
|
) : loadingReport ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-gray-400 text-sm">
|
||||||
|
Cargando reporte…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Financial summary */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||||
|
<h3 className="mb-4 text-base font-bold text-gray-900">📊 Resumen financiero</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{[
|
||||||
|
['Saldo inicial', fmt(report.openingCashCents)],
|
||||||
|
['Ventas', fmt(report.sales.completedTotalCents)],
|
||||||
|
['Saldo esperado', fmt(report.openingCashCents + report.sales.completedTotalCents)],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<p className="text-xs text-gray-500">{label}</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Efectivo real</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{fmt(report.actualCashCents ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Diferencia</p>
|
||||||
|
<p className={`text-lg font-bold ${diffClass}`}>
|
||||||
|
{fmt(diff)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sales */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||||
|
<h3 className="mb-3 text-base font-bold text-gray-900">🧾 Ventas</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{([
|
||||||
|
['Total', report.sales.totalCount],
|
||||||
|
['Completadas', report.sales.completedCount],
|
||||||
|
['Pendientes', report.sales.pendingCount],
|
||||||
|
['Reembolsadas', report.sales.refundedCount],
|
||||||
|
] as [string, number][]).map(([label, count]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<p className="text-xs text-gray-500">{label}</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{count}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Artículos vendidos</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{report.itemsSold}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Productos únicos</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{report.uniqueProductsSold}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By payment method */}
|
||||||
|
{report.paymentsByMethod.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||||
|
<h3 className="mb-3 text-base font-bold text-gray-900">💳 Formas de pago</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 text-left text-xs text-gray-500">
|
||||||
|
<th className="pb-2 font-medium">Método</th>
|
||||||
|
<th className="pb-2 text-right font-medium">Transacciones</th>
|
||||||
|
<th className="pb-2 text-right font-medium">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.paymentsByMethod.map(p => (
|
||||||
|
<tr key={p.methodCode} className="border-b border-gray-50 last:border-0">
|
||||||
|
<td className="py-2 font-medium text-gray-800">{p.methodName}</td>
|
||||||
|
<td className="py-2 text-right text-gray-600">{p.count}</td>
|
||||||
|
<td className="py-2 text-right font-bold text-gray-900">{fmt(p.totalCents)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ href: '/reporting/dashboard', label: 'Dashboard', icon: '📊' },
|
{ href: '/reporting/dashboard', label: 'Dashboard', icon: '📊' },
|
||||||
{ href: '/reporting/sales', label: 'Ventas', icon: '🧾' },
|
{ href: '/reporting/sales', label: 'Ventas', icon: '🧾' },
|
||||||
|
{ href: '/reporting/closures', label: 'Cierres', icon: '🔒' },
|
||||||
{ href: '/reporting/products', label: 'Productos', icon: '📦' },
|
{ href: '/reporting/products', label: 'Productos', icon: '📦' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user