Compare commits

...

2 Commits

Author SHA1 Message Date
TakahashiNguyen ae8134fd64 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
2026-05-13 06:29:14 +00:00
gitea-actions edbf675997 chore: release [ci skip] 2026-05-05 14:49:39 +00:00
47 changed files with 1107 additions and 3088 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/muhmdraouf/devcontainers-features/alpine-apk:0": {
"version": "0.0.1",
"resolved": "ghcr.io/muhmdraouf/devcontainers-features/alpine-apk@sha256:3f5010a1880699fad8f65f71002e56bc5cf57c47c63da36c0efea85958ff9044",
"integrity": "sha256:3f5010a1880699fad8f65f71002e56bc5cf57c47c63da36c0efea85958ff9044"
}
}
}
+4 -1
View File
@@ -13,7 +13,10 @@
},
"customizations": {
"vscode": {
"extensions": ["esbenp.prettier-vscode"]
"extensions": [
"esbenp.prettier-vscode",
"anthropic.claude-code"
]
}
},
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- uses: pnpm/action-setup@v3
with:
version: latest
version: 10.9.0
run_install: false
- name: Get pnpm store directory
+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>
);
}
+8 -3
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) {
@@ -68,7 +68,10 @@ export default function LoginPasswordPage() {
setErrors({ password: "", general: msg });
}
} catch {
setErrors({ password: "", general: "Unable to connect, please try again" });
setErrors({
password: "",
general: "Unable to connect, please try again",
});
} finally {
setIsLoading(false);
}
@@ -134,7 +137,9 @@ export default function LoginPasswordPage() {
className="absolute top-1/2 right-4 -translate-y-1/2 text-(--color-text-muted) transition-colors hover:text-(--color-primary)"
aria-label={showPassword ? "Hide password" : "Show password"}
>
<i className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}></i>
<i
className={`fa-solid ${showPassword ? "fa-eye-slash" : "fa-eye"}`}
></i>
</button>
</div>
{errors.password && (
+136 -30
View File
@@ -14,13 +14,24 @@ export default function ManagerSignupPage() {
const [pageState, setPageState] = useState<PageState>("checking");
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({ name: "", phone: "", password: "", eateryName: "" });
const [errors, setErrors] = useState({ name: "", phone: "", password: "", eateryName: "", submit: "" });
const [form, setForm] = useState({
name: "",
phone: "",
password: "",
eateryName: "",
});
const [errors, setErrors] = useState({
name: "",
phone: "",
password: "",
eateryName: "",
submit: "",
});
useEffect(() => {
fetch("/api/manager/signup")
.then((res) => {
setPageState("available")
setPageState("available");
// if (res.ok) ;
// else if (res.status === 403) setPageState("closed");
// else setPageState("error");
@@ -28,21 +39,32 @@ export default function ManagerSignupPage() {
.catch(() => setPageState("error"));
}, []);
const validatePhone = (phone: string) => /^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
const validatePhone = (phone: string) =>
/^(0[3|5|7|8|9])[0-9]{8}$/.test(phone);
const handleChange = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [field]: e.target.value });
setErrors({ ...errors, [field]: "", submit: "" });
};
const handleChange =
(field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [field]: e.target.value });
setErrors({ ...errors, [field]: "", submit: "" });
};
const validate = () => {
const next = { name: "", phone: "", password: "", eateryName: "", submit: "" };
const next = {
name: "",
phone: "",
password: "",
eateryName: "",
submit: "",
};
if (!form.name.trim()) next.name = "Please enter your full name";
if (!form.phone.trim()) next.phone = "Please enter your phone number";
else if (!validatePhone(form.phone)) next.phone = "Invalid phone number (e.g. 0987654321)";
else if (!validatePhone(form.phone))
next.phone = "Invalid phone number (e.g. 0987654321)";
if (!form.password.trim()) next.password = "Please enter your password";
else if (form.password.length < 6) next.password = "Password must be at least 6 characters";
if (!form.eateryName.trim()) next.eateryName = "Please enter the restaurant name";
else if (form.password.length < 6)
next.password = "Password must be at least 6 characters";
if (!form.eateryName.trim())
next.eateryName = "Please enter the restaurant name";
setErrors(next);
return !next.name && !next.phone && !next.password && !next.eateryName;
};
@@ -67,7 +89,10 @@ export default function ManagerSignupPage() {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
setErrors({ ...errors, submit: errorMap[errorCode] ?? "An error occurred, please try again" });
setErrors({
...errors,
submit: errorMap[errorCode] ?? "An error occurred, please try again",
});
}
} catch {
setErrors({ ...errors, submit: "Unable to connect, please try again" });
@@ -82,10 +107,21 @@ export default function ManagerSignupPage() {
{/* Logo */}
<div className="mb-8 flex flex-col items-center">
<div className="relative mb-4 h-20 w-20">
<Image src={SHOP_INFO.logo} alt={SHOP_INFO.name} fill className="object-contain" sizes="80px" priority />
<Image
src={SHOP_INFO.logo}
alt={SHOP_INFO.name}
fill
className="object-contain"
sizes="80px"
priority
/>
</div>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">{SHOP_INFO.name}</h1>
<p className="text-sm text-(--color-text-muted)">Create a manager account</p>
<h1 className="mb-1 text-2xl font-bold text-(--color-primary-dark)">
{SHOP_INFO.name}
</h1>
<p className="text-sm text-(--color-text-muted)">
Create a manager account
</p>
</div>
{/* Checking */}
@@ -103,8 +139,13 @@ export default function ManagerSignupPage() {
<i className="fa-solid fa-lock text-2xl text-red-500"></i>
</div>
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Registration closed</h2>
<p className="text-sm text-(--color-text-muted)">The system already has a restaurant. Registration is no longer available.</p>
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">
Registration closed
</h2>
<p className="text-sm text-(--color-text-muted)">
The system already has a restaurant. Registration is no longer
available.
</p>
</div>
<Link
href="/login"
@@ -122,13 +163,36 @@ export default function ManagerSignupPage() {
<i className="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
</div>
<div className="text-center">
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">Không thể kết nối</h2>
<p className="text-sm text-(--color-text-muted)">Không thể kiểm tra trạng thái đăng . Vui lòng thử lại.</p>
<h2 className="mb-2 text-lg font-semibold text-(--color-text-primary)">
Không thể kết nối
</h2>
<p className="text-sm text-(--color-text-muted)">
Không thể kiểm tra trạng thái đăng . Vui lòng thử lại.
</p>
</div>
<Button variant="primaryNoBorder" style="login" size="lg" onClick={() => { setPageState("checking"); fetch("/api/manager/signup").then((r) => { if (r.ok) setPageState("available"); else if (r.status === 403) setPageState("closed"); else setPageState("error"); }).catch(() => setPageState("error")); }}>
<Button
variant="primaryNoBorder"
style="login"
size="lg"
onClick={() => {
setPageState("checking");
fetch("/api/manager/signup")
.then((r) => {
if (r.ok) setPageState("available");
else if (r.status === 403) setPageState("closed");
else setPageState("error");
})
.catch(() => setPageState("error"));
}}
>
Thử lại
</Button>
<Link href="/login" className="text-sm text-(--color-primary) underline">Quay lại đăng nhập</Link>
<Link
href="/login"
className="text-sm text-(--color-primary) underline"
>
Quay lại đăng nhập
</Link>
</div>
)}
@@ -136,17 +200,50 @@ export default function ManagerSignupPage() {
{pageState === "available" && (
<form onSubmit={handleSubmit} className="space-y-4">
{[
{ id: "name", label: "Họ tên", icon: "fa-user", placeholder: "Nguyễn Văn A", field: "name" as const, type: "text" },
{ id: "phone", label: "Số điện thoại", icon: "fa-phone", placeholder: "0987654321", field: "phone" as const, type: "tel" },
{ id: "password", label: "Mật khẩu", icon: "fa-lock", placeholder: "Ít nhất 6 ký tự", field: "password" as const, type: "password" },
{ id: "eateryName", label: "Tên nhà hàng", icon: "fa-store", placeholder: "Coffee & More", field: "eateryName" as const, type: "text" },
{
id: "name",
label: "Họ tên",
icon: "fa-user",
placeholder: "Nguyễn Văn A",
field: "name" as const,
type: "text",
},
{
id: "phone",
label: "Số điện thoại",
icon: "fa-phone",
placeholder: "0987654321",
field: "phone" as const,
type: "tel",
},
{
id: "password",
label: "Mật khẩu",
icon: "fa-lock",
placeholder: "Ít nhất 6 ký tự",
field: "password" as const,
type: "password",
},
{
id: "eateryName",
label: "Tên nhà hàng",
icon: "fa-store",
placeholder: "Coffee & More",
field: "eateryName" as const,
type: "text",
},
].map(({ id, label, icon, placeholder, field, type }) => (
<div key={id}>
<label htmlFor={id} className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
<label
htmlFor={id}
className="mb-2 block text-sm font-medium text-(--color-text-secondary)"
>
{label}
</label>
<div className="relative">
<i className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}></i>
<i
className={`fa-solid ${icon} absolute top-1/2 left-4 hidden -translate-y-1/2 text-(--color-text-muted) lg:block`}
></i>
<input
id={id}
type={type}
@@ -174,9 +271,18 @@ export default function ManagerSignupPage() {
)}
<div className="space-y-3 pt-2">
<Button variant="primaryNoBorder" type="submit" style="login" size="lg" disabled={isLoading}>
<Button
variant="primaryNoBorder"
type="submit"
style="login"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<><i className="fa-solid fa-spinner fa-spin mr-2"></i>Đang xử ...</>
<>
<i className="fa-solid fa-spinner fa-spin mr-2"></i>Đang xử
...
</>
) : (
"Đăng ký"
)}
+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>
+3 -2
View File
@@ -91,7 +91,8 @@ export default function RegisterPage() {
ExistedUser: "Phone number already registered",
InvalidPhoneNumber: "Invalid phone number",
};
const msg = phoneErrorMap[errorCode] ?? "An error occurred, please try again";
const msg =
phoneErrorMap[errorCode] ?? "An error occurred, please try again";
setErrors({ phone: msg, otp: "" });
}
} catch {
@@ -364,7 +365,7 @@ export default function RegisterPage() {
type="button"
onClick={sendOtp}
disabled={otpSending || isLoading}
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:opacity-50 disabled:no-underline"
className="text-sm text-(--color-primary) underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
>
{otpSending ? (
<>
-433
View File
@@ -1,433 +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>
);
}
-2
View File
@@ -1,5 +1,3 @@
import type { Product } from "@/lib/types";
export interface ProductCardProps {
image: string;
imageAlt?: string;
-209
View File
@@ -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>
);
}
-124
View File
@@ -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>
);
}
-7
View File
@@ -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";
+8 -2
View File
@@ -21,7 +21,10 @@ export default function LoginForm() {
return false;
}
if (!PHONE_REGEX.test(phone)) {
setErrors({ phone: "Invalid phone number (e.g. 0987654321)", general: "" });
setErrors({
phone: "Invalid phone number (e.g. 0987654321)",
general: "",
});
return false;
}
return true;
@@ -50,7 +53,10 @@ export default function LoginForm() {
} else if (res.status === 404) {
setErrors({ phone: "", general: "Phone number not registered" });
} else {
setErrors({ phone: "", general: "An error occurred, please try again" });
setErrors({
phone: "",
general: "An error occurred, please try again",
});
}
} catch {
setErrors({ phone: "", general: "Unable to connect, please try again" });
-6
View File
@@ -23,16 +23,10 @@ export {
StatusBadge,
DeleteConfirm,
ProductModal,
CategoryModal,
ComboModal,
ProductsTab,
CategoriesTab,
CombosTab,
} from "./manager";
export type {
ProductModalProps,
CategoryModalProps,
ComboModalProps,
DeleteConfirmProps,
StatusBadgeProps,
} from "./manager";
@@ -1,110 +0,0 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { MenuCategory } from "@/lib/types";
import { useState } from "react";
import CategoryModal from "./CategoryModal";
import DeleteConfirm from "./DeleteConfirm";
export default function CategoriesTab() {
const { categories, products, addCategory, updateCategory, deleteCategory } =
useManager();
const [modalCategory, setModalCategory] = useState<
MenuCategory | null | "new"
>(null);
const [deleteTarget, setDeleteTarget] = useState<MenuCategory | null>(null);
const getProductCount = (catId: string) =>
products.filter((p) => p.category === catId).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{categories.length}</strong>{" "}
categories
</p>
<button
onClick={() => setModalCategory("new")}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Add category</span>
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{categories.map((cat) => {
const count = getProductCount(cat.id);
return (
<div
key={cat.id}
className="group relative flex items-center gap-4 rounded-2xl border border-(--color-border-light) bg-white p-4 shadow-sm transition hover:shadow-md"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-(--color-accent-light)">
<i className={`${cat.icon} text-xl text-(--color-primary)`}></i>
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate font-semibold">
{cat.name}
</p>
<p className="text-xs text-(--color-text-muted)">{count} items</p>
</div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => setModalCategory(cat)}
title="Edit"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-pen text-[11px]"></i>
</button>
<button
onClick={() => setDeleteTarget(cat)}
title="Delete"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
>
<i className="fa-solid fa-trash text-[11px]"></i>
</button>
</div>
</div>
);
})}
{categories.length === 0 && (
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
<i className="fa-solid fa-tag text-4xl opacity-30"></i>
<p className="text-sm">No categories yet</p>
</div>
)}
</div>
{modalCategory !== null && (
<CategoryModal
category={modalCategory === "new" ? null : modalCategory}
onSave={(data) => {
if ("id" in data) {
updateCategory(data as MenuCategory);
} else {
addCategory(data);
}
setModalCategory(null);
}}
onClose={() => setModalCategory(null)}
/>
)}
{deleteTarget !== null && (
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteCategory(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
@@ -1,123 +0,0 @@
"use client";
import type { MenuCategory } from "@/lib/types";
import { useState } from "react";
import type { CategoryModalProps } from "./Manager.types";
const FA_ICONS = [
"fa-solid fa-mug-hot",
"fa-solid fa-leaf",
"fa-solid fa-jar",
"fa-solid fa-blender",
"fa-solid fa-mug-saucer",
"fa-solid fa-ice-cream",
"fa-solid fa-layer-group",
"fa-solid fa-burger",
"fa-solid fa-pizza-slice",
"fa-solid fa-bowl-food",
"fa-solid fa-candy-cane",
"fa-solid fa-cookie",
"fa-solid fa-cake-candles",
"fa-solid fa-drumstick-bite",
"fa-solid fa-fish",
"fa-solid fa-carrot",
];
export default function CategoryModal({
category,
onSave,
onClose,
}: CategoryModalProps) {
const isEdit = category !== null;
const [form, setForm] = useState<Omit<MenuCategory, "id">>({
name: category?.name ?? "",
icon: category?.icon ?? FA_ICONS[0],
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEdit && category) {
onSave({ ...form, id: category.id });
} else {
onSave(form);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<h2 className="text-foreground text-lg font-bold">
{isEdit ? "Edit category" : "Add new category"}
</h2>
<button
title="Close"
onClick={onClose}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Category name <span className="text-red-500">*</span>
</label>
<input
required
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
placeholder="e.g. Coffee"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
Icon
</label>
<div className="grid grid-cols-8 gap-2">
{FA_ICONS.map((icon) => (
<button
key={icon}
type="button"
onClick={() => setForm({ ...form, icon })}
title={icon}
className={`flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition ${
form.icon === icon
? "border-(--color-primary) bg-(--color-primary) text-white"
: "bg-background border-(--color-border-light) text-(--color-text-secondary) hover:border-(--color-primary-light) hover:text-(--color-primary)"
}`}
>
<i className={`${icon} text-sm`}></i>
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Hủy
</button>
<button
type="submit"
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
{isEdit ? "Lưu thay đổi" : "Thêm danh mục"}
</button>
</div>
</form>
</div>
</div>
);
}
-237
View File
@@ -1,237 +0,0 @@
"use client";
import type { Combo, Product } from "@/lib/types";
import { useState } from "react";
import type { ComboModalProps } from "./Manager.types";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
export default function ComboModal({
combo,
products,
onSave,
onClose,
}: ComboModalProps) {
const isEdit = combo !== null;
const [form, setForm] = useState<Omit<Combo, "id">>({
name: combo?.name ?? "",
description: combo?.description ?? "",
price: combo?.price ?? 0,
image: combo?.image ?? "/imgs/products/placeholder.jpg",
items: combo?.items ?? [],
available: combo?.available ?? true,
});
const updateItemQty = (productId: number, qty: number) => {
if (qty <= 0) {
setForm((prev) => ({
...prev,
items: prev.items.filter((i) => i.productId !== productId),
}));
} else {
setForm((prev) => {
const existing = prev.items.find((i) => i.productId === productId);
if (existing) {
return {
...prev,
items: prev.items.map((i) =>
i.productId === productId ? { ...i, quantity: qty } : i,
),
};
}
return {
...prev,
items: [...prev.items, { productId, quantity: qty }],
};
});
}
};
const getQty = (productId: number) =>
form.items.find((i) => i.productId === productId)?.quantity ?? 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (form.items.length === 0) return;
if (isEdit && combo) {
onSave({ ...form, id: combo.id });
} else {
onSave(form);
}
};
const inputCls =
"w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="flex max-h-[90vh] w-full max-w-xl flex-col rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-(--color-border-light) px-6 py-4">
<h2 className="text-foreground text-lg font-bold">
{isEdit ? "Chỉnh sửa combo" : "Thêm combo mới"}
</h2>
<button
title="Close"
onClick={onClose}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition-colors hover:bg-(--color-border-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-xmark"></i>
</button>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Tên combo <span className="text-red-500">*</span>
</label>
<input
required
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls}
placeholder="Ví dụ: Combo Cà Phê Đôi"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Giá combo (đ) <span className="text-red-500">*</span>
</label>
<input
title="Giá combo"
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
tả
</label>
<textarea
title="Mô tả combo"
rows={2}
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
className={`${inputCls} resize-none`}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-(--color-text-secondary)">
Món trong combo{" "}
{form.items.length === 0 && (
<span className="text-xs text-red-500">
(Chọn ít nhất 1 món)
</span>
)}
</label>
<div className="bg-background max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-(--color-border-light) p-2">
{products.map((p) => {
const qty = getQty(p.id);
return (
<div
key={p.id}
className="flex items-center justify-between rounded-lg bg-white px-3 py-2 text-sm"
>
<span className="text-foreground flex-1 truncate">
{p.name}
</span>
<span className="mr-3 text-xs text-(--color-text-muted)">
{formatPrice(p.price)}
</span>
<div className="flex items-center gap-1">
<button
title="Giảm"
type="button"
onClick={() => updateItemQty(p.id, qty - 1)}
disabled={qty === 0}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary) disabled:cursor-not-allowed disabled:opacity-40"
>
<i className="fa-solid fa-minus"></i>
</button>
<span className="text-foreground w-5 text-center text-sm font-semibold">
{qty}
</span>
<button
title="Tăng"
type="button"
onClick={() => updateItemQty(p.id, qty + 1)}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-(--color-border) bg-white text-xs text-(--color-text-secondary) transition hover:border-(--color-primary) hover:text-(--color-primary)"
>
<i className="fa-solid fa-plus"></i>
</button>
</div>
</div>
);
})}
</div>
</div>
<div className="bg-background flex items-center justify-between rounded-xl border border-(--color-border-light) px-4 py-3">
<div>
<p className="text-foreground text-sm font-medium">
Trạng thái
</p>
<p className="text-xs text-(--color-text-muted)">
{form.available ? "Còn hàng" : "Tạm hết"}
</p>
</div>
<button
title="Chuyển đổi trạng thái"
type="button"
onClick={() => setForm({ ...form, available: !form.available })}
className={`relative h-6 w-11 cursor-pointer rounded-full border-none transition-colors duration-200 ${
form.available ? "bg-(--color-primary)" : "bg-gray-300"
}`}
>
<span
className={`absolute top-0.5 left-0 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ${
form.available ? "translate-x-5.5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
<div className="flex gap-3 border-t border-(--color-border-light) px-6 py-4">
<button
type="button"
onClick={onClose}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border) bg-white px-4 py-2.5 text-sm font-medium text-(--color-text-secondary) transition hover:bg-(--color-border-light)"
>
Hủy
</button>
<button
type="submit"
disabled={form.items.length === 0}
className="flex-1 cursor-pointer rounded-xl border-none bg-(--color-primary) px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{isEdit ? "Lưu thay đổi" : "Thêm combo"}
</button>
</div>
</form>
</div>
</div>
);
}
-149
View File
@@ -1,149 +0,0 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { Combo } from "@/lib/types";
import { useState } from "react";
import ComboModal from "./ComboModal";
import DeleteConfirm from "./DeleteConfirm";
import StatusBadge from "./StatusBadge";
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
}
export default function CombosTab() {
const {
combos,
products,
addCombo,
updateCombo,
deleteCombo,
toggleComboAvailability,
} = useManager();
const [modalCombo, setModalCombo] = useState<Combo | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<Combo | null>(null);
const getProductName = (id: number) =>
products.find((p) => p.id === id)?.name ?? `Món #${id}`;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{combos.length}</strong> combo
</p>
<button
onClick={() => setModalCombo("new")}
className="flex cursor-pointer items-center gap-2 rounded-xl border-none bg-(--color-primary) px-4 py-2 text-sm font-semibold text-white transition hover:bg-(--color-primary-dark) active:scale-95"
>
<i className="fa-solid fa-plus"></i>
<span className="hidden sm:inline">Thêm combo</span>
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{combos.length === 0 ? (
<div className="col-span-full flex flex-col items-center gap-3 py-16 text-(--color-text-muted)">
<i className="fa-solid fa-layer-group text-4xl opacity-30"></i>
<p className="text-sm">Chưa combo nào</p>
</div>
) : (
combos.map((combo) => (
<div
key={combo.id}
className="flex flex-col rounded-2xl border border-(--color-border-light) bg-white shadow-sm transition hover:shadow-md"
>
<div className="flex items-start justify-between p-4">
<div className="min-w-0 flex-1">
<h3 className="text-foreground truncate font-semibold">
{combo.name}
</h3>
{combo.description && (
<p className="mt-1 line-clamp-2 text-xs text-(--color-text-muted)">
{combo.description}
</p>
)}
</div>
<button
onClick={() => toggleComboAvailability(combo.id)}
className="ml-3 shrink-0 cursor-pointer border-none bg-transparent"
title="Đổi trạng thái"
>
<StatusBadge available={combo.available} />
</button>
</div>
<div className="bg-background mx-4 mb-3 rounded-xl px-3 py-2">
<p className="mb-1 text-[11px] font-semibold tracking-wide text-(--color-text-muted) uppercase">
Bao gồm
</p>
<ul className="space-y-0.5">
{combo.items.map((item) => (
<li
key={item.productId}
className="flex items-center justify-between text-xs text-(--color-text-secondary)"
>
<span>{getProductName(item.productId)}</span>
<span className="font-medium">×{item.quantity}</span>
</li>
))}
</ul>
</div>
<div className="flex items-center justify-between border-t border-(--color-border-light) px-4 py-3">
<span className="text-base font-bold text-(--color-primary)">
{formatPrice(combo.price)}
</span>
<div className="flex gap-1.5">
<button
onClick={() => setModalCombo(combo)}
title="Chỉnh sửa"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-(--color-border-light) bg-transparent text-(--color-text-muted) transition hover:border-(--color-primary-light) hover:text-(--color-primary)"
>
<i className="fa-solid fa-pen text-xs"></i>
</button>
<button
onClick={() => setDeleteTarget(combo)}
title="Xóa"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-transparent text-(--color-text-muted) transition hover:border-red-200 hover:bg-red-50 hover:text-red-500"
>
<i className="fa-solid fa-trash text-xs"></i>
</button>
</div>
</div>
</div>
))
)}
</div>
{modalCombo !== null && (
<ComboModal
combo={modalCombo === "new" ? null : modalCombo}
products={products}
onSave={(data) => {
if ("id" in data) {
updateCombo(data as Combo);
} else {
addCombo(data);
}
setModalCombo(null);
}}
onClose={() => setModalCombo(null)}
/>
)}
{deleteTarget !== null && (
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteCombo(deleteTarget.id);
setDeleteTarget(null);
}}
onClose={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
+3 -17
View File
@@ -1,22 +1,8 @@
import type { Combo, MenuCategory, Product } from "@/lib/types";
import type { MenuItemEntity } from "@/lib/types";
export interface ProductModalProps {
product: Product | null; // null = add mode
categories: MenuCategory[];
onSave: (p: Omit<Product, "id"> | Product) => void;
onClose: () => void;
}
export interface CategoryModalProps {
category: MenuCategory | null;
onSave: (c: Omit<MenuCategory, "id"> | MenuCategory) => void;
onClose: () => void;
}
export interface ComboModalProps {
combo: Combo | null;
products: Product[];
onSave: (c: Omit<Combo, "id"> | Combo) => void;
product: MenuItemEntity | null;
onSave: (p: MenuItemEntity) => void;
onClose: () => void;
}
+75 -84
View File
@@ -1,19 +1,38 @@
"use client";
import { eateryClient } from "@/lib/apollo-clients";
import { useAuth } from "@/lib/auth-context";
import { addManagerMenuItem, gqlFetch } from "@/lib/graphql";
import { useCallback, useEffect, useState } from "react";
import {
MenuItemEntity,
addMenuItemMutation,
allEateriesQuery,
} from "@/lib/types";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import { useEffect, useState } from "react";
interface MenuItem {
id: string;
name: string;
price: number;
}
const GET_EATERY_MENU = gql`
query GetEateryMenu {
allEateries {
id
menuItems {
id
name
price
}
}
}
`;
interface Eatery {
id: string;
ownerId: string;
}
const ADD_MENU_ITEM = gql`
mutation addMenuItem($menuItem: AddMenuItemInput!) {
addMenuItem(menuItem: $menuItem) {
id
name
price
}
}
`;
function formatPrice(price: number) {
return price.toLocaleString("vi-VN") + "đ";
@@ -21,10 +40,20 @@ function formatPrice(price: number) {
export default function MenuItemsTab() {
const { user } = useAuth();
const [eateryId, setEateryId] = useState<string | null>(null);
const [menuItems, setMenuItems] = useState<MenuItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [menuItems, setMenuItems] = useState<MenuItemEntity[]>([]);
const {
data,
loading: gqlLoading,
error: gqlError,
} = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
client: eateryClient,
fetchPolicy: "network-only",
});
const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, {
client: eateryClient,
});
const [showForm, setShowForm] = useState(false);
const [newName, setNewName] = useState("");
@@ -32,57 +61,36 @@ export default function MenuItemsTab() {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const loadEateryId = useCallback(async (): Promise<string | null> => {
if (!user) return null;
const data = await gqlFetch<{ allEateries: Eatery[] }>(
"{ allEateries { id ownerId } }",
);
return (
data.allEateries.find((e) => e.ownerId === String(user.id))?.id ?? null
);
}, [user]);
const loadMenuItems = useCallback(async (id: string): Promise<MenuItem[]> => {
const data = await gqlFetch<{ menuItemsByEatery: MenuItem[] }>(
`query($eateryId: String!) {
menuItemsByEatery(eateryId: $eateryId) { id name price }
}`,
{ eateryId: id },
);
return data.menuItemsByEatery ?? [];
}, []);
useEffect(() => {
async function init() {
setLoading(true);
setError(null);
try {
const id = await loadEateryId();
setEateryId(id);
if (id) setMenuItems(await loadMenuItems(id));
else setMenuItems([]);
} catch {
setError("Không thể tải dữ liệu menu. Kiểm tra kết nối backend.");
} finally {
setLoading(false);
}
if (data?.allEateries?.[0]) {
setMenuItems(data.allEateries[0].menuItems);
}
init();
}, [loadEateryId, loadMenuItems]);
}, [data]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newPrice) return;
setSubmitting(true);
setSubmitError(null);
try {
const added = await addManagerMenuItem(newName.trim(), parseFloat(newPrice));
if (added) setMenuItems((prev) => [...prev, added]);
setNewName("");
setNewPrice("");
setShowForm(false);
} catch {
setSubmitError("Không thể thêm món. Vui lòng thử lại.");
const { data: mutationResult } = await mutateAddMenuItem({
variables: {
menuItem: {
name: newName,
price: parseFloat(newPrice),
},
},
});
if (mutationResult?.addMenuItem) {
setMenuItems((prev) => [...prev, mutationResult.addMenuItem]);
closeForm();
}
} catch (err: any) {
console.error("Mutation Error:", err);
setSubmitError(err.message || "Không thể thêm món. Vui lòng thử lại.");
} finally {
setSubmitting(false);
}
@@ -95,39 +103,22 @@ export default function MenuItemsTab() {
setNewPrice("");
};
if (loading) {
if (gqlLoading && menuItems.length === 0)
return <div className="py-16 text-center">Đang tải menu...</div>;
if (gqlError)
return (
<div className="flex items-center justify-center py-16 text-(--color-text-muted)">
<i className="fa-solid fa-spinner mr-2 animate-spin"></i>
Đang tải menu...
<div className="py-16 text-center text-red-500">
Lỗi: {gqlError.message}
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-16 text-red-500">
<i className="fa-solid fa-circle-exclamation mb-2 text-2xl"></i>
<p className="text-sm">{error}</p>
</div>
);
}
if (!eateryId) {
return (
<div className="flex flex-col items-center justify-center py-16 text-(--color-text-muted)">
<i className="fa-solid fa-store mb-2 block text-3xl opacity-30"></i>
<p className="text-sm">Quán của bạn chưa đưc khởi tạo trong hệ thống.</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<p className="text-sm text-(--color-text-muted)">
<strong className="text-foreground">{menuItems.length}</strong> món trong menu
<strong className="text-foreground">{menuItems.length}</strong> món
trong menu
</p>
<button
onClick={() => setShowForm(true)}
@@ -206,7 +197,7 @@ export default function MenuItemsTab() {
onChange={(e) => setNewName(e.target.value)}
placeholder="Nhập tên món..."
required
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
@@ -222,7 +213,7 @@ export default function MenuItemsTab() {
required
min={0}
step={500}
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm outline-none transition focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
className="text-foreground w-full rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary) focus:ring-2 focus:ring-(--color-primary)/20"
/>
</div>
@@ -237,7 +228,7 @@ export default function MenuItemsTab() {
<button
type="button"
onClick={closeForm}
className="flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition hover:bg-background"
className="hover:bg-background flex-1 cursor-pointer rounded-xl border border-(--color-border-light) bg-transparent py-2 text-sm font-medium text-(--color-text-secondary) transition"
>
Hủy
</button>
+19 -41
View File
@@ -1,22 +1,20 @@
"use client";
import type { Product } from "@/lib/types";
import type { MenuItemEntity } from "@/lib/types";
import { useState } from "react";
import type { ProductModalProps } from "./Manager.types";
export default function ProductModal({
product,
categories,
onSave,
onClose,
}: ProductModalProps) {
const isEdit = product !== null;
const [form, setForm] = useState<Omit<Product, "id">>({
const [form, setForm] = useState<MenuItemEntity>({
name: product?.name ?? "",
category: product?.category ?? categories[0]?.id ?? "",
price: product?.price ?? 0,
image: product?.image ?? "/imgs/products/placeholder.jpg",
imageUrl: product?.imageUrl ?? "/imgs/products/placeholder.jpg",
description: product?.description ?? "",
available: product?.available ?? true,
});
@@ -67,42 +65,22 @@ export default function ProductModal({
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Danh mục <span className="text-red-500">*</span>
</label>
<select
required
title="Chọn danh mục"
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className={inputCls}
>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Giá (đ) <span className="text-red-500">*</span>
</label>
<input
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
placeholder="25000"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-(--color-text-secondary)">
Giá (đ) <span className="text-red-500">*</span>
</label>
<input
required
type="number"
min={0}
step={1000}
value={form.price}
onChange={(e) =>
setForm({ ...form, price: Number(e.target.value) })
}
className={inputCls}
placeholder="25000"
/>
</div>
<div>
+11 -43
View File
@@ -1,7 +1,7 @@
"use client";
import { useManager } from "@/lib/manager-context";
import type { Product } from "@/lib/types";
import type { MenuItemEntity } from "@/lib/types";
import { useState } from "react";
import DeleteConfirm from "./DeleteConfirm";
@@ -15,25 +15,22 @@ function formatPrice(price: number) {
export default function ProductsTab() {
const {
products,
categories,
addProduct,
updateProduct,
deleteProduct,
toggleProductAvailability,
} = useManager();
const [filterCategory, setFilterCategory] = useState("all");
const [filterStatus, setFilterStatus] = useState<
"all" | "available" | "unavailable"
>("all");
const [search, setSearch] = useState("");
const [modalProduct, setModalProduct] = useState<Product | null | "new">(
null,
);
const [deleteTarget, setDeleteTarget] = useState<Product | null>(null);
const [modalProduct, setModalProduct] = useState<
MenuItemEntity | null | "new"
>(null);
const [deleteTarget, setDeleteTarget] = useState<MenuItemEntity>(null!);
const filtered = products.filter((p) => {
if (filterCategory !== "all" && p.category !== filterCategory) return false;
if (filterStatus === "available" && p.available === false) return false;
if (filterStatus === "unavailable" && p.available !== false) return false;
if (
@@ -45,9 +42,6 @@ export default function ProductsTab() {
return true;
});
const getCategoryName = (id: string) =>
categories.find((c) => c.id === id)?.name ?? id;
return (
<div className="space-y-4">
{/* Toolbar */}
@@ -73,20 +67,6 @@ export default function ProductsTab() {
)}
</div>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="text-foreground cursor-pointer rounded-xl border border-(--color-border) bg-white px-3 py-2 text-sm transition outline-none focus:border-(--color-primary)"
title="Lọc theo danh mục"
>
<option value="all">Tất cả danh mục</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) =>
@@ -125,9 +105,6 @@ export default function ProductsTab() {
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Tên món
</th>
<th className="px-4 py-3 text-left font-semibold text-(--color-text-secondary)">
Danh mục
</th>
<th className="px-4 py-3 text-right font-semibold text-(--color-text-secondary)">
Giá
</th>
@@ -166,20 +143,12 @@ export default function ProductsTab() {
)}
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-(--color-accent-light) px-2.5 py-0.5 text-xs font-medium text-(--color-primary-dark)">
<i
className={`${categories.find((c) => c.id === p.category)?.icon ?? "fa-solid fa-tag"} text-[10px]`}
></i>
{getCategoryName(p.category)}
</span>
</td>
<td className="px-4 py-3 text-right font-semibold text-(--color-primary)">
{formatPrice(p.price)}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => toggleProductAvailability(p.id)}
onClick={() => toggleProductAvailability(p)}
title="Nhấn để đổi trạng thái"
className="cursor-pointer border-none bg-transparent"
>
@@ -214,10 +183,9 @@ export default function ProductsTab() {
{modalProduct !== null && (
<ProductModal
product={modalProduct === "new" ? null : modalProduct}
categories={categories}
onSave={(data) => {
onSave={(data: MenuItemEntity) => {
if ("id" in data) {
updateProduct(data as Product);
updateProduct(data);
} else {
addProduct(data);
}
@@ -231,10 +199,10 @@ export default function ProductsTab() {
<DeleteConfirm
name={deleteTarget.name}
onConfirm={() => {
deleteProduct(deleteTarget.id);
setDeleteTarget(null);
deleteProduct(deleteTarget.id!);
setDeleteTarget(null!);
}}
onClose={() => setDeleteTarget(null)}
onClose={() => setDeleteTarget(null!)}
/>
)}
</div>
-6
View File
@@ -1,16 +1,10 @@
export { default as StatusBadge } from "./StatusBadge";
export { default as DeleteConfirm } from "./DeleteConfirm";
export { default as ProductModal } from "./ProductModal";
export { default as CategoryModal } from "./CategoryModal";
export { default as ComboModal } from "./ComboModal";
export { default as ProductsTab } from "./ProductsTab";
export { default as CategoriesTab } from "./CategoriesTab";
export { default as CombosTab } from "./CombosTab";
export { default as MenuItemsTab } from "./MenuItemsTab";
export type {
ProductModalProps,
CategoryModalProps,
ComboModalProps,
DeleteConfirmProps,
StatusBadgeProps,
} from "./Manager.types";
@@ -1,7 +1,6 @@
"use client";
import { MENU_CATEGORIES, SHOP_INFO } from "@/lib/constants";
import type { MenuCategory } from "@/lib/types";
import { SHOP_INFO } from "@/lib/constants";
import type React from "react";
import type { CategorySidebarProps } from "./Navigation.types";
@@ -18,8 +17,6 @@ import type { CategorySidebarProps } from "./Navigation.types";
export default function CategorySidebar({
isOpen,
onToggle,
activeCategory = "all",
onCategoryChange,
}: CategorySidebarProps) {
return (
<aside
@@ -58,40 +55,6 @@ export default function CategorySidebar({
</button>
</div>
{/* ── Category list ── */}
<nav className="flex-1 py-2">
<ul className="flex flex-col gap-0.5 px-2">
{MENU_CATEGORIES.map((cat: MenuCategory) => {
const isActive = activeCategory === cat.id;
return (
<li key={cat.id}>
<button
onClick={() => onCategoryChange?.(cat.id)}
title={!isOpen ? cat.name : undefined}
className={`flex w-full cursor-pointer items-center rounded-xl border-none text-sm font-medium transition-all duration-150 xl:justify-start xl:gap-3 xl:px-3 xl:py-2.5 ${isOpen ? "gap-3 px-3 py-2.5" : "justify-center px-0 py-2.5"} ${
isActive
? "bg-(--color-primary) text-white shadow-sm"
: "bg-transparent text-(--color-text-secondary) hover:bg-(--color-border-light) hover:text-(--color-primary-dark)"
} `}
>
{/* Icon */}
<i
className={` ${cat.icon} w-5 shrink-0 text-center text-base ${isActive ? "text-white" : "text-(--color-primary)"} `}
></i>
{/* Label — hidden when collapsed, always shown on xl+ */}
<span
className={`overflow-hidden text-ellipsis whitespace-nowrap ${isOpen ? "block" : "hidden"} xl:block`}
>
{cat.name}
</span>
</button>
</li>
);
})}
</ul>
</nav>
{/* ── Sidebar footer: opening hours ── */}
<div
className={`shrink-0 border-t border-(--color-border) py-3 xl:px-4 ${isOpen ? "px-4" : "flex justify-center px-0"} `}
@@ -2,8 +2,7 @@
import { ProductCard } from "@/components/molecules/cards";
import { useCart } from "@/lib/cart-context";
import { MENU_CATEGORIES, MOCK_PRODUCTS } from "@/lib/constants";
import { useMenu } from "@/lib/menu-context";
import { MOCK_PRODUCTS } from "@/lib/constants";
import type { ProductGridProps } from "./ProductGrid.types";
@@ -11,61 +10,30 @@ export default function ProductGrid({
searchQuery = "",
isSidebarOpen = false,
}: ProductGridProps) {
const { activeCategory, setActiveCategory } = useMenu();
const { addToCart } = useCart();
const filteredProducts = MOCK_PRODUCTS.filter((p) => {
const isAvailable = p.available !== false;
const matchesCategory =
activeCategory === "all" || p.category === activeCategory;
const matchesSearch =
searchQuery.trim() === "" ||
p.name.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
? "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";
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 ── */}
{filteredProducts.length > 0 ? (
<div className={`grid gap-4 ${gridCols}`}>
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
image={product.image}
image={product.imageUrl}
imageAlt={product.name}
productName={product.name}
price={product.price}
@@ -178,7 +178,9 @@ export default function MobileShiftView({
</div>
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500"></span>
<span className="text-[10px] text-(--color-text-muted)">Registered</span>
<span className="text-[10px] text-(--color-text-muted)">
Registered
</span>
</div>
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-purple-400"></span>
@@ -188,7 +190,9 @@ export default function MobileShiftView({
</div>
<div className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-red-400"></span>
<span className="text-[10px] text-(--color-text-muted)">Absent</span>
<span className="text-[10px] text-(--color-text-muted)">
Absent
</span>
</div>
</div>
</div>
@@ -199,7 +203,8 @@ export default function MobileShiftView({
{dayOfWeek}, {selectedDateObj.getDate()}/
{selectedDateObj.getMonth() + 1}/{selectedDateObj.getFullYear()}
<span className="ml-2 text-xs font-normal text-(--color-text-muted)">
({selectedShifts.length} shift{selectedShifts.length !== 1 ? "s" : ""})
({selectedShifts.length} shift
{selectedShifts.length !== 1 ? "s" : ""})
</span>
</h3>
+3 -1
View File
@@ -23,7 +23,9 @@ export default function ShopGrid({
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-(--color-text-muted)">
<i className="fa-solid fa-store text-5xl opacity-30"></i>
<p className="text-base font-medium">No shops found matching your search</p>
<p className="text-base font-medium">
No shops found matching your search
</p>
</div>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec:
containers:
- name: frontend-container
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.3
image: git.demonkernel.io.vn/foodsurf/frontend:1.2.4
ports:
- containerPort: 3000
resources:
+52
View File
@@ -0,0 +1,52 @@
import {
ApolloClient,
ApolloLink,
HttpLink,
InMemoryCache,
Observable,
} from "@apollo/client";
const responseWrapperLink = new ApolloLink((operation, forward) => {
return new Observable((observer) => {
const handle = forward(operation).subscribe({
next: (response) => {
// Kiểm tra nếu dữ liệu trả về bị "trần" (thiếu data property)
if (
response &&
!Object.prototype.hasOwnProperty.call(response, "data")
) {
// Khởi tạo một object mới theo chuẩn GraphQL
const formattedResponse = {
data: response,
errors: (response as any).errors,
};
observer.next(formattedResponse);
} else {
observer.next(response);
}
},
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
return () => {
if (handle) handle.unsubscribe();
};
});
});
export const cartClient = new ApolloClient({
link: ApolloLink.from([
responseWrapperLink,
new HttpLink({ uri: "/api/cart/graphql" }),
]),
cache: new InMemoryCache(),
});
export const eateryClient = new ApolloClient({
link: ApolloLink.from([
responseWrapperLink,
new HttpLink({ uri: "/api/eatery/graphql" }),
]),
cache: new InMemoryCache(),
});
+8 -2
View File
@@ -14,7 +14,10 @@ interface AuthContextType {
user: User | null;
setUser: (user: User | null) => void;
isInitialized: boolean;
login: (username: string, password: string) => Promise<{ ok: boolean; status?: number }>;
login: (
username: string,
password: string,
) => Promise<{ ok: boolean; status?: number }>;
logout: () => void;
}
@@ -36,7 +39,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setIsInitialized(true);
}, []);
const login = async (username: string, password: string): Promise<{ ok: boolean; status?: number }> => {
const login = async (
username: string,
password: string,
): Promise<{ ok: boolean; status?: number }> => {
const isPhone = /^0\d{9}$/.test(username.trim());
const role = isPhone ? "customer" : "manager";
+27 -30
View File
@@ -1,40 +1,33 @@
"use client";
import type { Product } from "@/lib/types";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
export interface CartItem {
id: number;
name: string;
description: string;
price: number;
quantity: number;
}
import { CartItemEntity, MenuItemEntity } from "./types";
interface CartContextValue {
items: CartItem[];
items: CartItemEntity[];
totalItems: number;
totalPrice: number;
addToCart: (product: Product) => void;
increaseQty: (id: number) => void;
decreaseQty: (id: number) => void;
removeFromCart: (id: number) => void;
setQuantity: (id: number, quantity: number) => void;
addToCart: (product: MenuItemEntity) => void;
increaseQty: (id: string) => void;
decreaseQty: (id: string) => void;
removeFromCart: (id: string) => void;
setQuantity: (id: string, quantity: number) => void;
}
const STORAGE_KEY = "coffee-shop-cart";
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const [items, setItems] = useState<CartItemEntity[]>([]);
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as CartItem[];
const parsed = JSON.parse(raw) as CartItemEntity[];
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 {
localStorage.removeItem(STORAGE_KEY);
@@ -45,17 +38,17 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}, [items]);
const addToCart = (product: Product) => {
const addToCart = (product: MenuItemEntity) => {
setItems((prev) => {
const index = prev.findIndex((i) => i.id === product.id);
const index = prev.findIndex((i) => i.productId === product.id);
if (index === -1) {
return [
...prev,
{
id: product.id,
productId: product.id!,
name: product.name,
description: product.description,
price: product.price,
priceAtTimeOfAdding: product.price,
quantity: 1,
},
];
@@ -67,19 +60,19 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
});
};
const increaseQty = (id: number) => {
const increaseQty = (id: string) => {
setItems((prev) =>
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) =>
prev
.map((item) =>
item.id === id
item.productId === id
? { ...item, quantity: Math.max(0, item.quantity - 1) }
: item,
)
@@ -87,11 +80,11 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
);
};
const removeFromCart = (id: number) => {
setItems((prev) => prev.filter((item) => item.id !== id));
const removeFromCart = (id: string) => {
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)
? Math.max(0, Math.floor(quantity))
: 0;
@@ -102,7 +95,7 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
setItems((prev) =>
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(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
() =>
items.reduce(
(sum, item) => sum + item.priceAtTimeOfAdding * item.quantity,
0,
),
[items],
);
+62 -327
View File
@@ -1,8 +1,6 @@
import type {
Combo,
Department,
MenuCategory,
Product,
MenuItemEntity,
ProductSalesStats,
RevenueDataPoint,
ShiftSlot,
@@ -35,248 +33,189 @@ export const SOCIAL_LINKS: SocialLinks = {
website: "/",
};
// ===== MENU CATEGORIES =====
// Each category has a unique FontAwesome icon representing the item type
export const MENU_CATEGORIES: MenuCategory[] = [
{ id: "all", name: "All", icon: "fa-solid fa-border-all" },
{ id: "cafe", name: "Coffee", icon: "fa-solid fa-mug-hot" },
{ id: "tra", name: "Tea", icon: "fa-solid fa-leaf" },
{ id: "sua-chua", name: "Yogurt", icon: "fa-solid fa-jar" },
{ id: "nuoc-ep", name: "Juice", icon: "fa-solid fa-blender" },
{ id: "latte", name: "Latte", icon: "fa-solid fa-mug-saucer" },
{
id: "giai-khat",
name: "Refreshments / Snacks",
icon: "fa-solid fa-ice-cream",
},
{ id: "topping", name: "Topping", icon: "fa-solid fa-layer-group" },
];
// ===== MOCK PRODUCTS =====
// Placeholder data replace with real API calls when backend is ready
export const MOCK_PRODUCTS: Product[] = [
export const MOCK_PRODUCTS: MenuItemEntity[] = [
{
id: 1,
id: "1",
name: "Black Coffee",
category: "cafe",
price: 25000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Traditional Vietnamese black coffee, rich and bold, brewed by hand with a phin filter.",
available: true,
},
{
id: 2,
id: "2",
name: "Milk Coffee",
category: "cafe",
price: 30000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Aromatic condensed milk coffee, rich and creamy — a perfect blend of strong coffee and sweet condensed milk.",
available: true,
},
{
id: 3,
id: "3",
name: "White Coffee",
category: "cafe",
price: 32000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Light and mild, with less coffee and more milk — perfect for those just starting to enjoy coffee.",
available: true,
},
{
id: 4,
id: "4",
name: "Egg Coffee",
category: "cafe",
price: 45000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"A Hanoi specialty — smooth, velvety egg cream layered over a bold coffee base.",
available: true,
},
{
id: 5,
id: "5",
name: "Peach Orange Lemongrass Tea",
category: "tra",
price: 35000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Fragrant peach tea with fresh orange and lemongrass — refreshing and wonderfully cooling.",
available: true,
},
{
id: 6,
id: "6",
name: "Matcha Green Tea",
category: "tra",
price: 40000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Pure Japanese matcha with a signature light bitterness — aromatic, refreshing, and nutritious.",
available: true,
},
{
id: 7,
id: "7",
name: "Lychee Jasmine Tea",
category: "tra",
price: 38000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Sweet lychee tea delicately infused with jasmine blossom — a calming and soulful sip.",
available: true,
},
{
id: 8,
id: "8",
name: "Yogurt with Tapioca Pearls",
category: "sua-chua",
price: 38000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Smooth, creamy yogurt paired with chewy black tapioca pearls — a perfectly balanced sweet and tangy treat.",
available: true,
},
{
id: 9,
id: "9",
name: "Strawberry Yogurt",
category: "sua-chua",
price: 40000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Chilled yogurt with fresh sweet-tart strawberries, packed with vitamins and minerals.",
available: true,
},
{
id: 10,
id: "10",
name: "Fresh Orange Juice",
category: "nuoc-ep",
price: 35000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"100% freshly squeezed orange juice, rich in vitamin C and great for your health.",
available: true,
},
{
id: 11,
id: "11",
name: "Watermelon Juice",
category: "nuoc-ep",
price: 30000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Ice-cold watermelon juice — instantly refreshing on hot summer days.",
available: true,
},
{
id: 12,
id: "12",
name: "Caramel Latte",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Sweet and indulgent caramel latte with a velvety milk foam topping and a drizzle of caramel sauce.",
available: true,
},
{
id: 13,
id: "13",
name: "Vanilla Latte",
category: "latte",
price: 45000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Smooth and gentle vanilla latte with a delicate natural vanilla fragrance.",
available: true,
},
{
id: 14,
id: "14",
name: "Toasted Butter Bread",
category: "giai-khat",
price: 20000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Crispy toasted bread spread with fragrant butter and strawberry jam — the perfect coffee companion.",
available: true,
},
{
id: 15,
id: "15",
name: "Caramel Flan",
category: "giai-khat",
price: 25000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Silky smooth flan with a golden caramel topping that melts in your mouth.",
available: true,
},
{
id: 16,
id: "16",
name: "Black Tapioca Pearls",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Chewy black tapioca pearls — add them to any drink for extra flavor and texture.",
available: true,
},
{
id: 17,
id: "17",
name: "Coffee Jelly",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Cool coffee jelly that adds a distinctive flavor boost to your drink.",
available: true,
},
{
id: 18,
id: "18",
name: "White Tapioca Pearls",
category: "topping",
price: 10000,
image: "/imgs/products/placeholder.jpg",
imageUrl: "/imgs/products/placeholder.jpg",
description:
"Chewy white tapioca pearls — add them to any drink for extra flavor and texture.",
available: true,
},
];
// ===== MOCK COMBOS =====
export const MOCK_COMBOS: Combo[] = [
{
id: 1,
name: "Double Coffee Combo",
description: "2 black coffees + 2 toasted butter breads, save 15%.",
price: 75000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 1, quantity: 2 },
{ productId: 14, quantity: 2 },
],
available: true,
},
{
id: 2,
name: "Group Tea Combo",
description: "2 peach orange lemongrass teas + 2 matcha green teas, perfect for a group.",
price: 130000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 5, quantity: 2 },
{ productId: 6, quantity: 2 },
],
available: true,
},
{
id: 3,
name: "Morning Combo",
description: "1 milk coffee + 1 caramel flan — start your day on a sweet note.",
price: 48000,
image: "/imgs/products/placeholder.jpg",
items: [
{ productId: 2, quantity: 1 },
{ productId: 15, quantity: 1 },
],
available: false,
},
];
// ===== MOCK SHOPS (for Feed page) =====
export const MOCK_SHOPS: Shop[] = [
{
@@ -393,217 +332,6 @@ export const MOCK_REVENUE_YEARLY: RevenueDataPoint[] = [
{ label: "2026", revenue: 180000000, orders: 6480 },
];
// Product sales statistics (with cost price for profit analysis)
export const MOCK_PRODUCT_SALES: ProductSalesStats[] = [
{
productId: 12,
name: "Caramel Latte",
category: "latte",
unitsSold: 487,
revenue: 21915000,
costPrice: 18000,
sellingPrice: 45000,
profit: 13185000,
profitMargin: 60.2,
},
{
productId: 6,
name: "Matcha Green Tea",
category: "tra",
unitsSold: 412,
revenue: 16480000,
costPrice: 15000,
sellingPrice: 40000,
profit: 10300000,
profitMargin: 62.5,
},
{
productId: 5,
name: "Peach Orange Lemongrass Tea",
category: "tra",
unitsSold: 398,
revenue: 13930000,
costPrice: 12000,
sellingPrice: 35000,
profit: 9153000,
profitMargin: 65.7,
},
{
productId: 13,
name: "Vanilla Latte",
category: "latte",
unitsSold: 356,
revenue: 16020000,
costPrice: 18000,
sellingPrice: 45000,
profit: 9612000,
profitMargin: 60.0,
},
{
productId: 4,
name: "Egg Coffee",
category: "cafe",
unitsSold: 340,
revenue: 15300000,
costPrice: 16000,
sellingPrice: 45000,
profit: 9860000,
profitMargin: 64.4,
},
{
productId: 2,
name: "Milk Coffee",
category: "cafe",
unitsSold: 325,
revenue: 9750000,
costPrice: 10000,
sellingPrice: 30000,
profit: 6500000,
profitMargin: 66.7,
},
{
productId: 3,
name: "White Coffee",
category: "cafe",
unitsSold: 298,
revenue: 9536000,
costPrice: 11000,
sellingPrice: 32000,
profit: 6259000,
profitMargin: 65.6,
},
{
productId: 1,
name: "Black Coffee",
category: "cafe",
unitsSold: 285,
revenue: 7125000,
costPrice: 8000,
sellingPrice: 25000,
profit: 4845000,
profitMargin: 68.0,
},
{
productId: 9,
name: "Strawberry Yogurt",
category: "sua-chua",
unitsSold: 267,
revenue: 10680000,
costPrice: 15000,
sellingPrice: 40000,
profit: 6675000,
profitMargin: 62.5,
},
{
productId: 10,
name: "Fresh Orange Juice",
category: "nuoc-ep",
unitsSold: 241,
revenue: 8435000,
costPrice: 12000,
sellingPrice: 35000,
profit: 5543000,
profitMargin: 65.7,
},
{
productId: 8,
name: "Yogurt with Tapioca Pearls",
category: "sua-chua",
unitsSold: 228,
revenue: 8664000,
costPrice: 14000,
sellingPrice: 38000,
profit: 5472000,
profitMargin: 63.2,
},
{
productId: 7,
name: "Lychee Jasmine Tea",
category: "tra",
unitsSold: 215,
revenue: 8170000,
costPrice: 13000,
sellingPrice: 38000,
profit: 5375000,
profitMargin: 65.8,
},
{
productId: 15,
name: "Caramel Flan",
category: "giai-khat",
unitsSold: 198,
revenue: 4950000,
costPrice: 8000,
sellingPrice: 25000,
profit: 3366000,
profitMargin: 68.0,
},
{
productId: 11,
name: "Watermelon Juice",
category: "nuoc-ep",
unitsSold: 182,
revenue: 5460000,
costPrice: 10000,
sellingPrice: 30000,
profit: 3640000,
profitMargin: 66.7,
},
{
productId: 14,
name: "Toasted Butter Bread",
category: "giai-khat",
unitsSold: 175,
revenue: 3500000,
costPrice: 6000,
sellingPrice: 20000,
profit: 2450000,
profitMargin: 70.0,
},
{
productId: 16,
name: "Black Tapioca Pearls",
category: "topping",
unitsSold: 456,
revenue: 4560000,
costPrice: 2000,
sellingPrice: 10000,
profit: 3648000,
profitMargin: 80.0,
},
{
productId: 17,
name: "Coffee Jelly",
category: "topping",
unitsSold: 389,
revenue: 3890000,
costPrice: 2000,
sellingPrice: 10000,
profit: 3112000,
profitMargin: 80.0,
},
{
productId: 18,
name: "White Tapioca Pearls",
category: "topping",
unitsSold: 342,
revenue: 3420000,
costPrice: 2000,
sellingPrice: 10000,
profit: 2736000,
profitMargin: 80.0,
},
];
// ===== SHIFT / SCHEDULE DATA =====
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" },
];
/**
* Generate mock shift slots for the weeks around today (April 2026).
* Covers Mon 6 Apr Sun 26 Apr 2026.
@@ -674,7 +402,7 @@ function generateMockShifts(): ShiftSlot[] {
}
shifts.push({
id: shiftId,
id: "shiftId",
date: dateStr,
startTime: slot.start,
endTime: slot.end,
@@ -693,3 +421,10 @@ function generateMockShifts(): ShiftSlot[] {
}
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" },
];
-45
View File
@@ -1,45 +0,0 @@
import type { Eatery } from "./types";
export async function gqlFetch<T = Record<string, unknown>>(
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const res = await fetch("/api/eatery/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`GraphQL request failed: ${res.status}`);
return res.json() as Promise<T>;
}
export async function addManagerMenuItem(
name: string,
price: number,
): Promise<{ id: string; name: string; price: number } | null> {
const data = await gqlFetch<{
addMenuItem: { id: string; name: string; price: number } | null;
}>(
`mutation($name: String!, $price: Float!) {
addMenuItem(name: $name, price: $price) { id name price }
}`,
{ name, price },
);
return data.addMenuItem;
}
export async function fetchManagerEatery(): Promise<Eatery | null> {
const data = await gqlFetch<{ eateriesByOwner: Eatery | null }>(`
query {
eateriesByOwner {
id
ownerId
name
isVerified
}
}
`);
return data.eateriesByOwner;
}
+134 -101
View File
@@ -1,149 +1,182 @@
"use client";
import { ReactNode, createContext, useContext, useState } from "react";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from "react";
import { MENU_CATEGORIES, MOCK_COMBOS, MOCK_PRODUCTS } from "./constants";
import type { Combo, ComboItem, MenuCategory, Product } from "./types";
import { eateryClient } from "./apollo-clients";
import {
type MenuItemEntity,
type addMenuItemMutation,
type allEateriesQuery,
deleteMenuItemMutation,
updateMenuItemMutation,
} from "./types";
// ─── Types ────────────────────────────────────────────────────────────────────
export type ManagerTab = "products" | "combos" | "categories" | "menu-items";
export type ManagerTab = "products";
interface ManagerContextType {
// Data
products: Product[];
combos: Combo[];
categories: MenuCategory[];
products: MenuItemEntity[];
// Active tab
activeTab: ManagerTab;
setActiveTab: (tab: ManagerTab) => void;
// Product actions
addProduct: (product: Omit<Product, "id">) => void;
updateProduct: (product: Product) => void;
deleteProduct: (id: number) => void;
toggleProductAvailability: (id: number) => void;
// Combo actions
addCombo: (combo: Omit<Combo, "id">) => void;
updateCombo: (combo: Combo) => void;
deleteCombo: (id: number) => void;
toggleComboAvailability: (id: number) => void;
// Category actions
addCategory: (category: Omit<MenuCategory, "id">) => void;
updateCategory: (category: MenuCategory) => void;
deleteCategory: (id: string) => void;
// MenuItemEntity actions
addProduct: (product: MenuItemEntity) => void;
updateProduct: (product: MenuItemEntity) => void;
deleteProduct: (id: string) => void;
toggleProductAvailability: (product: MenuItemEntity) => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const ManagerContext = createContext<ManagerContextType | undefined>(undefined);
// ___ Graphql __________________________________________________________________
const GET_EATERY_MENU = gql`
query GetEateryMenu {
allEateries {
id
menuItems {
id
name
available
description
price
}
}
}
`;
const ADD_MENU_ITEM = gql`
mutation addMenuItem($menuItem: AddMenuItemInput!) {
addMenuItem(menuItem: $menuItem) {
id
name
available
description
price
}
}
`;
const UPDATE_MENU_ITEM = gql`
mutation updateMenuItem($menuItem: UpdateMenuItemInput!) {
updateMenuItem(menuItem: $menuItem) {
id
name
available
description
price
}
}
`;
const DELETE_MENU_ITEM = gql`
mutation deleteMenuItem($input: String!) {
deleteMenuItem(menuItemId: $input)
}
`;
// ─── Provider ─────────────────────────────────────────────────────────────────
export function ManagerProvider({ children }: { children: ReactNode }) {
const [products, setProducts] = useState<Product[]>(MOCK_PRODUCTS);
const [combos, setCombos] = useState<Combo[]>(MOCK_COMBOS);
// Filter out the "all" pseudo-category — managers manage real categories only
const [categories, setCategories] = useState<MenuCategory[]>(
MENU_CATEGORIES.filter((c) => c.id !== "all"),
);
const [products, setProducts] = useState<MenuItemEntity[]>([]);
const [activeTab, setActiveTab] = useState<ManagerTab>("products");
// ── Product actions ──────────────────────────────────────────────────────
const { data } = useQuery<allEateriesQuery>(GET_EATERY_MENU, {
client: eateryClient,
fetchPolicy: "network-only",
});
const addProduct = (product: Omit<Product, "id">) => {
const newProduct: Product = {
...product,
id: Date.now(),
};
setProducts((prev) => [...prev, newProduct]);
const [mutateAddMenuItem] = useMutation<addMenuItemMutation>(ADD_MENU_ITEM, {
client: eateryClient,
});
const [mutateUpdateMenuItem] = useMutation<updateMenuItemMutation>(
UPDATE_MENU_ITEM,
{ client: eateryClient },
);
const [mutateDeleteMenuItem] = useMutation<deleteMenuItemMutation>(
DELETE_MENU_ITEM,
{ client: eateryClient },
);
useEffect(() => {
if (data?.allEateries?.[0]) {
setProducts(data.allEateries[0].menuItems);
}
}, [data]);
// ── MenuItemEntity actions ──────────────────────────────────────────────────────
const addProduct = async (product: MenuItemEntity) => {
const { data } = await mutateAddMenuItem({
variables: {
menuItem: product,
},
});
if (data) setProducts((prev) => [...prev, data.addMenuItem]);
};
const updateProduct = (product: Product) => {
setProducts((prev) => prev.map((p) => (p.id === product.id ? product : p)));
const updateProduct = async (product: MenuItemEntity) => {
const { data } = await mutateUpdateMenuItem({
variables: {
menuItem: product,
},
});
if (data)
setProducts((prev) =>
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
);
};
const deleteProduct = (id: number) => {
setProducts((prev) => prev.filter((p) => p.id !== id));
const deleteProduct = async (id: string) => {
const { data } = await mutateDeleteMenuItem({ variables: { input: id } });
if (data)
setProducts((prev) =>
prev.filter((p) => !(p.id == id && data.deleteMenuItem)),
);
};
const toggleProductAvailability = (id: number) => {
setProducts((prev) =>
prev.map((p) =>
p.id === id ? { ...p, available: !(p.available ?? true) } : p,
),
);
};
const toggleProductAvailability = async (product: MenuItemEntity) => {
const { data } = await mutateUpdateMenuItem({
variables: {
menuItem: { ...product, available: !product.available },
},
});
// ── Combo actions ────────────────────────────────────────────────────────
const addCombo = (combo: Omit<Combo, "id">) => {
const newCombo: Combo = { ...combo, id: Date.now() };
setCombos((prev) => [...prev, newCombo]);
};
const updateCombo = (combo: Combo) => {
setCombos((prev) => prev.map((c) => (c.id === combo.id ? combo : c)));
};
const deleteCombo = (id: number) => {
setCombos((prev) => prev.filter((c) => c.id !== id));
};
const toggleComboAvailability = (id: number) => {
setCombos((prev) =>
prev.map((c) => (c.id === id ? { ...c, available: !c.available } : c)),
);
};
// ── Category actions ─────────────────────────────────────────────────────
const addCategory = (category: Omit<MenuCategory, "id">) => {
const slug = category.name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const newCategory: MenuCategory = {
...category,
id: `${slug}-${Date.now()}`,
};
setCategories((prev) => [...prev, newCategory]);
};
const updateCategory = (category: MenuCategory) => {
setCategories((prev) =>
prev.map((c) => (c.id === category.id ? category : c)),
);
};
const deleteCategory = (id: string) => {
setCategories((prev) => prev.filter((c) => c.id !== id));
if (data)
setProducts((prev) =>
prev.map((p) => (p.id === product.id ? data.updateMenuItem : p)),
);
};
return (
<ManagerContext.Provider
value={{
products,
combos,
categories,
activeTab,
setActiveTab,
addProduct,
updateProduct,
deleteProduct,
toggleProductAvailability,
addCombo,
updateCombo,
deleteCombo,
toggleComboAvailability,
addCategory,
updateCategory,
deleteCategory,
}}
>
{children}
-34
View File
@@ -1,34 +0,0 @@
"use client";
import { createContext, useContext, useState } from "react";
interface MenuContextType {
/** Currently selected category id */
activeCategory: string;
/** Update the active category */
setActiveCategory: (id: string) => void;
}
const MenuContext = createContext<MenuContextType>({
activeCategory: "all",
setActiveCategory: () => {},
});
/**
* Provides shared activeCategory state to both the Header (mobile scrollable menu)
* and the Navbar sidebar (md+), so both always reflect the same selection.
*/
export function MenuProvider({ children }: { children: React.ReactNode }) {
const [activeCategory, setActiveCategory] = useState("all");
return (
<MenuContext.Provider value={{ activeCategory, setActiveCategory }}>
{children}
</MenuContext.Provider>
);
}
/** Consume the shared menu state anywhere inside MenuProvider */
export function useMenu() {
return useContext(MenuContext);
}
+46 -42
View File
@@ -9,24 +9,6 @@ export interface User {
phone?: string;
}
// ===== MENU TYPES =====
export interface MenuCategory {
id: string;
name: string;
icon: string; // FontAwesome class e.g. "fa-solid fa-mug-hot"
}
// ===== PRODUCT TYPES =====
export interface Product {
id: number;
name: string;
category: string; // matches MenuCategory.id
price: number;
image: string;
description: string;
available?: boolean;
}
// ===== SHOP INFO TYPES =====
export interface WifiInfo {
name: string;
@@ -52,14 +34,6 @@ export interface SocialLinks {
website: string;
}
// ===== EATERY (GRAPHQL) TYPES =====
export interface Eatery {
id: string;
ownerId: string;
name: string;
isVerified: boolean;
}
// ===== SHOP (QUÁN NƯỚC) TYPES =====
export interface Shop {
id: number;
@@ -107,22 +81,6 @@ export interface FinancialSummary {
profitComparison: PeriodComparison;
}
// ===== COMBO TYPES =====
export interface ComboItem {
productId: number;
quantity: number;
}
export interface Combo {
id: number;
name: string;
description: string;
price: number;
image: string;
items: ComboItem[]; // list of products + quantities in this combo
available: boolean;
}
// ===== SHIFT / SCHEDULE TYPES =====
export type ShiftStatus =
| "available"
@@ -153,3 +111,49 @@ export interface Department {
name: string;
icon: string; // FontAwesome class
}
export interface MenuItemEntity {
id?: string;
name: string;
price: number;
eatery?: EateryEntity;
imageUrl: string;
available: boolean;
description: string;
}
export interface ShiftEntity {}
export interface EateryEntity {
ownerId: string;
name: string;
menuItems: MenuItemEntity[];
shifts: ShiftEntity[];
}
export interface allEateriesQuery {
allEateries: EateryEntity[];
}
export interface addMenuItemMutation {
addMenuItem: MenuItemEntity;
}
export interface updateMenuItemMutation {
updateMenuItem: MenuItemEntity;
}
export interface deleteMenuItemMutation {
deleteMenuItem: boolean;
}
export interface CartItemEntity {
productId: string;
quantity: number;
priceAtTimeOfAdding: number;
}
export interface CartEntity {
Id: string;
items: CartItemEntity[];
}
+1 -1
View File
@@ -20,7 +20,7 @@ const nextConfig: NextConfig = {
],
},
async rewrites() {
const gatewayUrl = "http://localhost:32080";
const gatewayUrl = "http://host.docker.internal:32080";
return [
{
source: "/api/:path*",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "temp",
"version": "1.2.3",
"version": "1.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "temp",
"version": "1.2.3",
"version": "1.2.4",
"dependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^20.19.37",
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "temp",
"version": "1.2.3",
"version": "1.2.4",
"private": true,
"scripts": {
"dev": "next dev",
@@ -11,14 +11,16 @@
"release": "semantic-release"
},
"dependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@apollo/client": "^4.1.9",
"@tailwindcss/postcss": "^4.2.4",
"@types/node": "^20.19.39",
"@types/react": "^19.2.14",
"graphql": "^16.14.0",
"next": "16.1.7",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind": "^4.0.0",
"tailwindcss": "^4.2.2",
"tailwindcss": "^4.2.4",
"typescript": "^5.9.3"
},
"devDependencies": {
@@ -32,7 +34,7 @@
"prettier": "^3.8.3",
"prettier-plugin-embed": "^0.5.1",
"prettier-plugin-groovy": "^0.2.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"prettier-plugin-tailwindcss": "^0.7.4",
"semantic-release": "^25.0.3"
}
}
+363 -284
View File
File diff suppressed because it is too large Load Diff