feat(F-157): completed feature
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
|
||||
import { topNavItems, subNavItems, type NavItem } from '@/lib/permissions';
|
||||
import { visibleNavItems, type NavItem } from '@/lib/permissions';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
function NavItemRow({ item }: { item: NavItem }) {
|
||||
const pathname = window?.location?.pathname ?? '';
|
||||
const pathname = usePathname();
|
||||
const active = item.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.href);
|
||||
@@ -35,7 +35,7 @@ function NavItemRow({ item }: { item: NavItem }) {
|
||||
}
|
||||
|
||||
function Sidebar({ role, email }: { role: Role; email: string }) {
|
||||
const topItems = topNavItems(role);
|
||||
const items = visibleNavItems(role);
|
||||
|
||||
return (
|
||||
<div className="w-60 bg-white border-r border-gray-200 flex flex-col h-screen sticky top-0">
|
||||
@@ -50,22 +50,9 @@ function Sidebar({ role, email }: { role: Role; email: string }) {
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{topItems.map((item) => {
|
||||
const subs = subNavItems(item.href, role);
|
||||
|
||||
return (
|
||||
<div key={item.href}>
|
||||
<NavItemRow item={item} />
|
||||
{subs.length > 0 && (
|
||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||
{subs.map((sub) => (
|
||||
<NavItemRow key={sub.href} item={sub} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => (
|
||||
<NavItemRow key={item.href} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
|
||||
@@ -295,12 +295,9 @@ function DashboardContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 flex flex-col gap-6">
|
||||
<div className="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">Dashboard de ventas</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Tendencias, canales y rendimiento por tienda/terminal
|
||||
|
||||
46
project/apps/admin/src/app/(dashboard)/reporting/layout.tsx
Normal file
46
project/apps/admin/src/app/(dashboard)/reporting/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const SECTIONS = [
|
||||
{ href: '/reporting/dashboard', label: 'Dashboard', icon: '📊' },
|
||||
{ href: '/reporting/sales', label: 'Ventas', icon: '🧾' },
|
||||
{ href: '/reporting/products', label: 'Productos', icon: '📦' },
|
||||
] as const;
|
||||
|
||||
export default function ReportingLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reporting</h1>
|
||||
<p className="mt-0.5 text-sm text-gray-500">Informes de ventas y rendimiento del negocio.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-6">
|
||||
<nav aria-label="Apartados de Reporting" className="w-48 shrink-0 space-y-1">
|
||||
{SECTIONS.map((section) => {
|
||||
const active = pathname === section.href;
|
||||
return (
|
||||
<Link
|
||||
key={section.href}
|
||||
href={section.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`block w-full rounded-xl px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
active ? 'bg-[#2D6A4F] text-white' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2" aria-hidden="true">{section.icon}</span>
|
||||
{section.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,299 +1,5 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* F-147 — Reporting shell (main page).
|
||||
*
|
||||
* Layout: header + filter bar + KPI grid + data table.
|
||||
* Filters are persisted in URL searchParams.
|
||||
*/
|
||||
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { DateRangePicker, DATE_PRESETS } from '@/components/reporting/DateRangePicker';
|
||||
import { KpiCard } from '@/components/reporting/KpiCard';
|
||||
import { reportingClient, type Availability, type ReportingChannel, type SummaryResponse } from '@/lib/reporting-client';
|
||||
|
||||
function formatCents(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function metricAvailability(
|
||||
data: SummaryResponse | null,
|
||||
metric: keyof SummaryResponse['dataAvailability'],
|
||||
): Availability {
|
||||
return data?.dataAvailability?.[metric] ?? 'unavailable';
|
||||
}
|
||||
|
||||
function buildDefaultRange() {
|
||||
const preset = DATE_PRESETS[1]; // "Últimos 30 días"
|
||||
return preset.getValue();
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
from: string;
|
||||
to: string;
|
||||
channel: ReportingChannel;
|
||||
compare: 'none' | 'previous_equal' | 'previous_calendar';
|
||||
}
|
||||
|
||||
function ReportingContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Initialize filters from URL or defaults
|
||||
const getInitialFilters = (): FilterState => {
|
||||
const from = searchParams.get('from') ?? buildDefaultRange().from;
|
||||
const to = searchParams.get('to') ?? buildDefaultRange().to;
|
||||
const channel = (searchParams.get('channel') ?? 'all') as ReportingChannel;
|
||||
const compare = (searchParams.get('compare') ?? 'none') as FilterState['compare'];
|
||||
return { from, to, channel, compare };
|
||||
};
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>(getInitialFilters);
|
||||
const [data, setData] = useState<SummaryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Sync filters to URL
|
||||
const updateSearchParams = useCallback(
|
||||
(newFilters: FilterState) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('from', newFilters.from);
|
||||
params.set('to', newFilters.to);
|
||||
if (newFilters.channel !== 'all') params.set('channel', newFilters.channel);
|
||||
if (newFilters.compare !== 'none') params.set('compare', newFilters.compare);
|
||||
router.replace(`/reporting?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newFilters: FilterState) => {
|
||||
setFilters(newFilters);
|
||||
updateSearchParams(newFilters);
|
||||
},
|
||||
[updateSearchParams],
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await reportingClient.fetchSummary({
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
channel: filters.channel,
|
||||
compare: filters.compare,
|
||||
});
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar datos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const da = data?.dataAvailability;
|
||||
|
||||
return (
|
||||
<div className="p-8 flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reporting</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{data ? `Actualizado: ${new Date(data.updatedAt).toLocaleString('es-ES')}` : 'Cargando...'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<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">
|
||||
{/* Channel */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="channel-filter" className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Canal
|
||||
</label>
|
||||
<select
|
||||
id="channel-filter"
|
||||
value={filters.channel}
|
||||
onChange={(e) => handleFiltersChange({ ...filters, channel: e.target.value as ReportingChannel })}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="ecommerce">Ecommerce</option>
|
||||
<option value="pos">TPV</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Compare */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="compare-filter" className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Comparar
|
||||
</label>
|
||||
<select
|
||||
id="compare-filter"
|
||||
value={filters.compare}
|
||||
onChange={(e) => handleFiltersChange({ ...filters, compare: e.target.value as FilterState['compare'] })}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
|
||||
>
|
||||
<option value="none">Sin comparar</option>
|
||||
<option value="previous_equal">Período anterior (misma duración)</option>
|
||||
<option value="previous_calendar">Mes anterior (calendario)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Refresh */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium bg-[#2D6A4F] text-white rounded-lg hover:bg-[#245a42] disabled:opacity-50 transition-colors 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" />
|
||||
) : (
|
||||
<span>🔄</span>
|
||||
)}
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Rango de fechas</p>
|
||||
<DateRangePicker
|
||||
from={filters.from}
|
||||
to={filters.to}
|
||||
onChange={(from, to) => handleFiltersChange({ ...filters, from, to })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI grid */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-white border border-gray-200 rounded-xl p-5 animate-pulse">
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2 mb-3" />
|
||||
<div className="h-8 bg-gray-200 rounded w-3/4" />
|
||||
</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={loadData}
|
||||
className="text-sm text-[#2D6A4F] font-medium hover:underline"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : !data ? (
|
||||
<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">Cargando datos...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Comparison banner */}
|
||||
{data.comparison && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800">
|
||||
<strong>Comparación activa:</strong>{' '}
|
||||
Comparando con el período {filters.compare === 'previous_equal' ? 'anterior (misma duración)' : 'mes anterior (calendario)'}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KpiCard
|
||||
label="Pedidos"
|
||||
value={String(data.totals.orders)}
|
||||
availability={metricAvailability(data, 'orders')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Ventas brutas"
|
||||
value={formatCents(data.totals.grossSalesCents)}
|
||||
availability={metricAvailability(data, 'grossSales')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Clientes"
|
||||
value={String(data.totals.customers)}
|
||||
availability={metricAvailability(data, 'customers')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Unidades"
|
||||
value={String(data.totals.unitsSold)}
|
||||
availability={metricAvailability(data, 'unitsSold')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Descuentos"
|
||||
value={formatCents(data.totals.discountsCents)}
|
||||
availability={metricAvailability(data, 'discounts')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="IVA"
|
||||
value={formatCents(data.totals.taxCents)}
|
||||
availability={metricAvailability(data, 'tax')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Envíos"
|
||||
value={formatCents(data.totals.shippingCents)}
|
||||
availability={metricAvailability(data, 'shipping')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Margen"
|
||||
value="—"
|
||||
availability={metricAvailability(data, 'margin')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data availability legend */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||
Estado de métricas
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(data.dataAvailability).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-600 capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}:</span>
|
||||
<span
|
||||
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||
value === 'available'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{value === 'available' ? '✓' : '✗'} {value === 'available' ? 'Disponible' : 'No disponible'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function ReportingPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="p-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4" />
|
||||
<div className="h-32 bg-gray-200 rounded-xl" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<ReportingContent />
|
||||
</Suspense>
|
||||
);
|
||||
redirect('/reporting/dashboard');
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ function ProductsContent() {
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
channel: filters.channel,
|
||||
groupBy: filters.sort === 'revenue' ? 'revenue' : undefined,
|
||||
sort: filters.sort,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
});
|
||||
@@ -76,12 +76,9 @@ function ProductsContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 flex flex-col gap-6">
|
||||
<div className="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
|
||||
|
||||
@@ -114,12 +114,9 @@ function SalesContent() {
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
return (
|
||||
<div className="p-8 flex flex-col gap-6">
|
||||
<div className="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">Ventas agrupadas</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{data ? `${data.pagination.totalRows} filas · Actualizado: ${new Date(data.updatedAt).toLocaleString('es-ES')}` : 'Cargando...'}
|
||||
|
||||
@@ -37,15 +37,11 @@ export interface NavItem {
|
||||
icon: string;
|
||||
permission: Permission;
|
||||
badge?: number;
|
||||
parentHref?: string;
|
||||
}
|
||||
|
||||
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', parentHref: '/reporting' },
|
||||
{ href: '/reporting/sales', label: 'Ventas', icon: '🧾', permission: 'reporting.read', parentHref: '/reporting' },
|
||||
{ href: '/reporting/products', label: 'Productos', icon: '📦', permission: 'reporting.read', parentHref: '/reporting' },
|
||||
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
|
||||
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
|
||||
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
|
||||
@@ -64,10 +60,6 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/settings', label: 'Ajustes', icon: '⚙️', permission: 'dashboard' },
|
||||
];
|
||||
|
||||
export function topNavItems(role: Role): NavItem[] {
|
||||
return NAV_ITEMS.filter((item) => !item.parentHref && can(role, item.permission));
|
||||
}
|
||||
|
||||
export function subNavItems(parentHref: string, role: Role): NavItem[] {
|
||||
return NAV_ITEMS.filter((item) => item.parentHref === parentHref && can(role, item.permission));
|
||||
export function visibleNavItems(role: Role): NavItem[] {
|
||||
return NAV_ITEMS.filter((item) => can(role, item.permission));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ReportingFilters {
|
||||
terminalId?: string | string[];
|
||||
state?: string | string[];
|
||||
groupBy?: GroupBy;
|
||||
sort?: 'units' | 'revenue';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -138,6 +139,7 @@ function filtersToQueryString(filters: ReportingFilters): string {
|
||||
if (filters.compare && filters.compare !== 'none') params.set('compare', filters.compare);
|
||||
if (filters.channel && filters.channel !== 'all') params.set('channel', filters.channel);
|
||||
if (filters.groupBy) params.set('groupBy', filters.groupBy);
|
||||
if (filters.sort) params.set('sort', filters.sort);
|
||||
if (filters.page) params.set('page', String(filters.page));
|
||||
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
||||
if (filters.storeId) {
|
||||
|
||||
Reference in New Issue
Block a user