fix: manager login and menu management (#38)
Release package / release (push) Failing after 7m21s

Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com>
Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
2026-05-13 06:29:14 +00:00
parent edbf675997
commit ae8134fd64
39 changed files with 674 additions and 2791 deletions
+6 -1
View File
@@ -1,9 +1,14 @@
import { MainLayout } from "@/components/templates/main-layout";
import { ManagerProvider } from "@/lib/manager-context";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return <MainLayout>{children}</MainLayout>;
return (
<MainLayout>
<ManagerProvider>{children}</ManagerProvider>
</MainLayout>
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function LoginPasswordPage() {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, password, role: "manager" }),
body: JSON.stringify({ phone, password }),
});
if (res.ok) {
+35 -26
View File
@@ -3,10 +3,21 @@
import { SearchBar } from "@/components/molecules/search-bar";
import { CategorySidebar } from "@/components/organisms/navigation";
import { ProductGrid } from "@/components/organisms/product-grid";
import { MENU_CATEGORIES } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import { eateryClient } from "@/lib/apollo-clients";
import { allEateriesQuery } from "@/lib/types";
import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
const GET_EATERY_COUNT = gql`
{
allEateries {
id
}
}
`;
/**
* Main page — sidebar + product grid layout.
*
@@ -18,11 +29,30 @@ import { useEffect, useState } from "react";
* - Mobile (< 1024px): collapsed by default
*/
export default function Home() {
const { activeCategory, setActiveCategory } = useMenu();
const router = useRouter();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { data, loading, error } = useQuery<allEateriesQuery>(
GET_EATERY_COUNT,
{
client: eateryClient,
fetchPolicy: "no-cache",
},
);
useEffect(() => {
if (!loading && data) {
console.log(data);
const count = data.allEateries.length ?? 0;
if (count === 0) {
router.push("/manager-signup");
}
}
}, [data, loading, router]);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -32,40 +62,19 @@ export default function Home() {
return () => mq.removeEventListener("change", handler);
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSearchQuery("");
}, [activeCategory]);
const activeCategoryLabel =
MENU_CATEGORIES.find((c) => c.id === activeCategory)?.name ?? "All";
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div className="bg-background flex min-h-[calc(100vh-var(--spacing-header-height))] items-start">
{/* ── Sidebar ── */}
<CategorySidebar
isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((prev) => !prev)}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
/>
{/* ── Main content ── */}
<main className="min-w-0 flex-1 px-4 py-6 md:px-6 lg:px-8">
{/* ── Section heading + search bar ── */}
<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 */}
<SearchBar
value={searchQuery}
onChange={(q) => {
if (q && activeCategory !== "all") setActiveCategory("all");
setSearchQuery(q);
}}
onClear={() => setSearchQuery("")}
+70 -54
View File
@@ -5,6 +5,8 @@ import PaymentSummaryCard from "@/components/molecules/cards/PaymentSummaryCard"
import ReviewModal from "@/components/organisms/modals/ReviewModal";
import { useAuth } from "@/lib/auth-context";
import { useCart } from "@/lib/cart-context";
import { useManager } from "@/lib/manager-context";
import { MenuItemEntity } from "@/lib/types";
import { useState } from "react";
const formatPrice = (value: number) =>
@@ -20,6 +22,10 @@ export default function PaymentPage() {
setQuantity,
} = useCart();
const { user } = useAuth();
const { products } = useManager();
const findProduct = (id: string): MenuItemEntity =>
products.find((i) => i.id == id)!;
const [isReviewOpen, setIsReviewOpen] = useState(false);
const isCustomer = user?.role === "customer";
@@ -66,61 +72,71 @@ export default function PaymentPage() {
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.id}
className="border-t border-(--color-border-light)"
>
<td className="text-foreground px-4 py-3 font-medium">
{item.name}
</td>
<td className="px-4 py-3 font-semibold text-(--color-primary)">
{formatPrice(item.price)}
</td>
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
<p className="line-clamp-2">{item.description}</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => decreaseQty(item.id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Giảm số lượng ${item.name}`}
>
-
</button>
<input
type="number"
min={1}
value={item.quantity}
onChange={(e) =>
setQuantity(item.id, Number(e.target.value))
}
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
title="Nhập số lượng"
/>
<button
onClick={() => increaseQty(item.id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Tăng số lượng ${item.name}`}
>
+
</button>
</div>
</td>
<td className="px-4 py-3 text-right">
<Button
onClick={() => removeFromCart(item.id)}
variant="danger"
size="md"
style="payment"
aria-label={`Xóa ${item.name} khỏi giỏ hàng`}
{items.map(
({
productId: id,
priceAtTimeOfAdding: price,
quantity,
}) => {
const { name, description } = findProduct(id);
return (
<tr
key={id}
className="border-t border-(--color-border-light)"
>
Delete product
</Button>
</td>
</tr>
))}
<td className="text-foreground px-4 py-3 font-medium">
{name}
</td>
<td className="px-4 py-3 font-semibold text-(--color-primary)">
{formatPrice(price)}
</td>
<td className="max-w-70 px-4 py-3 text-(--color-text-muted)">
<p className="line-clamp-2">{description}</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => decreaseQty(id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Giảm số lượng ${name}`}
>
-
</button>
<input
type="number"
min={1}
value={quantity}
onChange={(e) =>
setQuantity(id, Number(e.target.value))
}
className="h-8 w-16 rounded-lg border border-(--color-border) bg-transparent text-center"
title="Nhập số lượng"
/>
<button
onClick={() => increaseQty(id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--color-border) hover:bg-(--color-border-light)"
aria-label={`Tăng số lượng ${name}`}
>
+
</button>
</div>
</td>
<td className="px-4 py-3 text-right">
<Button
onClick={() => removeFromCart(id)}
variant="danger"
size="md"
style="payment"
aria-label={`Xóa ${name} khỏi giỏ hàng`}
>
Delete product
</Button>
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>
-428
View File
@@ -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 -26
View File
@@ -1,9 +1,6 @@
"use client";
import {
CategoriesTab,
CombosTab,
MenuItemsTab,
ProductsTab,
} from "@/components/organisms/manager";
import { useAuth } from "@/lib/auth-context";
@@ -12,8 +9,7 @@ import Link from "next/link";
export default function ManagerPage() {
const { user, logout } = useAuth();
const { activeTab, setActiveTab, products, combos, categories } =
useManager();
const { activeTab, setActiveTab, products } = useManager();
const tabs = [
{
@@ -22,24 +18,6 @@ export default function ManagerPage() {
icon: "fa-solid fa-utensils",
count: products.length,
},
{
id: "combos" as const,
label: "Combo",
icon: "fa-solid fa-layer-group",
count: combos.length,
},
{
id: "categories" as const,
label: "Categories",
icon: "fa-solid fa-tags",
count: categories.length,
},
{
id: "menu-items" as const,
label: "Menu",
icon: "fa-solid fa-bowl-food",
count: null,
},
];
return (
@@ -209,9 +187,6 @@ export default function ManagerPage() {
<main className="flex-1 p-5 md:p-8">
{activeTab === "products" && <ProductsTab />}
{activeTab === "combos" && <CombosTab />}
{activeTab === "categories" && <CategoriesTab />}
{activeTab === "menu-items" && <MenuItemsTab />}
</main>
</div>
</div>
+1 -4
View File
@@ -2,7 +2,6 @@
import { AuthProvider } from "@/lib/auth-context";
import { CartProvider } from "@/lib/cart-context";
import { MenuProvider } from "@/lib/menu-context";
/**
* Client-side providers wrapper.
@@ -12,9 +11,7 @@ import { MenuProvider } from "@/lib/menu-context";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<MenuProvider>
<CartProvider>{children}</CartProvider>
</MenuProvider>
<CartProvider>{children}</CartProvider>
</AuthProvider>
);
}