eca619b79a
✨ Created complete atoms library (21 components): - Buttons: Button, IconButton with variants (primary, secondary, danger, ghost) - Inputs: TextInput, SearchInput, Textarea with validation support - Typography: Heading (h1-h6), Text (variants), Caption - Badges: Badge (5 variants), PriceBadge (formatted pricing) - Dividers: Horizontal/vertical separators 🎯 Updated existing components to use atoms: - CartProduct: Now uses Button, Text, Caption atoms - ReviewModal: Now uses Button, Textarea, Heading, Text atoms 📚 Added comprehensive documentation: - components/atoms/ATOMS.md: Complete atoms reference guide - Usage examples, theming, import patterns, accessibility 🏗️ Architecture improvements: - Foundation for molecules/organisms - Type-safe components with full TypeScript support - Consistent theming via CSS variables - Barrel exports for clean imports ✅ Verified: - npm run build: Success (✓ Compiled successfully) - npm run format: All files formatted - npm run lint: No new errors in atoms Next: Phase 2 - Create molecules (ProductCard, FormField, SearchBar, etc.) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1173 lines
40 KiB
TypeScript
1173 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
MENU_CATEGORIES,
|
|
MOCK_PRODUCT_SALES,
|
|
MOCK_REVENUE_DAILY,
|
|
MOCK_REVENUE_MONTHLY,
|
|
MOCK_REVENUE_WEEKLY,
|
|
MOCK_REVENUE_YEARLY,
|
|
} from "@/lib/constants";
|
|
import type {
|
|
AnalyticsPeriod,
|
|
ProductSalesStats,
|
|
RevenueDataPoint,
|
|
} from "@/lib/types";
|
|
import Link from "next/link";
|
|
import { useMemo, useState } from "react";
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function formatCurrency(value: number): string {
|
|
if (value >= 1_000_000_000) return (value / 1_000_000_000).toFixed(1) + " tỷ";
|
|
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + " tr";
|
|
if (value >= 1_000) return (value / 1_000).toFixed(0) + " k";
|
|
return value.toLocaleString("vi-VN") + "đ";
|
|
}
|
|
|
|
function formatCurrencyFull(value: number): string {
|
|
return value.toLocaleString("vi-VN") + "đ";
|
|
}
|
|
|
|
function calcChange(current: number, previous: number) {
|
|
const change = current - previous;
|
|
const changePercent = previous === 0 ? 0 : (change / previous) * 100;
|
|
return { change, changePercent, isPositive: change >= 0 };
|
|
}
|
|
|
|
// ─── SVG Line Chart ───────────────────────────────────────────────────────────
|
|
|
|
interface LineChartProps {
|
|
data: RevenueDataPoint[];
|
|
height?: number;
|
|
}
|
|
|
|
function LineChart({ data, height = 200 }: LineChartProps) {
|
|
const [hovered, setHovered] = useState<number | null>(null);
|
|
const W = 800;
|
|
const H = height;
|
|
const padL = 56;
|
|
const padR = 16;
|
|
const padT = 16;
|
|
const padB = 40;
|
|
const chartW = W - padL - padR;
|
|
const chartH = H - padT - padB;
|
|
|
|
const maxRev = Math.max(...data.map((d) => d.revenue));
|
|
const minRev = 0;
|
|
const range = maxRev - minRev || 1;
|
|
|
|
const points = data.map((d, i) => ({
|
|
x: padL + (i / (data.length - 1)) * chartW,
|
|
y: padT + chartH - ((d.revenue - minRev) / range) * chartH,
|
|
data: d,
|
|
index: i,
|
|
}));
|
|
|
|
const pathD = points
|
|
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
|
|
.join(" ");
|
|
|
|
// Area path
|
|
const areaD =
|
|
pathD +
|
|
` L ${points[points.length - 1].x.toFixed(1)} ${(padT + chartH).toFixed(1)}` +
|
|
` L ${points[0].x.toFixed(1)} ${(padT + chartH).toFixed(1)} Z`;
|
|
|
|
// Y-axis gridlines
|
|
const yTicks = 5;
|
|
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => {
|
|
const val = minRev + (range / yTicks) * (yTicks - i);
|
|
const y = padT + (i / yTicks) * chartH;
|
|
return { val, y };
|
|
});
|
|
|
|
// X labels: show every nth to avoid crowding
|
|
const step = Math.ceil(data.length / 10);
|
|
|
|
return (
|
|
<div className="relative w-full overflow-x-auto">
|
|
<svg
|
|
viewBox={`0 0 ${W} ${H}`}
|
|
className="w-full"
|
|
style={{ height: H, minWidth: 320 }}
|
|
onMouseLeave={() => setHovered(null)}
|
|
>
|
|
<defs>
|
|
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#6F4E37" stopOpacity="0.25" />
|
|
<stop offset="100%" stopColor="#6F4E37" stopOpacity="0.02" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Grid lines */}
|
|
{gridLines.map((g, i) => (
|
|
<g key={i}>
|
|
<line
|
|
x1={padL}
|
|
y1={g.y}
|
|
x2={W - padR}
|
|
y2={g.y}
|
|
stroke="#E2C9A8"
|
|
strokeWidth="1"
|
|
strokeDasharray={i === yTicks ? "0" : "4 3"}
|
|
/>
|
|
<text
|
|
x={padL - 6}
|
|
y={g.y + 4}
|
|
textAnchor="end"
|
|
fontSize="10"
|
|
fill="#A08060"
|
|
>
|
|
{formatCurrency(g.val)}
|
|
</text>
|
|
</g>
|
|
))}
|
|
|
|
{/* Area fill */}
|
|
<path d={areaD} fill="url(#areaGrad)" />
|
|
|
|
{/* Line */}
|
|
<path
|
|
d={pathD}
|
|
fill="none"
|
|
stroke="#6F4E37"
|
|
strokeWidth="2.5"
|
|
strokeLinejoin="round"
|
|
strokeLinecap="round"
|
|
/>
|
|
|
|
{/* X labels */}
|
|
{points.map((p, i) =>
|
|
i % step === 0 ? (
|
|
<text
|
|
key={i}
|
|
x={p.x}
|
|
y={H - 8}
|
|
textAnchor="middle"
|
|
fontSize="10"
|
|
fill="#A08060"
|
|
>
|
|
{p.data.label}
|
|
</text>
|
|
) : null,
|
|
)}
|
|
|
|
{/* Interactive dots */}
|
|
{points.map((p) => (
|
|
<g key={p.index}>
|
|
<circle
|
|
cx={p.x}
|
|
cy={p.y}
|
|
r={hovered === p.index ? 5 : 3}
|
|
fill={hovered === p.index ? "#C8973A" : "#6F4E37"}
|
|
stroke="#FDF6EC"
|
|
strokeWidth="2"
|
|
style={{ cursor: "pointer", transition: "r 150ms" }}
|
|
onMouseEnter={() => setHovered(p.index)}
|
|
/>
|
|
</g>
|
|
))}
|
|
|
|
{/* Tooltip */}
|
|
{hovered !== null &&
|
|
(() => {
|
|
const p = points[hovered];
|
|
const tipW = 120;
|
|
const tipH = 48;
|
|
const tipX = Math.min(
|
|
Math.max(p.x - tipW / 2, padL),
|
|
W - padR - tipW,
|
|
);
|
|
const aboveY = p.y - tipH - 10;
|
|
const belowY = p.y + 10;
|
|
const minY = padT;
|
|
const maxY = padT + chartH - tipH;
|
|
const preferredY = aboveY >= minY ? aboveY : belowY;
|
|
const tipY = Math.min(Math.max(preferredY, minY), maxY);
|
|
return (
|
|
<g>
|
|
<rect
|
|
x={tipX}
|
|
y={tipY}
|
|
width={tipW}
|
|
height={tipH}
|
|
rx="6"
|
|
fill="#3D2B1F"
|
|
opacity="0.92"
|
|
/>
|
|
<text
|
|
x={tipX + tipW / 2}
|
|
y={tipY + 16}
|
|
textAnchor="middle"
|
|
fontSize="10"
|
|
fill="#F0D9A8"
|
|
>
|
|
{p.data.label}
|
|
</text>
|
|
<text
|
|
x={tipX + tipW / 2}
|
|
y={tipY + 30}
|
|
textAnchor="middle"
|
|
fontSize="11"
|
|
fontWeight="600"
|
|
fill="#C8973A"
|
|
>
|
|
{formatCurrency(p.data.revenue)}
|
|
</text>
|
|
<text
|
|
x={tipX + tipW / 2}
|
|
y={tipY + 44}
|
|
textAnchor="middle"
|
|
fontSize="10"
|
|
fill="#A08060"
|
|
>
|
|
{p.data.orders} đơn
|
|
</text>
|
|
</g>
|
|
);
|
|
})()}
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── SVG Bar Chart ────────────────────────────────────────────────────────────
|
|
|
|
interface BarChartProps {
|
|
current: RevenueDataPoint[];
|
|
previous: RevenueDataPoint[];
|
|
height?: number;
|
|
}
|
|
|
|
function BarChart({ current, previous, height = 200 }: BarChartProps) {
|
|
const [hovered, setHovered] = useState<{
|
|
set: "cur" | "prev";
|
|
idx: number;
|
|
} | null>(null);
|
|
const W = 800;
|
|
const H = height;
|
|
const padL = 56;
|
|
const padR = 16;
|
|
const padT = 16;
|
|
const padB = 40;
|
|
const chartW = W - padL - padR;
|
|
const chartH = H - padT - padB;
|
|
|
|
const n = current.length;
|
|
const allVals = [
|
|
...current.map((d) => d.revenue),
|
|
...previous.map((d) => d.revenue),
|
|
];
|
|
const maxVal = Math.max(...allVals) || 1;
|
|
|
|
const groupW = chartW / n;
|
|
const barW = groupW * 0.35;
|
|
const gap = groupW * 0.05;
|
|
|
|
const yTicks = 5;
|
|
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => {
|
|
const val = (maxVal / yTicks) * (yTicks - i);
|
|
const y = padT + (i / yTicks) * chartH;
|
|
return { val, y };
|
|
});
|
|
|
|
const step = Math.ceil(n / 8);
|
|
|
|
return (
|
|
<div className="relative w-full overflow-x-auto">
|
|
<svg
|
|
viewBox={`0 0 ${W} ${H}`}
|
|
className="w-full"
|
|
style={{ height: H, minWidth: 320 }}
|
|
onMouseLeave={() => setHovered(null)}
|
|
>
|
|
{/* Grid */}
|
|
{gridLines.map((g, i) => (
|
|
<g key={i}>
|
|
<line
|
|
x1={padL}
|
|
y1={g.y}
|
|
x2={W - padR}
|
|
y2={g.y}
|
|
stroke="#E2C9A8"
|
|
strokeWidth="1"
|
|
strokeDasharray={i === yTicks ? "0" : "4 3"}
|
|
/>
|
|
<text
|
|
x={padL - 6}
|
|
y={g.y + 4}
|
|
textAnchor="end"
|
|
fontSize="10"
|
|
fill="#A08060"
|
|
>
|
|
{formatCurrency(g.val)}
|
|
</text>
|
|
</g>
|
|
))}
|
|
|
|
{/* Bars */}
|
|
{current.map((d, i) => {
|
|
const groupX = padL + i * groupW;
|
|
const curH = (d.revenue / maxVal) * chartH;
|
|
const prevH = (previous[i]?.revenue / maxVal) * chartH || 0;
|
|
const curX = groupX + gap;
|
|
const prevX = curX + barW + gap;
|
|
const isHovCur = hovered?.set === "cur" && hovered.idx === i;
|
|
const isHovPrev = hovered?.set === "prev" && hovered.idx === i;
|
|
|
|
return (
|
|
<g key={i}>
|
|
{/* Previous bar */}
|
|
<rect
|
|
x={prevX}
|
|
y={padT + chartH - prevH}
|
|
width={barW}
|
|
height={prevH}
|
|
rx="3"
|
|
fill={isHovPrev ? "#A0785A" : "#E2C9A8"}
|
|
style={{ cursor: "pointer", transition: "fill 150ms" }}
|
|
onMouseEnter={() => setHovered({ set: "prev", idx: i })}
|
|
/>
|
|
{/* Current bar */}
|
|
<rect
|
|
x={curX}
|
|
y={padT + chartH - curH}
|
|
width={barW}
|
|
height={curH}
|
|
rx="3"
|
|
fill={isHovCur ? "#4A3728" : "#6F4E37"}
|
|
style={{ cursor: "pointer", transition: "fill 150ms" }}
|
|
onMouseEnter={() => setHovered({ set: "cur", idx: i })}
|
|
/>
|
|
{/* X label */}
|
|
{i % step === 0 && (
|
|
<text
|
|
x={groupX + groupW / 2}
|
|
y={H - 8}
|
|
textAnchor="middle"
|
|
fontSize="10"
|
|
fill="#A08060"
|
|
>
|
|
{d.label}
|
|
</text>
|
|
)}
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* Tooltip */}
|
|
{hovered !== null &&
|
|
(() => {
|
|
const d =
|
|
hovered.set === "cur"
|
|
? current[hovered.idx]
|
|
: previous[hovered.idx];
|
|
if (!d) return null;
|
|
const groupX = padL + hovered.idx * groupW;
|
|
const tipW = 130;
|
|
const tipH = 50;
|
|
const tipX = Math.min(
|
|
Math.max(groupX - tipW / 2, padL),
|
|
W - padR - tipW,
|
|
);
|
|
const barH = (d.revenue / maxVal) * chartH;
|
|
const barTopY = padT + chartH - barH;
|
|
const aboveY = barTopY - tipH - 8;
|
|
const belowY = barTopY + 8;
|
|
const minY = padT;
|
|
const maxY = padT + chartH - tipH;
|
|
const preferredY = aboveY >= minY ? aboveY : belowY;
|
|
const tipY = Math.min(Math.max(preferredY, minY), maxY);
|
|
return (
|
|
<g>
|
|
<rect
|
|
x={tipX}
|
|
y={tipY}
|
|
width={tipW}
|
|
height={tipH}
|
|
rx="6"
|
|
fill="#3D2B1F"
|
|
opacity="0.92"
|
|
/>
|
|
<text
|
|
x={tipX + tipW / 2}
|
|
y={tipY + 15}
|
|
textAnchor="middle"
|
|
fontSize="10"
|
|
fill="#F0D9A8"
|
|
>
|
|
{d.label} ({hovered.set === "cur" ? "Hiện tại" : "Trước"})
|
|
</text>
|
|
<text
|
|
x={tipX + tipW / 2}
|
|
y={tipY + 30}
|
|
textAnchor="middle"
|
|
fontSize="11"
|
|
fontWeight="600"
|
|
fill="#C8973A"
|
|
>
|
|
{formatCurrency(d.revenue)}
|
|
</text>
|
|
<text
|
|
x={tipX + tipW / 2}
|
|
y={tipY + 44}
|
|
textAnchor="middle"
|
|
fontSize="10"
|
|
fill="#A08060"
|
|
>
|
|
{d.orders} đơn hàng
|
|
</text>
|
|
</g>
|
|
);
|
|
})()}
|
|
|
|
{/* Legend */}
|
|
<rect x={padL} y={4} width={10} height={10} rx="2" fill="#6F4E37" />
|
|
<text x={padL + 13} y={13} fontSize="10" fill="#6F4E37">
|
|
Hiện tại
|
|
</text>
|
|
<rect
|
|
x={padL + 65}
|
|
y={4}
|
|
width={10}
|
|
height={10}
|
|
rx="2"
|
|
fill="#E2C9A8"
|
|
/>
|
|
<text x={padL + 78} y={13} fontSize="10" fill="#A08060">
|
|
Kỳ trước
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── SVG Pie Chart ────────────────────────────────────────────────────────────
|
|
|
|
interface PieChartProps {
|
|
data: { label: string; value: number; color: string }[];
|
|
}
|
|
|
|
function PieChart({ data }: PieChartProps) {
|
|
const [hovered, setHovered] = useState<number | null>(null);
|
|
const R = 80;
|
|
const CX = 110;
|
|
const CY = 110;
|
|
const total = data.reduce((s, d) => s + d.value, 0) || 1;
|
|
|
|
const slices = useMemo(() => {
|
|
type Acc = { items: ReturnType<typeof makeSlice>[]; angle: number };
|
|
const makeSlice = (d: (typeof data)[0], i: number, startAngle: number) => {
|
|
const angle = (d.value / total) * 2 * Math.PI;
|
|
const endAngle = startAngle + angle;
|
|
const midAngle = startAngle + angle / 2;
|
|
const x1 = CX + R * Math.cos(startAngle);
|
|
const y1 = CY + R * Math.sin(startAngle);
|
|
const x2 = CX + R * Math.cos(endAngle);
|
|
const y2 = CY + R * Math.sin(endAngle);
|
|
const largeArc = angle > Math.PI ? 1 : 0;
|
|
const pathD = `M ${CX} ${CY} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${R} ${R} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z`;
|
|
const labelX = CX + R * 0.65 * Math.cos(midAngle);
|
|
const labelY = CY + R * 0.65 * Math.sin(midAngle);
|
|
return {
|
|
...d,
|
|
pathD,
|
|
labelX,
|
|
labelY,
|
|
midAngle,
|
|
percent: (d.value / total) * 100,
|
|
index: i,
|
|
endAngle,
|
|
};
|
|
};
|
|
const { items } = data.reduce<Acc>(
|
|
(acc, d, i) => {
|
|
const slice = makeSlice(d, i, acc.angle);
|
|
return { items: [...acc.items, slice], angle: slice.endAngle };
|
|
},
|
|
{ items: [], angle: -Math.PI / 2 },
|
|
);
|
|
return items;
|
|
}, [data, total]);
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
|
|
<svg
|
|
viewBox="0 0 220 220"
|
|
className="w-full max-w-55 shrink-0"
|
|
style={{ height: 220 }}
|
|
onMouseLeave={() => setHovered(null)}
|
|
>
|
|
{slices.map((s) => (
|
|
<path
|
|
key={s.index}
|
|
d={s.pathD}
|
|
fill={s.color}
|
|
stroke="#FDF6EC"
|
|
strokeWidth="2"
|
|
style={{ cursor: "pointer", transition: "d 200ms" }}
|
|
onMouseEnter={() => setHovered(s.index)}
|
|
opacity={hovered !== null && hovered !== s.index ? 0.65 : 1}
|
|
/>
|
|
))}
|
|
{/* Show percent label for hovered */}
|
|
{hovered !== null && (
|
|
<text
|
|
x={CX}
|
|
y={CY + 5}
|
|
textAnchor="middle"
|
|
fontSize="12"
|
|
fontWeight="bold"
|
|
fill="#3D2B1F"
|
|
>
|
|
{slices[hovered].percent.toFixed(1)}%
|
|
</text>
|
|
)}
|
|
</svg>
|
|
{/* Legend */}
|
|
<div className="flex flex-wrap gap-x-4 gap-y-2 sm:flex-col">
|
|
{slices.map((s) => (
|
|
<div
|
|
key={s.index}
|
|
className="flex cursor-pointer items-center gap-2 text-sm"
|
|
onMouseEnter={() => setHovered(s.index)}
|
|
onMouseLeave={() => setHovered(null)}
|
|
>
|
|
<span
|
|
className="inline-block h-3 w-3 shrink-0 rounded-full"
|
|
style={{ backgroundColor: s.color }}
|
|
/>
|
|
<span
|
|
className="max-w-35 truncate"
|
|
style={{
|
|
color: hovered === s.index ? "#3D2B1F" : "#6F4E37",
|
|
fontWeight: hovered === s.index ? 600 : 400,
|
|
}}
|
|
>
|
|
{s.label}
|
|
</span>
|
|
<span className="text-xs text-(--color-text-muted)">
|
|
{s.percent.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Summary Card ─────────────────────────────────────────────────────────────
|
|
|
|
interface SummaryCardProps {
|
|
icon: string;
|
|
title: string;
|
|
value: string;
|
|
change: number;
|
|
changePercent: number;
|
|
isPositive: boolean;
|
|
subtitle?: string;
|
|
}
|
|
|
|
function SummaryCard({
|
|
icon,
|
|
title,
|
|
value,
|
|
change,
|
|
changePercent,
|
|
isPositive,
|
|
subtitle,
|
|
}: SummaryCardProps) {
|
|
return (
|
|
<div className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
|
|
<div className="mb-3 flex items-center gap-3">
|
|
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-(--color-accent-light) text-lg text-(--color-primary)">
|
|
<i className={icon}></i>
|
|
</span>
|
|
<span className="text-sm font-medium text-(--color-text-muted)">
|
|
{title}
|
|
</span>
|
|
</div>
|
|
<p className="text-foreground text-2xl font-bold tabular-nums">{value}</p>
|
|
{subtitle && (
|
|
<p className="mt-0.5 text-xs text-(--color-text-muted)">{subtitle}</p>
|
|
)}
|
|
<div
|
|
className={`mt-3 flex items-center gap-1.5 text-sm font-medium ${isPositive ? "text-green-600" : "text-red-500"}`}
|
|
>
|
|
<i
|
|
className={`fa-solid ${isPositive ? "fa-arrow-trend-up" : "fa-arrow-trend-down"} text-xs`}
|
|
></i>
|
|
<span>
|
|
{isPositive ? "+" : ""}
|
|
{changePercent.toFixed(1)}%
|
|
</span>
|
|
<span className="text-xs font-normal text-(--color-text-muted)">
|
|
({isPositive ? "+" : ""}
|
|
{formatCurrency(change)}) so với kỳ trước
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Product Table ────────────────────────────────────────────────────────────
|
|
|
|
interface ProductTableProps {
|
|
data: ProductSalesStats[];
|
|
}
|
|
|
|
function ProductTable({ data }: ProductTableProps) {
|
|
const [sortKey, setSortKey] = useState<keyof ProductSalesStats>("revenue");
|
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
|
|
|
const sorted = useMemo(() => {
|
|
return [...data].sort((a, b) => {
|
|
const av = a[sortKey] as number;
|
|
const bv = b[sortKey] as number;
|
|
return sortDir === "desc" ? bv - av : av - bv;
|
|
});
|
|
}, [data, sortKey, sortDir]);
|
|
|
|
const handleSort = (key: keyof ProductSalesStats) => {
|
|
if (key === sortKey) {
|
|
setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDir("desc");
|
|
}
|
|
};
|
|
|
|
const sortIcon = (col: keyof ProductSalesStats) => (
|
|
<i
|
|
className={`fa-solid ml-1 text-xs ${
|
|
sortKey === col
|
|
? sortDir === "desc"
|
|
? "fa-sort-down text-(--color-primary)"
|
|
: "fa-sort-up text-(--color-primary)"
|
|
: "fa-sort text-(--color-text-muted)"
|
|
}`}
|
|
></i>
|
|
);
|
|
|
|
const categoryName = (id: string) =>
|
|
MENU_CATEGORIES.find((c) => c.id === id)?.name ?? id;
|
|
|
|
return (
|
|
<div className="overflow-x-auto rounded-xl border border-(--color-border-light)">
|
|
<table className="w-full min-w-175 text-sm">
|
|
<thead>
|
|
<tr className="bg-background border-b border-(--color-border-light)">
|
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
|
#
|
|
</th>
|
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
|
Sản phẩm
|
|
</th>
|
|
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
|
|
Danh mục
|
|
</th>
|
|
<th
|
|
className="cursor-pointer px-4 py-3 text-right font-semibold text-(--color-text-secondary) hover:text-(--color-primary)"
|
|
onClick={() => handleSort("unitsSold")}
|
|
>
|
|
Số lượng {sortIcon("unitsSold")}
|
|
</th>
|
|
<th
|
|
className="cursor-pointer px-4 py-3 text-right font-semibold text-(--color-text-secondary) hover:text-(--color-primary)"
|
|
onClick={() => handleSort("revenue")}
|
|
>
|
|
Doanh thu {sortIcon("revenue")}
|
|
</th>
|
|
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
|
Giá nhập
|
|
</th>
|
|
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
|
|
Giá bán
|
|
</th>
|
|
<th
|
|
className="cursor-pointer px-4 py-3 text-right font-semibold text-(--color-text-secondary) hover:text-(--color-primary)"
|
|
onClick={() => handleSort("profit")}
|
|
>
|
|
Lợi nhuận {sortIcon("profit")}
|
|
</th>
|
|
<th
|
|
className="cursor-pointer px-4 py-3 text-right font-semibold text-(--color-text-secondary) hover:text-(--color-primary)"
|
|
onClick={() => handleSort("profitMargin")}
|
|
>
|
|
Biên LN {sortIcon("profitMargin")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted.map((row, i) => (
|
|
<tr
|
|
key={row.productId}
|
|
className="border-b border-(--color-border-light) bg-(--color-bg-card) transition-colors hover:bg-(--color-accent-light)/30"
|
|
>
|
|
<td className="px-4 py-3 text-(--color-text-muted)">{i + 1}</td>
|
|
<td className="text-foreground px-4 py-3 font-medium">
|
|
{row.name}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="rounded-full bg-(--color-accent-light) px-2 py-0.5 text-xs text-(--color-primary)">
|
|
{categoryName(row.category)}
|
|
</span>
|
|
</td>
|
|
<td className="text-foreground px-4 py-3 text-right tabular-nums">
|
|
{row.unitsSold.toLocaleString()}
|
|
</td>
|
|
<td className="px-4 py-3 text-right font-medium text-(--color-primary) tabular-nums">
|
|
{formatCurrencyFull(row.revenue)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-(--color-text-muted) tabular-nums">
|
|
{formatCurrencyFull(row.costPrice)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-(--color-text-secondary) tabular-nums">
|
|
{formatCurrencyFull(row.sellingPrice)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right font-medium text-green-600 tabular-nums">
|
|
{formatCurrencyFull(row.profit)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<span
|
|
className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
|
|
row.profitMargin >= 70
|
|
? "bg-green-100 text-green-700"
|
|
: row.profitMargin >= 60
|
|
? "bg-yellow-100 text-yellow-700"
|
|
: "bg-red-100 text-red-600"
|
|
}`}
|
|
>
|
|
{row.profitMargin.toFixed(1)}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
|
|
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
|
|
day: "Theo ngày",
|
|
week: "Theo tuần",
|
|
month: "Theo tháng",
|
|
year: "Theo năm",
|
|
};
|
|
|
|
const CATEGORY_COLORS = [
|
|
"#6F4E37",
|
|
"#C8973A",
|
|
"#A0785A",
|
|
"#8B6914",
|
|
"#D4A96A",
|
|
"#4A3728",
|
|
"#F0D9A8",
|
|
"#A08060",
|
|
"#3D2B1F",
|
|
];
|
|
|
|
export default function AnalyticsPage() {
|
|
const [period, setPeriod] = useState<AnalyticsPeriod>("month");
|
|
const [activeChart, setActiveChart] = useState<"line" | "bar" | "pie">(
|
|
"line",
|
|
);
|
|
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
|
|
|
// Pick revenue data based on period
|
|
const revenueData = useMemo((): RevenueDataPoint[] => {
|
|
switch (period) {
|
|
case "day":
|
|
return MOCK_REVENUE_DAILY;
|
|
case "week":
|
|
return MOCK_REVENUE_WEEKLY;
|
|
case "year":
|
|
return MOCK_REVENUE_YEARLY;
|
|
default:
|
|
return MOCK_REVENUE_MONTHLY;
|
|
}
|
|
}, [period]);
|
|
|
|
// Split current vs previous half for bar comparison
|
|
const half = Math.floor(revenueData.length / 2);
|
|
const currentHalf = revenueData.slice(half);
|
|
const previousHalf = revenueData.slice(0, half);
|
|
// Align lengths
|
|
const barCurrent = currentHalf.slice(0, previousHalf.length);
|
|
const barPrevious = previousHalf.slice(0, barCurrent.length);
|
|
|
|
// Filtered product sales
|
|
const filteredSales = useMemo(() => {
|
|
if (categoryFilter === "all") return MOCK_PRODUCT_SALES;
|
|
return MOCK_PRODUCT_SALES.filter((p) => p.category === categoryFilter);
|
|
}, [categoryFilter]);
|
|
|
|
// Summary stats
|
|
const totalRevenue = revenueData.reduce((s, d) => s + d.revenue, 0);
|
|
const totalOrders = revenueData.reduce((s, d) => s + d.orders, 0);
|
|
const totalProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
|
|
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
|
|
|
// Comparison: current vs previous half
|
|
const curRevenue = barCurrent.reduce((s, d) => s + d.revenue, 0);
|
|
const prevRevenue = barPrevious.reduce((s, d) => s + d.revenue, 0);
|
|
const curOrders = barCurrent.reduce((s, d) => s + d.orders, 0);
|
|
const prevOrders = barPrevious.reduce((s, d) => s + d.orders, 0);
|
|
const curProfit = curRevenue * 0.65;
|
|
const prevProfit = prevRevenue * 0.65;
|
|
|
|
const revComp = calcChange(curRevenue, prevRevenue);
|
|
const ordComp = calcChange(curOrders, prevOrders);
|
|
const proComp = calcChange(curProfit, prevProfit);
|
|
|
|
// Pie: revenue by category
|
|
const pieData = useMemo(() => {
|
|
const byCategory: Record<string, number> = {};
|
|
MOCK_PRODUCT_SALES.forEach((p) => {
|
|
byCategory[p.category] = (byCategory[p.category] ?? 0) + p.revenue;
|
|
});
|
|
return Object.entries(byCategory)
|
|
.map(([catId, rev], i) => ({
|
|
label: MENU_CATEGORIES.find((c) => c.id === catId)?.name ?? catId,
|
|
value: rev,
|
|
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
|
|
}))
|
|
.sort((a, b) => b.value - a.value);
|
|
}, []);
|
|
|
|
// Top 5 products
|
|
const top5 = useMemo(
|
|
() => [...filteredSales].sort((a, b) => b.revenue - a.revenue).slice(0, 5),
|
|
[filteredSales],
|
|
);
|
|
|
|
const categories = MENU_CATEGORIES.filter((c) => c.id !== "all");
|
|
|
|
return (
|
|
<div className="bg-background min-h-screen">
|
|
{/* ── Header ── */}
|
|
<header className="sticky top-0 z-30 border-b border-(--color-border-light) bg-(--color-bg-header) shadow-sm">
|
|
<div className="mx-auto flex max-w-screen-2xl items-center gap-4 px-4 py-3">
|
|
<Link
|
|
href="/manager"
|
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-(--color-text-muted) transition-colors hover:bg-(--color-accent-light) hover:text-(--color-primary)"
|
|
>
|
|
<i className="fa-solid fa-arrow-left"></i>
|
|
</Link>
|
|
<div className="flex items-center gap-3">
|
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-(--color-accent-light) text-(--color-primary)">
|
|
<i className="fa-solid fa-chart-line"></i>
|
|
</span>
|
|
<div>
|
|
<h1 className="text-foreground text-lg leading-tight font-bold">
|
|
Thống kê & Phân tích tài chính
|
|
</h1>
|
|
<p className="text-xs text-(--color-text-muted)">
|
|
Financial Analytics Dashboard
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{/* Period selector */}
|
|
{(Object.keys(PERIOD_LABELS) as AnalyticsPeriod[]).map((p) => (
|
|
<button
|
|
key={p}
|
|
onClick={() => setPeriod(p)}
|
|
className={`hidden rounded-lg px-3 py-1.5 text-xs font-medium transition-colors sm:block ${
|
|
period === p
|
|
? "bg-(--color-primary) text-white"
|
|
: "bg-background text-(--color-text-muted) hover:bg-(--color-accent-light)"
|
|
}`}
|
|
>
|
|
{PERIOD_LABELS[p]}
|
|
</button>
|
|
))}
|
|
{/* Mobile period select */}
|
|
<select
|
|
title="Chọn kỳ"
|
|
name="period"
|
|
value={period}
|
|
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
|
className="text-foreground block rounded-lg border border-(--color-border) bg-(--color-bg-card) px-2 py-1.5 text-xs sm:hidden"
|
|
>
|
|
{(
|
|
Object.entries(PERIOD_LABELS) as [AnalyticsPeriod, string][]
|
|
).map(([k, v]) => (
|
|
<option key={k} value={k}>
|
|
{v}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto max-w-screen-2xl space-y-6 p-4 pb-10">
|
|
{/* ── Summary Cards ── */}
|
|
<section>
|
|
<h2 className="mb-3 text-sm font-semibold tracking-wider text-(--color-text-muted) uppercase">
|
|
Tổng quan
|
|
</h2>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
<SummaryCard
|
|
icon="fa-solid fa-sack-dollar"
|
|
title="Tổng doanh thu"
|
|
value={formatCurrency(totalRevenue)}
|
|
change={revComp.change}
|
|
changePercent={revComp.changePercent}
|
|
isPositive={revComp.isPositive}
|
|
subtitle={`${PERIOD_LABELS[period]}`}
|
|
/>
|
|
<SummaryCard
|
|
icon="fa-solid fa-receipt"
|
|
title="Số đơn hàng"
|
|
value={totalOrders.toLocaleString()}
|
|
change={ordComp.change}
|
|
changePercent={ordComp.changePercent}
|
|
isPositive={ordComp.isPositive}
|
|
subtitle="Tổng đơn trong kỳ"
|
|
/>
|
|
<SummaryCard
|
|
icon="fa-solid fa-circle-dollar-to-slot"
|
|
title="Tổng lợi nhuận"
|
|
value={formatCurrency(totalProfit)}
|
|
change={proComp.change}
|
|
changePercent={proComp.changePercent}
|
|
isPositive={proComp.isPositive}
|
|
subtitle="Ước tính từ dữ liệu bán hàng"
|
|
/>
|
|
<SummaryCard
|
|
icon="fa-solid fa-basket-shopping"
|
|
title="Giá trị đơn TB"
|
|
value={formatCurrency(avgOrderValue)}
|
|
change={0}
|
|
changePercent={0}
|
|
isPositive={true}
|
|
subtitle="Doanh thu / số đơn hàng"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Revenue Chart ── */}
|
|
<section className="bg-background rounded-2xl border border-(--color-border-light) p-5 shadow-sm">
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
<h2 className="text-foreground text-base font-semibold">
|
|
<i className="fa-solid fa-chart-area mr-2 text-(--color-primary)"></i>
|
|
Biểu đồ doanh thu
|
|
</h2>
|
|
<div className="flex gap-2">
|
|
{(["line", "bar", "pie"] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setActiveChart(t)}
|
|
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
activeChart === t
|
|
? "bg-(--color-primary) text-white"
|
|
: "bg-background text-(--color-text-muted) hover:bg-(--color-accent-light)"
|
|
}`}
|
|
>
|
|
<i
|
|
className={`fa-solid text-xs ${
|
|
t === "line"
|
|
? "fa-chart-line"
|
|
: t === "bar"
|
|
? "fa-chart-bar"
|
|
: "fa-chart-pie"
|
|
}`}
|
|
></i>
|
|
<span className="hidden sm:inline">
|
|
{t === "line" ? "Line" : t === "bar" ? "Bar" : "Pie"}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{activeChart === "line" && (
|
|
<>
|
|
<p className="mb-3 text-xs text-(--color-text-muted)">
|
|
Doanh thu theo thời gian — {PERIOD_LABELS[period]}
|
|
</p>
|
|
<LineChart data={revenueData} height={220} />
|
|
</>
|
|
)}
|
|
|
|
{activeChart === "bar" && (
|
|
<>
|
|
<p className="mb-3 text-xs text-(--color-text-muted)">
|
|
So sánh doanh thu nửa đầu và nửa sau kỳ hiện tại
|
|
</p>
|
|
<BarChart
|
|
current={barCurrent}
|
|
previous={barPrevious}
|
|
height={220}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{activeChart === "pie" && (
|
|
<>
|
|
<p className="mb-3 text-xs text-(--color-text-muted)">
|
|
Tỷ trọng doanh thu theo danh mục sản phẩm
|
|
</p>
|
|
<PieChart data={pieData} />
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── Top 5 Products ── */}
|
|
<section className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
<h2 className="text-foreground text-base font-semibold">
|
|
<i className="fa-solid fa-fire mr-2 text-orange-500"></i>
|
|
Top sản phẩm bán chạy
|
|
</h2>
|
|
{/* Category filter */}
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-(--color-text-muted)">
|
|
Danh mục:
|
|
</label>
|
|
<select
|
|
title="Danh mục"
|
|
value={categoryFilter}
|
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
|
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
|
|
>
|
|
<option value="all">Tất cả</option>
|
|
{categories.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top 5 bar visual */}
|
|
<div className="space-y-3">
|
|
{top5.map((p, i) => {
|
|
const maxRev = top5[0].revenue;
|
|
const pct = (p.revenue / maxRev) * 100;
|
|
return (
|
|
<div key={p.productId} className="group">
|
|
<div className="mb-1 flex items-center justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-(--color-accent-light) text-xs font-bold text-(--color-primary)">
|
|
{i + 1}
|
|
</span>
|
|
<span className="text-foreground truncate text-sm font-medium">
|
|
{p.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-3 text-xs">
|
|
<span className="text-(--color-text-muted) tabular-nums">
|
|
{p.unitsSold} ly
|
|
</span>
|
|
<span className="font-semibold text-(--color-primary) tabular-nums">
|
|
{formatCurrency(p.revenue)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="bg-background h-2 overflow-hidden rounded-full">
|
|
<div
|
|
className="h-full rounded-full bg-(--color-primary) transition-all duration-500"
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Full Product Table ── */}
|
|
<section className="rounded-2xl border border-(--color-border-light) bg-(--color-bg-card) p-5 shadow-sm">
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
<h2 className="text-foreground text-base font-semibold">
|
|
<i className="fa-solid fa-table text-foreground mr-2"></i>
|
|
Phân tích chi tiết sản phẩm
|
|
</h2>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-(--color-text-muted)">
|
|
Lọc danh mục:
|
|
</label>
|
|
<select
|
|
title="Danh mục"
|
|
value={categoryFilter}
|
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
|
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
|
|
>
|
|
<option value="all">Tất cả</option>
|
|
{categories.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="mb-3 text-xs text-(--color-text-muted)">
|
|
Click vào tiêu đề cột để sắp xếp. Hiển thị {filteredSales.length}{" "}
|
|
sản phẩm.
|
|
</p>
|
|
<ProductTable data={filteredSales} />
|
|
|
|
{/* Totals row */}
|
|
<div className="bg-background mt-4 flex flex-wrap gap-4 rounded-xl p-4 text-sm">
|
|
<div>
|
|
<span className="text-(--color-text-muted)">
|
|
Tổng doanh thu:{" "}
|
|
</span>
|
|
<span className="font-semibold text-(--color-primary)">
|
|
{formatCurrencyFull(
|
|
filteredSales.reduce((s, d) => s + d.revenue, 0),
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-(--color-text-muted)">
|
|
Tổng lợi nhuận:{" "}
|
|
</span>
|
|
<span className="font-semibold text-green-600">
|
|
{formatCurrencyFull(
|
|
filteredSales.reduce((s, d) => s + d.profit, 0),
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-(--color-text-muted)">
|
|
Tổng sản lượng:{" "}
|
|
</span>
|
|
<span className="text-foreground font-semibold">
|
|
{filteredSales
|
|
.reduce((s, d) => s + d.unitsSold, 0)
|
|
.toLocaleString()}{" "}
|
|
ly
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-(--color-text-muted)">
|
|
Biên LN trung bình:{" "}
|
|
</span>
|
|
<span className="font-semibold text-yellow-700">
|
|
{filteredSales.length > 0
|
|
? (
|
|
filteredSales.reduce((s, d) => s + d.profitMargin, 0) /
|
|
filteredSales.length
|
|
).toFixed(1)
|
|
: 0}
|
|
%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|