feat(F-149): completed feature

This commit is contained in:
chattie
2026-08-22 13:05:43 +02:00
parent c497d5be99
commit 6a51d1ee74
15 changed files with 551 additions and 21 deletions

View File

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

View File

@@ -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' },

View File

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

View File

@@ -117,4 +117,28 @@ export async function registerReportingRoutes(
throw err;
}
});
// ── F-149: Products ranking ───────────────────────────────────────────────
const productsSchema: FastifySchema = {
tags: ['Reporting'],
summary: 'Reporting product rankings',
description:
'Top products by units sold or revenue with category/brand. Requires REPORTING_PRODUCTS.',
querystring: { type: 'object' },
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
};
app.get('/reporting/products', { schema: productsSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireReportingPermission(user, 'REPORTING_PRODUCTS');
try {
const filters = parseJson(reportingFiltersSchema, request.query ?? {});
return reply.send(await reporting.products(filters));
} catch (err) {
if (err instanceof Error && err.name === 'ZodError') {
throw new AppError(400, 'INVALID_FILTERS', (err as Error).message);
}
throw err;
}
});
}

View File

@@ -152,6 +152,12 @@ interface CountRow {
count: string;
}
/** F-149: Products ranking response. */
export interface ProductsResponse extends Omit<SalesResponse, 'items'> {
items: ProductRow[];
}
export class ReportingService {
constructor(private readonly pool: pg.Pool) {}
@@ -388,6 +394,147 @@ export class ReportingService {
// ── Group-by helpers ────────────────────────────────────────────────────────
// ── F-149: Product rankings ───────────────────────────────────────────
/** Product ranking row. */
interface ProductRow {
productId: string;
productName: string;
sku: string | null;
category: string | null;
brand: string | null;
metrics: Metrics;
}
interface ProductRowRaw {
product_id: string | null;
product_name: string | null;
sku: string | null;
category: string | null;
brand: string | null;
orders: number;
customers: number;
gross_sales_cents: string;
discounts_cents: string;
tax_cents: string;
units_sold: string;
shipping_cents: string;
}
/**
* Top N products by units sold or revenue within the filter range.
* Joins orders_items with catalog_products/categories/brands.
*/
async products(filters: ReportingFilters): Promise<ProductsResponse> {
const { range, channel, storeIds, terminalIds } = filters;
const channelFilter = channel === 'all' ? null : channel;
const sortBy = ((filters as { sort?: string }).sort ?? '') === 'revenue'
? 'gross_sales_cents'
: 'units_sold';
const pageSize = filters.pageSize ?? 20;
const offset = ((filters.page ?? 1) - 1) * pageSize;
const result = await this.pool.query<
ProductRowRaw & { total_count: string }
>(
`WITH filtered_orders AS (
SELECT o.id, o.user_id, o.state, o.source, o.store_id, o.terminal_id
FROM orders_orders o
WHERE o.created_at >= $1
AND o.created_at < $2
AND o.state = ANY($3::text[])
AND ($4::text IS NULL OR o.source = $4)
AND (cardinality($5::uuid[]) = 0 OR o.store_id = ANY($5::uuid[]))
AND (cardinality($6::uuid[]) = 0 OR o.terminal_id = ANY($6::uuid[]))
),
filtered_items AS (
SELECT i.order_id, i.product_id, i.quantity, i.unit_price_cents, i.discount_cents, i.tax_cents
FROM orders_items i
WHERE i.order_id IN (SELECT id FROM filtered_orders)
),
product_rank AS (
SELECT
p.id AS product_id,
p.name AS product_name,
p.sku,
c.name AS category,
b.name AS brand,
COUNT(DISTINCT fi.order_id)::int AS orders,
COUNT(DISTINCT o.user_id) FILTER (WHERE o.user_id IS NOT NULL)::int AS customers,
COALESCE(SUM(fi.unit_price_cents * fi.quantity), 0)::bigint AS gross_sales_cents,
COALESCE(SUM(fi.discount_cents), 0)::bigint AS discounts_cents,
COALESCE(SUM(fi.tax_cents), 0)::bigint AS tax_cents,
COALESCE(SUM(fi.quantity), 0)::int AS units_sold,
COALESCE(SUM(o.shipping_cents), 0)::bigint AS shipping_cents
FROM filtered_items fi
JOIN filtered_orders o ON o.id = fi.order_id
JOIN catalog_products p ON p.id = fi.product_id
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
LEFT JOIN categories_categories c ON c.id = pc.category_id
LEFT JOIN brands_brands b ON b.id = p.brand_id
GROUP BY p.id, p.name, p.sku, c.name, b.name
ORDER BY ${sortBy} DESC
LIMIT $7 OFFSET $8
),
total_count AS (SELECT COUNT(*)::int AS count FROM product_rank)
SELECT pr.*, tc.count AS total_count
FROM product_rank pr
CROSS JOIN total_count tc`,
[
range.from, range.to, [...SALES_STATES],
channelFilter, storeIds, terminalIds,
pageSize, offset,
],
);
const rows: ProductRow[] = result.rows.map((r) => ({
productId: r.product_id ?? '',
productName: r.product_name ?? 'Sin nombre',
sku: r.sku ?? null,
category: r.category ?? null,
brand: r.brand ?? null,
metrics: {
orders: r.orders ?? 0,
customers: r.customers ?? 0,
grossSalesCents: Number(r.gross_sales_cents) || 0,
discountsCents: Number(r.discounts_cents) || 0,
taxCents: Number(r.tax_cents) || 0,
unitsSold: Number(r.units_sold) || 0,
shippingCents: Number(r.shipping_cents) || 0,
},
}));
const totalRows = Number(result.rows[0]?.total_count ?? 0);
const totals = await this.runSummaryQuery(
range.from, range.to, channel, storeIds, terminalIds,
);
return {
range,
filters: { channel, storeIds, terminalIds, groupBy: null },
comparison: null,
dataAvailability: {
grossSales: 'available',
netSales: 'unavailable',
discounts: 'available',
tax: 'available',
unitsSold: 'available',
orders: 'available',
customers: 'available',
margin: 'unavailable',
paymentMethod: 'unavailable',
refunds: 'unavailable',
shipping: 'available',
},
items: rows,
totals,
pagination: { page: filters.page ?? 1, pageSize, totalRows },
updatedAt: new Date().toISOString(),
cache: { hit: false, maxAgeSeconds: 0 },
};
}
function buildGroupBy(dim: GroupBy | undefined): {
groupExpr: string;
selectExpr: string;