feat(F-147): completed feature
This commit is contained in:
299
project/apps/admin/src/app/(dashboard)/reporting/page.tsx
Normal file
299
project/apps/admin/src/app/(dashboard)/reporting/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
287
project/apps/admin/src/app/(dashboard)/reporting/sales/page.tsx
Normal file
287
project/apps/admin/src/app/(dashboard)/reporting/sales/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* F-147 — Reporting sales view (grouped table).
|
||||
* Shows grouped sales data with pagination.
|
||||
*/
|
||||
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { DateRangePicker, DATE_PRESETS } from '@/components/reporting/DateRangePicker';
|
||||
import { reportingClient, type Availability, type ReportingChannel, type SalesResponse, type GroupBy } from '@/lib/reporting-client';
|
||||
|
||||
function formatCents(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function buildDefaultRange() {
|
||||
return DATE_PRESETS[1].getValue();
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
from: string;
|
||||
to: string;
|
||||
channel: ReportingChannel;
|
||||
groupBy: GroupBy;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
function groupByLabel(groupBy: GroupBy) {
|
||||
const labels: Record<GroupBy, string> = {
|
||||
day: 'Día',
|
||||
week: 'Semana',
|
||||
month: 'Mes',
|
||||
hour: 'Hora',
|
||||
store: 'Tienda',
|
||||
channel: 'Canal',
|
||||
terminal: 'Terminal',
|
||||
cashier: 'Cajero',
|
||||
payment: 'Método de pago',
|
||||
};
|
||||
return labels[groupBy] ?? groupBy;
|
||||
}
|
||||
|
||||
function SalesContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const getInitialFilters = (): FilterState => ({
|
||||
from: searchParams.get('from') ?? buildDefaultRange().from,
|
||||
to: searchParams.get('to') ?? buildDefaultRange().to,
|
||||
channel: (searchParams.get('channel') as ReportingChannel) ?? 'all',
|
||||
groupBy: (searchParams.get('groupBy') as GroupBy) ?? 'day',
|
||||
page: Number(searchParams.get('page') ?? '1'),
|
||||
pageSize: Number(searchParams.get('pageSize') ?? '20'),
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>(getInitialFilters);
|
||||
const [data, setData] = useState<SalesResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
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.groupBy !== 'day') params.set('groupBy', newFilters.groupBy);
|
||||
params.set('page', String(newFilters.page));
|
||||
params.set('pageSize', String(newFilters.pageSize));
|
||||
router.replace(`/reporting/sales?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newFilters: FilterState) => {
|
||||
setFilters({ ...newFilters, page: 1 });
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('from', newFilters.from);
|
||||
params.set('to', newFilters.to);
|
||||
if (newFilters.channel !== 'all') params.set('channel', newFilters.channel);
|
||||
else params.delete('channel');
|
||||
if (newFilters.groupBy !== 'day') params.set('groupBy', newFilters.groupBy);
|
||||
else params.delete('groupBy');
|
||||
params.set('page', '1');
|
||||
params.set('pageSize', String(newFilters.pageSize));
|
||||
router.replace(`/reporting/sales?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await reportingClient.fetchSales({
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
channel: filters.channel,
|
||||
groupBy: filters.groupBy,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
});
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
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">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...'}
|
||||
</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 tracking-wide">Canal</label>
|
||||
<select
|
||||
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] 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 tracking-wide">Agrupar por</label>
|
||||
<select
|
||||
value={filters.groupBy}
|
||||
onChange={(e) => handleFiltersChange({ ...filters, groupBy: e.target.value as GroupBy })}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
|
||||
>
|
||||
{(['day', 'week', 'month', 'hour', 'store', 'channel', 'terminal'] as GroupBy[]).map((g) => (
|
||||
<option key={g} value={g}>{groupByLabel(g)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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" />}
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Rango</p>
|
||||
<DateRangePicker
|
||||
from={filters.from}
|
||||
to={filters.to}
|
||||
onChange={(from, to) => handleFiltersChange({ ...filters, from, to })}
|
||||
/>
|
||||
</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={loadData} 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">
|
||||
{groupByLabel(filters.groupBy)}
|
||||
</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">Ventas brutas</th>
|
||||
<th className="text-right text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Descuentos</th>
|
||||
<th className="text-right text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">IVA</th>
|
||||
<th className="text-right text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Unidades</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{data.items.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{row.period ? new Date(row.period).toLocaleString('es-ES', { dateStyle: 'medium' }) : (row.channel ?? '—')}
|
||||
</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">{formatCents(row.metrics.grossSalesCents)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-red-600">-{formatCents(row.metrics.discountsCents)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-600">{formatCents(row.metrics.taxCents)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-600">{row.metrics.unitsSold}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-gray-50 border-t-2 border-gray-300">
|
||||
<td className="px-4 py-3 text-sm font-semibold text-gray-700">TOTAL</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-bold text-gray-900">{data.totals.orders}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-bold text-gray-900">{formatCents(data.totals.grossSalesCents)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-bold text-red-600">-{formatCents(data.totals.discountsCents)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-bold text-gray-700">{formatCents(data.totals.taxCents)}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-bold text-gray-700">{data.totals.unitsSold}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Página {filters.page} de {Math.ceil(data.pagination.totalRows / filters.pageSize) || 1} ({data.pagination.totalRows} filas)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={filters.page <= 1}
|
||||
onClick={() => {
|
||||
const newPage = Math.max(1, filters.page - 1);
|
||||
const newFilters = { ...filters, page: newPage };
|
||||
setFilters(newFilters);
|
||||
updateSearchParams(newFilters);
|
||||
}}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={filters.page >= Math.ceil(data.pagination.totalRows / filters.pageSize)}
|
||||
onClick={() => {
|
||||
const maxPage = Math.ceil(data.pagination.totalRows / filters.pageSize);
|
||||
const newPage = Math.min(maxPage, filters.page + 1);
|
||||
const newFilters = { ...filters, page: newPage };
|
||||
setFilters(newFilters);
|
||||
updateSearchParams(newFilters);
|
||||
}}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SalesPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-gray-400">Cargando...</div>}>
|
||||
<SalesContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* F-147 — AvailabilityBadge component.
|
||||
* Shows a colored badge indicating whether a metric is available.
|
||||
*/
|
||||
|
||||
import type { Availability } from '@/lib/reporting-client';
|
||||
|
||||
const LABELS: Record<Availability, { label: string; className: string }> = {
|
||||
available: { label: 'Disponible', className: 'bg-green-100 text-green-800' },
|
||||
unavailable: { label: 'No disponible', className: 'bg-gray-100 text-gray-500' },
|
||||
};
|
||||
|
||||
interface AvailabilityBadgeProps {
|
||||
availability: Availability;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function AvailabilityBadge({ availability, label }: AvailabilityBadgeProps) {
|
||||
const { label: badgeLabel, className } = LABELS[availability];
|
||||
return (
|
||||
<span
|
||||
title={availability === 'unavailable' ? 'Esta métrica aún no está disponible con los datos actuales' : undefined}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium ${className}`}
|
||||
>
|
||||
{availability === 'available' ? (
|
||||
<span className="text-green-600" aria-hidden>✓</span>
|
||||
) : (
|
||||
<span className="text-gray-400" aria-hidden>✗</span>
|
||||
)}
|
||||
{label ?? badgeLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
129
project/apps/admin/src/components/reporting/DateRangePicker.tsx
Normal file
129
project/apps/admin/src/components/reporting/DateRangePicker.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* F-147 — DateRangePicker component.
|
||||
* Preset date ranges + custom from/to inputs.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export interface DatePreset {
|
||||
label: string;
|
||||
getValue: () => { from: string; to: string };
|
||||
}
|
||||
|
||||
const toInputValue = (iso: string) => iso.slice(0, 16); // YYYY-MM-DDTHH:MM
|
||||
|
||||
function todayAt(hour: number, minute = 0) {
|
||||
const d = new Date();
|
||||
d.setUTCHours(hour, minute, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function startOfDay(daysAgo: number) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function startOfMonth() {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function startOfPrevMonth() {
|
||||
const d = new Date();
|
||||
d.setDate(0); // last day of prev month
|
||||
d.setDate(1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function endOfPrevMonth() {
|
||||
const d = new Date();
|
||||
d.setDate(0); // last day of prev month
|
||||
d.setUTCHours(23, 59, 59, 999);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export const DATE_PRESETS: DatePreset[] = [
|
||||
{
|
||||
label: 'Últimos 7 días',
|
||||
getValue: () => ({ from: startOfDay(7), to: todayAt(23) }),
|
||||
},
|
||||
{
|
||||
label: 'Últimos 30 días',
|
||||
getValue: () => ({ from: startOfDay(30), to: todayAt(23) }),
|
||||
},
|
||||
{
|
||||
label: 'Últimos 90 días',
|
||||
getValue: () => ({ from: startOfDay(90), to: todayAt(23) }),
|
||||
},
|
||||
{
|
||||
label: 'Mes actual',
|
||||
getValue: () => ({ from: startOfMonth(), to: todayAt(23) }),
|
||||
},
|
||||
{
|
||||
label: 'Mes anterior',
|
||||
getValue: () => ({ from: startOfPrevMonth(), to: endOfPrevMonth() }),
|
||||
},
|
||||
];
|
||||
|
||||
interface DateRangePickerProps {
|
||||
from: string;
|
||||
to: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
}
|
||||
|
||||
export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
|
||||
const handlePreset = useCallback(
|
||||
(preset: DatePreset) => {
|
||||
const { from: f, to: t } = preset.getValue();
|
||||
onChange(f, t);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleFrom = (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value, to);
|
||||
const handleTo = (e: React.ChangeEvent<HTMLInputElement>) => onChange(from, e.target.value);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{DATE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => handlePreset(preset)}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition-colors"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="date-from" className="text-xs text-gray-500">Desde</label>
|
||||
<input
|
||||
id="date-from"
|
||||
type="datetime-local"
|
||||
value={toInputValue(from)}
|
||||
onChange={handleFrom}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="date-to" className="text-xs text-gray-500">Hasta</label>
|
||||
<input
|
||||
id="date-to"
|
||||
type="datetime-local"
|
||||
value={toInputValue(to)}
|
||||
onChange={handleTo}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
project/apps/admin/src/components/reporting/KpiCard.tsx
Normal file
40
project/apps/admin/src/components/reporting/KpiCard.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* F-147 — KpiCard component.
|
||||
* Shows a key metric with label, optional comparison, and availability badge.
|
||||
*/
|
||||
|
||||
import type { Availability } from '@/lib/reporting-client';
|
||||
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||
|
||||
interface KpiCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
availability: Availability;
|
||||
comparison?: { label: string; value: string; positive?: boolean };
|
||||
}
|
||||
|
||||
function formatComparison(val: string) {
|
||||
const num = parseFloat(val);
|
||||
if (isNaN(num)) return val;
|
||||
const sign = num > 0 ? '+' : '';
|
||||
return `${sign}${num.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function KpiCard({ label, value, availability, comparison }: KpiCardProps) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-gray-500">{label}</p>
|
||||
<AvailabilityBadge availability={availability} />
|
||||
</div>
|
||||
|
||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||
|
||||
{comparison && availability === 'available' && (
|
||||
<p className={`text-xs font-medium ${comparison.positive === false ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{comparison.label}: {formatComparison(comparison.value)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export type Permission =
|
||||
| 'cms.write'
|
||||
| 'admin-users.read'
|
||||
| 'admin-users.write'
|
||||
| 'audit.read';
|
||||
| 'audit.read'
|
||||
| 'reporting.read';
|
||||
|
||||
export function can(role: Role, permission: Permission): boolean {
|
||||
if (role === 'admin') return true;
|
||||
@@ -41,6 +42,7 @@ export interface NavItem {
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
|
||||
{ href: '/reporting', label: 'Reporting', 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' },
|
||||
|
||||
155
project/apps/admin/src/lib/reporting-client.ts
Normal file
155
project/apps/admin/src/lib/reporting-client.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* F-147 — Reporting API client (frontend admin).
|
||||
*
|
||||
* Wraps the backend reporting endpoints (F-143, F-146).
|
||||
* All paths go through /api/* (Next.js proxy).
|
||||
*/
|
||||
|
||||
import { api } from './api-client';
|
||||
|
||||
export type ReportingChannel = 'all' | 'ecommerce' | 'pos' | 'admin';
|
||||
export type ComparisonMode = 'none' | 'previous_equal' | 'previous_calendar';
|
||||
export type GroupBy = 'day' | 'week' | 'month' | 'hour' | 'store' | 'channel' | 'terminal' | 'cashier' | 'payment';
|
||||
|
||||
export interface DateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface ReportingFilters {
|
||||
from: string;
|
||||
to: string;
|
||||
compare?: ComparisonMode;
|
||||
channel?: ReportingChannel;
|
||||
storeId?: string | string[];
|
||||
terminalId?: string | string[];
|
||||
state?: string | string[];
|
||||
groupBy?: GroupBy;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export type Availability = 'available' | 'unavailable';
|
||||
|
||||
export interface DataAvailability {
|
||||
grossSales: Availability;
|
||||
netSales: Availability;
|
||||
discounts: Availability;
|
||||
tax: Availability;
|
||||
unitsSold: Availability;
|
||||
orders: Availability;
|
||||
customers: Availability;
|
||||
margin: Availability;
|
||||
paymentMethod: Availability;
|
||||
refunds: Availability;
|
||||
shipping: Availability;
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
orders: number;
|
||||
customers: number;
|
||||
grossSalesCents: number;
|
||||
discountsCents: number;
|
||||
taxCents: number;
|
||||
unitsSold: number;
|
||||
shippingCents: number;
|
||||
}
|
||||
|
||||
export interface Comparison {
|
||||
previous: Metrics;
|
||||
variation?: number;
|
||||
}
|
||||
|
||||
export interface SummaryResponse {
|
||||
range: DateRange;
|
||||
filters: {
|
||||
channel: ReportingChannel;
|
||||
storeIds: string[];
|
||||
terminalIds: string[];
|
||||
};
|
||||
comparison: Comparison | null;
|
||||
dataAvailability: DataAvailability;
|
||||
totals: Metrics;
|
||||
updatedAt: string;
|
||||
cache: { hit: boolean; maxAgeSeconds: number };
|
||||
}
|
||||
|
||||
export interface SalesRow {
|
||||
period: string | null;
|
||||
channel: ReportingChannel | null;
|
||||
storeId: string | null;
|
||||
terminalId: string | null;
|
||||
metrics: Metrics;
|
||||
}
|
||||
|
||||
export interface SalesResponse extends SummaryResponse {
|
||||
filters: {
|
||||
channel: ReportingChannel;
|
||||
storeIds: string[];
|
||||
terminalIds: string[];
|
||||
groupBy: GroupBy | null;
|
||||
};
|
||||
items: SalesRow[];
|
||||
totals: Metrics;
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalRows: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FilterSchema {
|
||||
filterSchema: {
|
||||
filters: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
repeatable: boolean;
|
||||
options?: readonly string[];
|
||||
description: string;
|
||||
}>;
|
||||
comparison: { modes: ComparisonMode[]; rangeBounds: string };
|
||||
groupBy: GroupBy[];
|
||||
pagination: { pageMin: number; pageSizeMin: number; pageSizeMax: number };
|
||||
dataAvailability: DataAvailability;
|
||||
};
|
||||
permissions: { role: string; grants: string[] };
|
||||
}
|
||||
|
||||
// ── API calls ───────────────────────────────────────────────────────────────
|
||||
|
||||
function filtersToQueryString(filters: ReportingFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('from', filters.from);
|
||||
params.set('to', filters.to);
|
||||
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.page) params.set('page', String(filters.page));
|
||||
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
||||
if (filters.storeId) {
|
||||
const ids = Array.isArray(filters.storeId) ? filters.storeId : [filters.storeId];
|
||||
ids.forEach((id) => params.append('storeId', id));
|
||||
}
|
||||
if (filters.terminalId) {
|
||||
const ids = Array.isArray(filters.terminalId) ? filters.terminalId : [filters.terminalId];
|
||||
ids.forEach((id) => params.append('terminalId', id));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export const reportingClient = {
|
||||
async fetchSchema(): Promise<FilterSchema> {
|
||||
return api.get<FilterSchema>('/api/reporting/filters/schema');
|
||||
},
|
||||
|
||||
async fetchSummary(filters: ReportingFilters): Promise<SummaryResponse> {
|
||||
const qs = filtersToQueryString(filters);
|
||||
return api.get<SummaryResponse>(`/api/reporting/summary?${qs}`);
|
||||
},
|
||||
|
||||
async fetchSales(filters: ReportingFilters): Promise<SalesResponse> {
|
||||
const qs = filtersToQueryString(filters);
|
||||
return api.get<SalesResponse>(`/api/reporting/sales?${qs}`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user