feat(F-148): completed feature
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
71
project/apps/admin/src/components/reporting/TrendChart.tsx
Normal file
71
project/apps/admin/src/components/reporting/TrendChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user