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