feat(F-147): completed feature

This commit is contained in:
chattie
2026-08-22 12:56:09 +02:00
parent 91044da178
commit d39342cdeb
19 changed files with 1138 additions and 23 deletions

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

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