Files
frontend/app/(manager)/manager/analytics/page.tsx
T
Thanh Quy - wolf 40b673b9bb
Release package / release (pull_request) Successful in 2m32s
Truy van menu nha hang
2026-05-05 21:36:48 +07:00

434 lines
16 KiB
TypeScript

"use client";
import {
BarChart,
LineChart,
PieChart,
ProductTable,
SummaryCard,
} from "@/components/organisms/analytics";
import type { PieSlice } from "@/components/organisms/analytics";
import {
calcChange,
formatCurrency,
formatCurrencyFull,
} from "@/lib/analytics-utils";
import {
MENU_CATEGORIES,
MOCK_PRODUCT_SALES,
MOCK_REVENUE_DAILY,
MOCK_REVENUE_MONTHLY,
MOCK_REVENUE_WEEKLY,
MOCK_REVENUE_YEARLY,
} from "@/lib/constants";
import type { AnalyticsPeriod, RevenueDataPoint } from "@/lib/types";
import Link from "next/link";
import { useMemo, useState } from "react";
// ─── Constants ────────────────────────────────────────────────────────────────
const PERIOD_LABELS: Record<AnalyticsPeriod, string> = {
day: "By day",
week: "By week",
month: "By month",
year: "By year",
};
const CATEGORY_COLORS = [
"#6F4E37",
"#C8973A",
"#A0785A",
"#8B6914",
"#D4A96A",
"#4A3728",
"#F0D9A8",
"#A08060",
"#3D2B1F",
];
const REVENUE_MAP: Record<AnalyticsPeriod, RevenueDataPoint[]> = {
day: MOCK_REVENUE_DAILY,
week: MOCK_REVENUE_WEEKLY,
month: MOCK_REVENUE_MONTHLY,
year: MOCK_REVENUE_YEARLY,
};
const CHART_TYPES = ["line", "bar", "pie"] as const;
type ChartType = (typeof CHART_TYPES)[number];
const CHART_META: Record<ChartType, { icon: string; label: string }> = {
line: { icon: "fa-chart-line", label: "Line" },
bar: { icon: "fa-chart-bar", label: "Bar" },
pie: { icon: "fa-chart-pie", label: "Pie" },
};
// ─── Category filter select ───────────────────────────────────────────────────
function CategorySelect({
value,
onChange,
label = "Category:",
}: {
value: string;
onChange: (v: string) => void;
label?: string;
}) {
const categories = MENU_CATEGORIES.filter((c) => c.id !== "all");
return (
<div className="flex items-center gap-2">
<label className="text-xs text-(--color-text-muted)">{label}</label>
<select
title="Category"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-background text-foreground rounded-lg border border-(--color-border) px-2 py-1.5 text-xs"
>
<option value="all">All</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function AnalyticsPage() {
const [period, setPeriod] = useState<AnalyticsPeriod>("month");
const [activeChart, setActiveChart] = useState<ChartType>("line");
const [categoryFilter, setCategoryFilter] = useState("all");
// Revenue data for selected period
const revenueData = REVENUE_MAP[period];
// Split into halves for bar comparison
const half = Math.floor(revenueData.length / 2);
const barCurrent = revenueData.slice(half);
const barPrevious = revenueData.slice(0, half).slice(0, barCurrent.length);
// Filtered product sales
const filteredSales = useMemo(
() =>
categoryFilter === "all"
? MOCK_PRODUCT_SALES
: 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;
// Period-over-period comparisons
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 revComp = calcChange(curRevenue, prevRevenue);
const ordComp = calcChange(curOrders, prevOrders);
const proComp = calcChange(curRevenue * 0.65, prevRevenue * 0.65);
// Pie data: revenue by category
const pieData = useMemo((): PieSlice[] => {
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],
);
// Totals for summary row
const filteredRevenue = filteredSales.reduce((s, d) => s + d.revenue, 0);
const filteredProfit = filteredSales.reduce((s, d) => s + d.profit, 0);
const filteredUnits = filteredSales.reduce((s, d) => s + d.unitsSold, 0);
const avgMargin =
filteredSales.length > 0
? filteredSales.reduce((s, d) => s + d.profitMargin, 0) /
filteredSales.length
: 0;
return (
<div className="bg-background min-h-screen">
{/* ── Page 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">
Financial Statistics & Analytics
</h1>
<p className="text-xs text-(--color-text-muted)">
Financial Analytics Dashboard
</p>
</div>
</div>
{/* Period selector */}
<div className="ml-auto flex items-center gap-2">
{(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>
))}
<select
title="Select 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">
Overview
</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="Total Revenue"
value={formatCurrency(totalRevenue)}
subtitle={PERIOD_LABELS[period]}
change={revComp.change}
changePercent={revComp.changePercent}
isPositive={revComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-receipt"
title="Total Orders"
value={totalOrders.toLocaleString()}
subtitle="Total orders in period"
change={ordComp.change}
changePercent={ordComp.changePercent}
isPositive={ordComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-circle-dollar-to-slot"
title="Total Profit"
value={formatCurrency(totalProfit)}
subtitle="Estimated from sales data"
change={proComp.change}
changePercent={proComp.changePercent}
isPositive={proComp.isPositive}
/>
<SummaryCard
icon="fa-solid fa-basket-shopping"
title="Avg. Order Value"
value={formatCurrency(avgOrderValue)}
subtitle="Revenue / number of orders"
change={0}
changePercent={0}
isPositive={true}
/>
</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>
Revenue Chart
</h2>
<div className="flex gap-2">
{CHART_TYPES.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 ${CHART_META[t].icon}`}></i>
<span className="hidden sm:inline">
{CHART_META[t].label}
</span>
</button>
))}
</div>
</div>
{activeChart === "line" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Revenue over time {PERIOD_LABELS[period]}
</p>
<LineChart data={revenueData} height={220} />
</>
)}
{activeChart === "bar" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Comparing revenue of the first and second half of the current period
</p>
<BarChart
current={barCurrent}
previous={barPrevious}
height={220}
/>
</>
)}
{activeChart === "pie" && (
<>
<p className="mb-3 text-xs text-(--color-text-muted)">
Revenue share by product category
</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 best-selling products
</h2>
<CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
/>
</div>
<div className="space-y-3">
{top5.map((p, i) => {
const pct = (p.revenue / top5[0].revenue) * 100;
return (
<div key={p.productId}>
<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} cups
</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>
Detailed product analytics
</h2>
<CategorySelect
value={categoryFilter}
onChange={setCategoryFilter}
label="Filter category:"
/>
</div>
<p className="mb-3 text-xs text-(--color-text-muted)">
Click column headers to sort. Showing {filteredSales.length}{" "}
products.
</p>
<ProductTable data={filteredSales} />
{/* Summary 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)">
Total revenue:{" "}
</span>
<span className="font-semibold text-(--color-primary)">
{formatCurrencyFull(filteredRevenue)}
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Total profit:{" "}
</span>
<span className="font-semibold text-green-600">
{formatCurrencyFull(filteredProfit)}
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Total units:{" "}
</span>
<span className="text-foreground font-semibold">
{filteredUnits.toLocaleString()} cups
</span>
</div>
<div>
<span className="text-(--color-text-muted)">
Avg. profit margin:{" "}
</span>
<span className="font-semibold text-yellow-700">
{avgMargin.toFixed(1)}%
</span>
</div>
</div>
</section>
</main>
</div>
);
}