Files
frontend/app/(manager)/manager/analytics/page.tsx
T
Thanh Quy - wolf eca619b79a feat: Implement Atomic Design Phase 1 - Atoms Foundation
 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>
2026-04-03 19:40:07 +07:00

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