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:
chattie
2026-08-23 09:27:35 +02:00
parent 18a518e58b
commit 950c4d622b
2 changed files with 283 additions and 0 deletions

View File

@@ -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>
);
}

View File

@@ -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;