feat(F-148): completed feature

This commit is contained in:
chattie
2026-08-22 12:58:17 +02:00
parent d39342cdeb
commit c497d5be99
15 changed files with 712 additions and 22 deletions

View File

@@ -0,0 +1,394 @@
'use client';
/**
* F-148 — Reporting dashboard: KPIs, trends, channel breakdown, store/terminal rankings.
* Built on F-147 shell components and F-146 ReportingService.
*/
import { Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { DateRangePicker, DATE_PRESETS } from '@/components/reporting/DateRangePicker';
import { TrendChart } from '@/components/reporting/TrendChart';
import { ChannelBreakdown } from '@/components/reporting/ChannelBreakdown';
import { reportingClient, type SalesResponse, type ReportingChannel } from '@/lib/reporting-client';
function formatCents(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function buildDefaultRange() {
return DATE_PRESETS[1].getValue(); // 30 days
}
interface FilterState {
from: string;
to: string;
channel: ReportingChannel;
trendPeriod: '7' | '30' | '90';
}
function TrendDashboard({ filters }: { filters: FilterState }) {
const [data, setData] = useState<SalesResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
const days = parseInt(filters.trendPeriod, 10);
const from = new Date(filters.to);
from.setDate(from.getDate() - days);
from.setUTCHours(0, 0, 0, 0);
const fromStr = from.toISOString();
const toStr = filters.to;
try {
const result = await reportingClient.fetchSales({
from: fromStr,
to: toStr,
channel: filters.channel,
groupBy: 'day',
pageSize: days + 5,
page: 1,
});
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => { load(); }, [load]);
if (loading) {
return <div className="h-56 bg-gray-100 rounded-xl animate-pulse" />;
}
if (error) {
return (
<div className="h-56 bg-red-50 border border-red-200 rounded-xl flex items-center justify-center">
<p className="text-red-600 text-sm">{error}</p>
</div>
);
}
const points = (data?.items ?? []).map((item) => ({
label: item.period ?? '',
value: item.metrics.grossSalesCents,
}));
const maxValue = Math.max(...points.map((p) => p.value), 1);
return <TrendChart data={points} maxValue={maxValue} height={220} />;
}
function ChannelDashboard({ filters }: { filters: FilterState }) {
const [data, setData] = useState<SalesResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await reportingClient.fetchSales({
from: filters.from,
to: filters.to,
channel: filters.channel,
groupBy: 'channel',
pageSize: 10,
page: 1,
});
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => { load(); }, [load]);
if (loading) {
return (
<div className="grid grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-32 bg-gray-100 rounded-xl animate-pulse" />
))}
</div>
);
}
if (error) return <p className="text-red-500 text-sm">{error}</p>;
const channelData = (data?.items ?? []).map((item) => ({
channel: item.channel ?? 'all' as ReportingChannel,
orders: item.metrics.orders,
grossSalesCents: item.metrics.grossSalesCents,
}));
const total = data?.totals.grossSalesCents ?? 0;
return <ChannelBreakdown data={channelData} totalSalesCents={total} />;
}
function StoresTable({ filters }: { filters: FilterState }) {
const [data, setData] = useState<SalesResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await reportingClient.fetchSales({
from: filters.from,
to: filters.to,
channel: filters.channel,
groupBy: 'store',
pageSize: 10,
page: 1,
});
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => { load(); }, [load]);
if (loading) {
return <div className="h-48 bg-gray-100 rounded-xl animate-pulse" />;
}
if (error) return <p className="text-red-500 text-sm">{error}</p>;
const items = (data?.items ?? []).sort((a, b) => b.metrics.grossSalesCents - a.metrics.grossSalesCents);
const total = data?.totals.grossSalesCents ?? 1;
if (items.length === 0) {
return <p className="text-gray-400 text-sm">No hay datos de tiendas</p>;
}
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-3 font-semibold text-gray-500 uppercase text-xs">Tienda</th>
<th className="text-right py-2 px-3 font-semibold text-gray-500 uppercase text-xs">Pedidos</th>
<th className="text-right py-2 px-3 font-semibold text-gray-500 uppercase text-xs">Ventas</th>
<th className="text-right py-2 px-3 font-semibold text-gray-500 uppercase text-xs">%</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{items.map((row, i) => (
<tr key={i} className="hover:bg-gray-50">
<td className="py-2.5 px-3 font-medium text-gray-800">
{row.storeId ? row.storeId.slice(0, 8) + '...' : 'Sin tienda'}
</td>
<td className="py-2.5 px-3 text-right text-gray-600">{row.metrics.orders}</td>
<td className="py-2.5 px-3 text-right font-medium text-gray-900">
{formatCents(row.metrics.grossSalesCents)}
</td>
<td className="py-2.5 px-3 text-right text-gray-500">
{((row.metrics.grossSalesCents / total) * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
);
}
function TerminalsTable({ filters }: { filters: FilterState }) {
const [data, setData] = useState<SalesResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await reportingClient.fetchSales({
from: filters.from,
to: filters.to,
channel: 'pos',
groupBy: 'terminal',
pageSize: 10,
page: 1,
});
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => { load(); }, [load]);
if (loading) {
return <div className="h-48 bg-gray-100 rounded-xl animate-pulse" />;
}
if (error) return <p className="text-red-500 text-sm">{error}</p>;
const items = (data?.items ?? []).sort((a, b) => b.metrics.grossSalesCents - a.metrics.grossSalesCents);
if (items.length === 0) {
return <p className="text-gray-400 text-sm">No hay datos de terminales</p>;
}
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-3 font-semibold text-gray-500 uppercase text-xs">Terminal</th>
<th className="text-right py-2 px-3 font-semibold text-gray-500 uppercase text-xs">Pedidos</th>
<th className="text-right py-2 px-3 font-semibold text-gray-500 uppercase text-xs">Ventas</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{items.map((row, i) => (
<tr key={i} className="hover:bg-gray-50">
<td className="py-2.5 px-3 font-medium text-gray-800">
{row.terminalId ? row.terminalId.slice(0, 8) + '...' : 'Sin terminal'}
</td>
<td className="py-2.5 px-3 text-right text-gray-600">{row.metrics.orders}</td>
<td className="py-2.5 px-3 text-right font-medium text-gray-900">
{formatCents(row.metrics.grossSalesCents)}
</td>
</tr>
))}
</tbody>
</table>
);
}
function DashboardContent() {
const router = useRouter();
const searchParams = useSearchParams();
const getInitial = (): FilterState => ({
from: searchParams.get('from') ?? buildDefaultRange().from,
to: searchParams.get('to') ?? buildDefaultRange().to,
channel: (searchParams.get('channel') as ReportingChannel) ?? 'all',
trendPeriod: (searchParams.get('trend') as FilterState['trendPeriod']) ?? '30',
});
const [filters, setFilters] = useState<FilterState>(getInitial);
const updateSearchParams = useCallback(
(f: FilterState) => {
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.trendPeriod !== '30') params.set('trend', f.trendPeriod);
router.replace(`/reporting/dashboard?${params}`, { scroll: false });
},
[router],
);
const handleFiltersChange = (f: FilterState) => {
setFilters(f);
updateSearchParams(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">Dashboard de ventas</h1>
<p className="text-sm text-gray-500 mt-0.5">
Tendencias, canales y rendimiento por tienda/terminal
</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">
{/* Channel */}
<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>
{/* Trend period */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Período tendencia</label>
<select
value={filters.trendPeriod}
onChange={(e) => handleFiltersChange({ ...filters, trendPeriod: e.target.value as FilterState['trendPeriod'] })}
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="7">7 días</option>
<option value="30">30 días</option>
<option value="90">90 días</option>
</select>
</div>
</div>
<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>
{/* Channel breakdown */}
<div className="bg-white border border-gray-200 rounded-xl p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">Canales</h2>
<ChannelDashboard filters={filters} />
</div>
{/* Trend chart */}
<div className="bg-white border border-gray-200 rounded-xl p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">
Tendencia ({filters.trendPeriod === '7' ? '7 días' : filters.trendPeriod === '30' ? '30 días' : '90 días'})
</h2>
<TrendDashboard filters={filters} />
</div>
{/* Store and terminal tables */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">Top tiendas</h2>
<StoresTable filters={filters} />
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">Top terminales (TPV)</h2>
<TerminalsTable filters={filters} />
</div>
</div>
</div>
);
}
export default function DashboardPage() {
return (
<Suspense fallback={
<div className="p-8 animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/3" />
<div className="h-48 bg-gray-200 rounded-xl" />
<div className="h-64 bg-gray-200 rounded-xl" />
</div>
}>
<DashboardContent />
</Suspense>
);
}

View File

@@ -0,0 +1,89 @@
/**
* F-148 — ChannelBreakdown: ecommerce vs POS vs admin sales breakdown.
*/
import type { ReportingChannel } from '@/lib/reporting-client';
interface ChannelData {
channel: ReportingChannel;
orders: number;
grossSalesCents: number;
}
interface ChannelBreakdownProps {
data: ChannelData[];
totalSalesCents: number;
}
function formatCents(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function channelLabel(ch: ReportingChannel) {
const labels: Record<ReportingChannel, string> = {
all: 'Todos',
ecommerce: 'Ecommerce',
pos: 'TPV',
admin: 'Admin',
};
return labels[ch] ?? ch;
}
function channelColor(ch: ReportingChannel) {
const colors: Record<ReportingChannel, string> = {
all: 'bg-gray-100 text-gray-700 border-gray-200',
ecommerce: 'bg-blue-50 text-blue-800 border-blue-200',
pos: 'bg-green-50 text-green-800 border-green-200',
admin: 'bg-purple-50 text-purple-800 border-purple-200',
};
return colors[ch] ?? 'bg-gray-100 text-gray-700 border-gray-200';
}
function channelIcon(ch: ReportingChannel) {
const icons: Record<ReportingChannel, string> = {
all: '🌐',
ecommerce: '🛒',
pos: '🏪',
admin: '⚙️',
};
return icons[ch] ?? '📊';
}
export function ChannelBreakdown({ data, totalSalesCents }: ChannelBreakdownProps) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{data.map((row) => {
const pct = totalSalesCents > 0
? ((row.grossSalesCents / totalSalesCents) * 100).toFixed(1)
: '0.0';
return (
<div
key={row.channel}
className={`rounded-xl border p-5 flex flex-col gap-3 ${channelColor(row.channel)}`}
>
<div className="flex items-center gap-2">
<span className="text-2xl" aria-hidden>{channelIcon(row.channel)}</span>
<p className="font-semibold text-sm">{channelLabel(row.channel)}</p>
</div>
<div>
<p className="text-2xl font-bold">{formatCents(row.grossSalesCents)}</p>
<p className="text-xs opacity-75">{row.orders} pedidos</p>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-black/10 rounded-full h-2 overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs font-medium">{pct}%</span>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,71 @@
/**
* F-148 — TrendChart: SVG bar chart for sales over time.
*/
interface TrendDataPoint {
label: string;
value: number; // cents
}
interface TrendChartProps {
data: TrendDataPoint[];
maxValue: number;
height?: number;
}
export function TrendChart({ data, maxValue, height = 200 }: TrendChartProps) {
if (data.length === 0) return null;
const width = 100; // percentage-based SVG
const barWidth = Math.min(3, (width * 0.9) / data.length);
const gap = Math.max(0.2, (width - barWidth * data.length) / (data.length + 1));
const chartHeight = height - 40; // leave room for labels
return (
<div className="w-full overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full"
style={{ minWidth: `${Math.max(300, data.length * 8)}px` }}
aria-label="Tendencia de ventas"
role="img"
>
{/* Y-axis baseline */}
<line x1="0" y1={chartHeight} x2={width} y2={chartHeight} stroke="#e5e7eb" strokeWidth="0.5" />
{data.map((point, i) => {
const barH = maxValue > 0 ? (point.value / maxValue) * chartHeight : 0;
const x = gap + i * (barWidth + gap);
const y = chartHeight - barH;
return (
<g key={i} className="group">
<title>{`${point.label}: €${(point.value / 100).toFixed(2)}`}</title>
<rect
x={x}
y={y}
width={barWidth}
height={barH}
fill="#2D6A4F"
rx="1"
className="transition-all duration-200 hover:fill-[#245a42]"
/>
{/* Label (show every ~7 bars or if narrow) */}
{data.length <= 31 && (
<text
x={x + barWidth / 2}
y={height - 2}
textAnchor="middle"
fontSize="2.5"
fill="#9ca3af"
>
{point.label.slice(5, 10)}
</text>
)}
</g>
);
})}
</svg>
</div>
);
}