feat(F-149): completed feature
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* F-149 — Reporting products page: top products by units sold or revenue.
|
||||
*/
|
||||
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { DateRangePicker, DATE_PRESETS } from '@/components/reporting/DateRangePicker';
|
||||
import { reportingClient, type ReportingChannel, type ProductsResponse } from '@/lib/reporting-client';
|
||||
|
||||
function formatCents(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function buildDefaultRange() {
|
||||
return DATE_PRESETS[1].getValue();
|
||||
}
|
||||
|
||||
type SortMode = 'units' | 'revenue';
|
||||
|
||||
function ProductsContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const getInitial = () => ({
|
||||
from: searchParams.get('from') ?? buildDefaultRange().from,
|
||||
to: searchParams.get('to') ?? buildDefaultRange().to,
|
||||
channel: (searchParams.get('channel') as ReportingChannel) ?? 'all',
|
||||
sort: (searchParams.get('sort') as SortMode) ?? 'units',
|
||||
page: Number(searchParams.get('page') ?? '1'),
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState(getInitial);
|
||||
const [data, setData] = useState<ProductsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const updateUrl = (f: typeof filters) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('from', f.from);
|
||||
params.set('to', f.to);
|
||||
if (f.channel !== 'all') params.set('channel', f.channel);
|
||||
if (f.sort !== 'units') params.set('sort', f.sort);
|
||||
params.set('page', String(f.page));
|
||||
router.replace(`/reporting/products?${params}`, { scroll: false });
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await reportingClient.fetchProducts({
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
channel: filters.channel,
|
||||
groupBy: filters.sort === 'revenue' ? 'revenue' : undefined,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
});
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const setPage = (page: number) => {
|
||||
const f = { ...filters, page };
|
||||
setFilters(f);
|
||||
updateUrl(f);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<a href="/reporting" className="text-sm text-[#2D6A4F] hover:underline">← Reporting</a>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Ranking de productos más vendidos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Canal</label>
|
||||
<select
|
||||
value={filters.channel}
|
||||
onChange={(e) => {
|
||||
const f = { ...filters, channel: e.target.value as ReportingChannel, page: 1 };
|
||||
setFilters(f);
|
||||
updateUrl(f);
|
||||
}}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="ecommerce">Ecommerce</option>
|
||||
<option value="pos">TPV</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Ordenar por</label>
|
||||
<select
|
||||
value={filters.sort}
|
||||
onChange={(e) => {
|
||||
const f = { ...filters, sort: e.target.value as SortMode, page: 1 };
|
||||
setFilters(f);
|
||||
updateUrl(f);
|
||||
}}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
|
||||
>
|
||||
<option value="units">Unidades vendidas</option>
|
||||
<option value="revenue">Facturación</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium bg-[#2D6A4F] text-white rounded-lg hover:bg-[#245a42] disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading && <span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />}
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase mb-2">Rango de fechas</p>
|
||||
<DateRangePicker
|
||||
from={filters.from}
|
||||
to={filters.to}
|
||||
onChange={(from, to) => {
|
||||
const f = { ...filters, from, to, page: 1 };
|
||||
setFilters(f);
|
||||
updateUrl(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="p-12 text-center text-gray-400">
|
||||
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
|
||||
<p className="mt-2 text-sm">Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||||
<p className="text-red-700 font-medium mb-3">{error}</p>
|
||||
<button onClick={load} className="text-sm text-[#2D6A4F] font-medium hover:underline">Reintentar</button>
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-12 text-center">
|
||||
<p className="text-4xl mb-3">📦</p>
|
||||
<p className="text-gray-500 text-sm">No hay datos para este período</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">#</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Producto</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Categoría</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Marca</th>
|
||||
<th className="text-right text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Pedidos</th>
|
||||
<th className="text-right text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Unidades</th>
|
||||
<th className="text-right text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Ventas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{data.items.map((row, i) => (
|
||||
<tr key={row.productId} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{(filters.page - 1) * filters.pageSize + i + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-gray-900">{row.productName}</p>
|
||||
{row.sku && <p className="text-xs text-gray-400 font-mono">{row.sku}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{row.category ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{row.brand ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium text-gray-900">{row.metrics.orders}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium text-gray-900">{row.metrics.unitsSold}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium text-gray-900">{formatCents(row.metrics.grossSalesCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
{data.items.length > 0
|
||||
? `${(filters.page - 1) * filters.pageSize + 1}–${Math.min(filters.page * filters.pageSize, data.pagination.totalRows)} de ${data.pagination.totalRows}`
|
||||
: 'Sin resultados'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={filters.page <= 1}
|
||||
onClick={() => setPage(filters.page - 1)}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={filters.page >= Math.ceil(data.pagination.totalRows / filters.pageSize)}
|
||||
onClick={() => setPage(filters.page + 1)}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-gray-400">Cargando...</div>}>
|
||||
<ProductsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,9 @@ export interface NavItem {
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
|
||||
{ href: '/reporting', label: 'Reporting', icon: '📈', permission: 'reporting.read' },
|
||||
{ href: '/reporting/dashboard', label: ' Dashboard', icon: '📊', permission: 'reporting.read' },
|
||||
{ href: '/reporting/sales', label: ' Ventas', icon: '🧾', permission: 'reporting.read' },
|
||||
{ href: '/reporting/products', label: ' Productos', icon: '📦', permission: 'reporting.read' },
|
||||
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
|
||||
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
|
||||
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
|
||||
|
||||
@@ -82,6 +82,19 @@ export interface SalesRow {
|
||||
metrics: Metrics;
|
||||
}
|
||||
|
||||
export interface ProductRow {
|
||||
productId: string;
|
||||
productName: string;
|
||||
sku: string | null;
|
||||
category: string | null;
|
||||
brand: string | null;
|
||||
metrics: Metrics;
|
||||
}
|
||||
|
||||
export interface ProductsResponse extends Omit<SalesResponse, 'items'> {
|
||||
items: ProductRow[];
|
||||
}
|
||||
|
||||
export interface SalesResponse extends SummaryResponse {
|
||||
filters: {
|
||||
channel: ReportingChannel;
|
||||
@@ -152,4 +165,9 @@ export const reportingClient = {
|
||||
const qs = filtersToQueryString(filters);
|
||||
return api.get<SalesResponse>(`/api/reporting/sales?${qs}`);
|
||||
},
|
||||
|
||||
async fetchProducts(filters: ReportingFilters): Promise<ProductsResponse> {
|
||||
const qs = filtersToQueryString(filters);
|
||||
return api.get<ProductsResponse>(`/api/reporting/products?${qs}`);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user