chore: update
This commit is contained in:
@@ -4,7 +4,6 @@ import { SearchBar } from "@/components/molecules/search-bar";
|
|||||||
import { CategorySidebar } from "@/components/organisms/navigation";
|
import { CategorySidebar } from "@/components/organisms/navigation";
|
||||||
import { ProductGrid } from "@/components/organisms/product-grid";
|
import { ProductGrid } from "@/components/organisms/product-grid";
|
||||||
import { eateryClient } from "@/lib/apollo-clients";
|
import { eateryClient } from "@/lib/apollo-clients";
|
||||||
import { MENU_CATEGORIES } from "@/lib/constants";
|
|
||||||
import { useMenu } from "@/lib/menu-context";
|
import { useMenu } from "@/lib/menu-context";
|
||||||
import { allEateriesQuery } from "@/lib/types";
|
import { allEateriesQuery } from "@/lib/types";
|
||||||
import { gql } from "@apollo/client";
|
import { gql } from "@apollo/client";
|
||||||
@@ -63,9 +62,6 @@ export default function Home() {
|
|||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
}, [activeCategory]);
|
}, [activeCategory]);
|
||||||
|
|
||||||
const activeCategoryLabel =
|
|
||||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "All";
|
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (loading) return <div>Loading...</div>;
|
||||||
if (error) return <div>Error: {error.message}</div>;
|
if (error) return <div>Error: {error.message}</div>;
|
||||||
|
|
||||||
@@ -83,13 +79,6 @@ export default function Home() {
|
|||||||
<main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
|
<main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
|
||||||
{/* ── Section heading + search bar ── */}
|
{/* ── Section heading + search bar ── */}
|
||||||
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
<div className="mb-5 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||||
{/* Title + count */}
|
|
||||||
<div className="shrink-0">
|
|
||||||
<h1 className="text-foreground text-xl font-bold">
|
|
||||||
{activeCategoryLabel}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
<SearchBar
|
<SearchBar
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { formatCurrency } from "@/lib/analytics-utils";
|
|
||||||
import type { RevenueDataPoint } from "@/lib/types";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface BarChartProps {
|
|
||||||
current: RevenueDataPoint[];
|
|
||||||
previous: RevenueDataPoint[];
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure-SVG grouped bar chart comparing current vs previous period revenue.
|
|
||||||
* Hover bars show tooltip with label, revenue, and order count.
|
|
||||||
* Tooltip auto-flips above/below bar top to stay inside the viewBox.
|
|
||||||
*/
|
|
||||||
export 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,
|
|
||||||
padR = 16,
|
|
||||||
padT = 16,
|
|
||||||
padB = 40;
|
|
||||||
const chartW = W - padL - padR;
|
|
||||||
const chartH = H - padT - padB;
|
|
||||||
|
|
||||||
const n = current.length;
|
|
||||||
const maxVal =
|
|
||||||
Math.max(
|
|
||||||
...current.map((d) => d.revenue),
|
|
||||||
...previous.map((d) => d.revenue),
|
|
||||||
) || 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) => ({
|
|
||||||
val: (maxVal / yTicks) * (yTicks - i),
|
|
||||||
y: padT + (i / yTicks) * chartH,
|
|
||||||
}));
|
|
||||||
|
|
||||||
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)}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{current.map((d, i) => {
|
|
||||||
const groupX = padL + i * groupW;
|
|
||||||
const curH = (d.revenue / maxVal) * chartH;
|
|
||||||
const prevH = ((previous[i]?.revenue ?? 0) / maxVal) * chartH;
|
|
||||||
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}>
|
|
||||||
<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 })}
|
|
||||||
/>
|
|
||||||
<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 })}
|
|
||||||
/>
|
|
||||||
{i % step === 0 && (
|
|
||||||
<text
|
|
||||||
x={groupX + groupW / 2}
|
|
||||||
y={H - 8}
|
|
||||||
textAnchor="middle"
|
|
||||||
fontSize="10"
|
|
||||||
fill="#A08060"
|
|
||||||
>
|
|
||||||
{d.label}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{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,
|
|
||||||
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 tipY = Math.min(
|
|
||||||
Math.max(aboveY >= padT ? aboveY : barTopY + 8, padT),
|
|
||||||
padT + chartH - tipH,
|
|
||||||
);
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { formatCurrency } from "@/lib/analytics-utils";
|
|
||||||
import type { RevenueDataPoint } from "@/lib/types";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface LineChartProps {
|
|
||||||
data: RevenueDataPoint[];
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure-SVG interactive line chart for revenue over time.
|
|
||||||
* Hover dots show tooltip with label, revenue, and order count.
|
|
||||||
* Tooltip auto-flips above/below the dot to stay inside the viewBox.
|
|
||||||
*/
|
|
||||||
export function LineChart({ data, height = 200 }: LineChartProps) {
|
|
||||||
const [hovered, setHovered] = useState<number | null>(null);
|
|
||||||
const W = 800;
|
|
||||||
const H = height;
|
|
||||||
const padL = 56,
|
|
||||||
padR = 16,
|
|
||||||
padT = 16,
|
|
||||||
padB = 40;
|
|
||||||
const chartW = W - padL - padR;
|
|
||||||
const chartH = H - padT - padB;
|
|
||||||
|
|
||||||
const maxRev = Math.max(...data.map((d) => d.revenue));
|
|
||||||
const range = maxRev || 1;
|
|
||||||
|
|
||||||
const points = data.map((d, i) => ({
|
|
||||||
x: padL + (i / (data.length - 1)) * chartW,
|
|
||||||
y: padT + chartH - (d.revenue / 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(" ");
|
|
||||||
|
|
||||||
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`;
|
|
||||||
|
|
||||||
const yTicks = 5;
|
|
||||||
const gridLines = Array.from({ length: yTicks + 1 }, (_, i) => ({
|
|
||||||
val: (range / yTicks) * (yTicks - i),
|
|
||||||
y: padT + (i / yTicks) * chartH,
|
|
||||||
}));
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<path d={areaD} fill="url(#areaGrad)" />
|
|
||||||
<path
|
|
||||||
d={pathD}
|
|
||||||
fill="none"
|
|
||||||
stroke="#6F4E37"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{points.map((p) => (
|
|
||||||
<circle
|
|
||||||
key={p.index}
|
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{hovered !== null &&
|
|
||||||
(() => {
|
|
||||||
const p = points[hovered];
|
|
||||||
const tipW = 120,
|
|
||||||
tipH = 48;
|
|
||||||
const tipX = Math.min(
|
|
||||||
Math.max(p.x - tipW / 2, padL),
|
|
||||||
W - padR - tipW,
|
|
||||||
);
|
|
||||||
const aboveY = p.y - tipH - 10;
|
|
||||||
const tipY = Math.min(
|
|
||||||
Math.max(aboveY >= padT ? aboveY : p.y + 10, padT),
|
|
||||||
padT + chartH - tipH,
|
|
||||||
);
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
|
|
||||||
export interface PieSlice {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PieChartProps {
|
|
||||||
data: PieSlice[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure-SVG interactive pie chart.
|
|
||||||
* Hover a slice or legend item to highlight it and show its percentage.
|
|
||||||
*/
|
|
||||||
export 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: PieSlice, 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`;
|
|
||||||
return {
|
|
||||||
...d,
|
|
||||||
pathD,
|
|
||||||
labelX: CX + R * 0.65 * Math.cos(midAngle),
|
|
||||||
labelY: CY + R * 0.65 * Math.sin(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: "opacity 200ms" }}
|
|
||||||
onMouseEnter={() => setHovered(s.index)}
|
|
||||||
opacity={hovered !== null && hovered !== s.index ? 0.65 : 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { formatCurrencyFull } from "@/lib/analytics-utils";
|
|
||||||
import { MENU_CATEGORIES } from "@/lib/constants";
|
|
||||||
import type { ProductSalesStats } from "@/lib/types";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
|
|
||||||
interface ProductTableProps {
|
|
||||||
data: ProductSalesStats[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryName = (id: string) =>
|
|
||||||
MENU_CATEGORIES.find((c) => c.id === id)?.name ?? id;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sortable product sales table.
|
|
||||||
* Click column headers to sort ascending/descending.
|
|
||||||
*/
|
|
||||||
export function ProductTable({ data }: ProductTableProps) {
|
|
||||||
const [sortKey, setSortKey] = useState<keyof ProductSalesStats>("revenue");
|
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
|
||||||
|
|
||||||
const sorted = useMemo(
|
|
||||||
() =>
|
|
||||||
[...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)"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const SortTh = ({
|
|
||||||
col,
|
|
||||||
label,
|
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
col: keyof ProductSalesStats;
|
|
||||||
label: string;
|
|
||||||
className?: string;
|
|
||||||
}) => (
|
|
||||||
<th
|
|
||||||
className={`cursor-pointer px-4 py-3 font-semibold text-(--color-text-secondary) hover:text-(--color-primary) ${className}`}
|
|
||||||
onClick={() => handleSort(col)}
|
|
||||||
>
|
|
||||||
{label} {sortIcon(col)}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
|
|
||||||
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>
|
|
||||||
<SortTh col="unitsSold" label="Số lượng" className="text-right" />
|
|
||||||
<SortTh col="revenue" label="Doanh thu" className="text-right" />
|
|
||||||
<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>
|
|
||||||
<SortTh col="profit" label="Lợi nhuận" className="text-right" />
|
|
||||||
<SortTh col="profitMargin" label="Biên LN" className="text-right" />
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { formatCurrency } from "@/lib/analytics-utils";
|
|
||||||
|
|
||||||
export interface SummaryCardProps {
|
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
value: string;
|
|
||||||
change: number;
|
|
||||||
changePercent: number;
|
|
||||||
isPositive: boolean;
|
|
||||||
subtitle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Summary metric card with period-over-period comparison indicator.
|
|
||||||
* Used in the Financial Analytics dashboard header row.
|
|
||||||
*/
|
|
||||||
export 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 text-xs ${
|
|
||||||
isPositive ? "fa-arrow-trend-up" : "fa-arrow-trend-down"
|
|
||||||
}`}
|
|
||||||
></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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export { BarChart } from "./BarChart";
|
|
||||||
export { LineChart } from "./LineChart";
|
|
||||||
export { PieChart } from "./PieChart";
|
|
||||||
export type { PieSlice } from "./PieChart";
|
|
||||||
export { ProductTable } from "./ProductTable";
|
|
||||||
export { SummaryCard } from "./SummaryCard";
|
|
||||||
export type { SummaryCardProps } from "./SummaryCard";
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { ProductCard } from "@/components/molecules/cards";
|
import { ProductCard } from "@/components/molecules/cards";
|
||||||
import { useCart } from "@/lib/cart-context";
|
import { useCart } from "@/lib/cart-context";
|
||||||
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
|
import { MOCK_PRODUCTS } from "@/lib/constants";
|
||||||
import { useMenu } from "@/lib/menu-context";
|
|
||||||
|
|
||||||
import type { ProductGridProps } from "./ProductGrid.types";
|
import type { ProductGridProps } from "./ProductGrid.types";
|
||||||
|
|
||||||
@@ -11,61 +10,30 @@ export default function ProductGrid({
|
|||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
isSidebarOpen = false,
|
isSidebarOpen = false,
|
||||||
}: ProductGridProps) {
|
}: ProductGridProps) {
|
||||||
const { activeCategory, setActiveCategory } = useMenu();
|
|
||||||
const { addToCart } = useCart();
|
const { addToCart } = useCart();
|
||||||
|
|
||||||
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
|
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
|
||||||
const isAvailable = p.available !== false;
|
const isAvailable = p.available !== false;
|
||||||
const matchesCategory =
|
|
||||||
activeCategory === "all" || p.category === activeCategory;
|
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
searchQuery.trim() === "" ||
|
searchQuery.trim() === "" ||
|
||||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
|
p.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
return isAvailable && matchesCategory && matchesSearch;
|
return isAvailable && matchesSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeCategoryLabel =
|
|
||||||
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "Tất cả";
|
|
||||||
|
|
||||||
const gridCols = isSidebarOpen
|
const gridCols = isSidebarOpen
|
||||||
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
? "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||||
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
|
: "grid-cols-1 min-[480px]:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Mobile category menu — visible only on < md ── */}
|
|
||||||
<div className="bg-background sticky top-18 z-50 -mx-4 mb-4 overflow-x-auto px-4 pt-2 md:hidden">
|
|
||||||
<div className="flex items-center gap-1.5 pb-1">
|
|
||||||
{MENU_CATEGORIES.map((cat) => {
|
|
||||||
const isActive = activeCategory === cat.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={cat.id}
|
|
||||||
onClick={() => setActiveCategory(cat.id)}
|
|
||||||
className={`flex shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border-none px-3 py-2 text-sm font-medium whitespace-nowrap transition-all duration-150 ${
|
|
||||||
isActive
|
|
||||||
? "bg-(--color-primary) text-white shadow-sm"
|
|
||||||
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
|
|
||||||
} `}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className={` ${cat.icon} shrink-0 text-sm ${isActive ? "text-white" : "text-(--color-primary)"} `}
|
|
||||||
></i>
|
|
||||||
<span>{cat.name}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Product grid ── */}
|
{/* ── Product grid ── */}
|
||||||
{filteredProducts.length > 0 ? (
|
{filteredProducts.length > 0 ? (
|
||||||
<div className={`grid gap-4 ${gridCols}`}>
|
<div className={`grid gap-4 ${gridCols}`}>
|
||||||
{filteredProducts.map((product) => (
|
{filteredProducts.map((product) => (
|
||||||
<ProductCard
|
<ProductCard
|
||||||
key={product.id}
|
key={product.id}
|
||||||
image={product.image}
|
image={product.imageUrl}
|
||||||
imageAlt={product.name}
|
imageAlt={product.name}
|
||||||
productName={product.name}
|
productName={product.name}
|
||||||
price={product.price}
|
price={product.price}
|
||||||
|
|||||||
+27
-30
@@ -1,40 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Product } from "@/lib/types";
|
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export interface CartItem {
|
import { CartItemEntity, MenuItemEntity } from "./types";
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
price: number;
|
|
||||||
quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CartContextValue {
|
interface CartContextValue {
|
||||||
items: CartItem[];
|
items: CartItemEntity[];
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPrice: number;
|
totalPrice: number;
|
||||||
addToCart: (product: Product) => void;
|
addToCart: (product: MenuItemEntity) => void;
|
||||||
increaseQty: (id: number) => void;
|
increaseQty: (id: string) => void;
|
||||||
decreaseQty: (id: number) => void;
|
decreaseQty: (id: string) => void;
|
||||||
removeFromCart: (id: number) => void;
|
removeFromCart: (id: string) => void;
|
||||||
setQuantity: (id: number, quantity: number) => void;
|
setQuantity: (id: string, quantity: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "coffee-shop-cart";
|
const STORAGE_KEY = "coffee-shop-cart";
|
||||||
const CartContext = createContext<CartContextValue | null>(null);
|
const CartContext = createContext<CartContextValue | null>(null);
|
||||||
|
|
||||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [items, setItems] = useState<CartItem[]>([]);
|
const [items, setItems] = useState<CartItemEntity[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const parsed = JSON.parse(raw) as CartItem[];
|
const parsed = JSON.parse(raw) as CartItemEntity[];
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
setItems(parsed.filter((i) => i && i.id && i.quantity > 0));
|
setItems(parsed.filter((i) => i && i.productId && i.quantity > 0));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
@@ -45,17 +38,17 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const addToCart = (product: Product) => {
|
const addToCart = (product: MenuItemEntity) => {
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const index = prev.findIndex((i) => i.id === product.id);
|
const index = prev.findIndex((i) => i.productId === product.id);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: product.id,
|
productId: product.id!,
|
||||||
name: product.name,
|
name: product.name,
|
||||||
description: product.description,
|
description: product.description,
|
||||||
price: product.price,
|
priceAtTimeOfAdding: product.price,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -67,19 +60,19 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const increaseQty = (id: number) => {
|
const increaseQty = (id: string) => {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
|
item.productId === id ? { ...item, quantity: item.quantity + 1 } : item,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const decreaseQty = (id: number) => {
|
const decreaseQty = (id: string) => {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev
|
prev
|
||||||
.map((item) =>
|
.map((item) =>
|
||||||
item.id === id
|
item.productId === id
|
||||||
? { ...item, quantity: Math.max(0, item.quantity - 1) }
|
? { ...item, quantity: Math.max(0, item.quantity - 1) }
|
||||||
: item,
|
: item,
|
||||||
)
|
)
|
||||||
@@ -87,11 +80,11 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromCart = (id: number) => {
|
const removeFromCart = (id: string) => {
|
||||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
setItems((prev) => prev.filter((item) => item.productId !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQuantity = (id: number, quantity: number) => {
|
const setQuantity = (id: string, quantity: number) => {
|
||||||
const safeQty = Number.isFinite(quantity)
|
const safeQty = Number.isFinite(quantity)
|
||||||
? Math.max(0, Math.floor(quantity))
|
? Math.max(0, Math.floor(quantity))
|
||||||
: 0;
|
: 0;
|
||||||
@@ -102,7 +95,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === id ? { ...item, quantity: safeQty } : item,
|
item.productId === id ? { ...item, quantity: safeQty } : item,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -113,7 +106,11 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const totalPrice = useMemo(
|
const totalPrice = useMemo(
|
||||||
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
() =>
|
||||||
|
items.reduce(
|
||||||
|
(sum, item) => sum + item.priceAtTimeOfAdding * item.quantity,
|
||||||
|
0,
|
||||||
|
),
|
||||||
[items],
|
[items],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -421,3 +421,10 @@ function generateMockShifts(): ShiftSlot[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
|
export const MOCK_SHIFT_SLOTS: ShiftSlot[] = generateMockShifts();
|
||||||
|
|
||||||
|
export const DEPARTMENTS: Department[] = [
|
||||||
|
{ id: "bar", name: "Bar Staff", icon: "fa-solid fa-martini-glass-citrus" },
|
||||||
|
{ id: "kitchen", name: "Kitchen", icon: "fa-solid fa-kitchen-set" },
|
||||||
|
{ id: "cashier", name: "Cashier", icon: "fa-solid fa-cash-register" },
|
||||||
|
{ id: "janitor", name: "Janitor", icon: "fa-solid fa-broom" },
|
||||||
|
];
|
||||||
@@ -146,3 +146,14 @@ export interface updateMenuItemMutation {
|
|||||||
export interface deleteMenuItemMutation {
|
export interface deleteMenuItemMutation {
|
||||||
deleteMenuItem: boolean;
|
deleteMenuItem: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CartItemEntity {
|
||||||
|
productId: string;
|
||||||
|
quantity: number;
|
||||||
|
priceAtTimeOfAdding: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartEntity {
|
||||||
|
Id: string;
|
||||||
|
items: CartItemEntity[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user