From ae8134fd64184cd531613f87c4a71195acf8fa90 Mon Sep 17 00:00:00 2001 From: TakahashiNguyen Date: Wed, 13 May 2026 06:29:14 +0000 Subject: [PATCH] fix: manager login and menu management (#38) Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com> Reviewed-on: https://git.demonkernel.io.vn/FoodSurf/frontend/pulls/38 --- .devcontainer/devcontainer-lock.json | 9 + .devcontainer/devcontainer.json | 5 +- .gitea/workflows/release.yaml | 2 +- app/(main)/layout.tsx | 7 +- app/(main)/login/password/page.tsx | 2 +- app/(main)/page.tsx | 61 +-- app/(main)/payment/page.tsx | 124 ++--- app/(manager)/manager/analytics/page.tsx | 428 ------------------ app/(manager)/manager/page.tsx | 27 +- app/providers.tsx | 5 +- components/molecules/cards/Card.types.ts | 2 - components/organisms/analytics/BarChart.tsx | 209 --------- components/organisms/analytics/LineChart.tsx | 191 -------- components/organisms/analytics/PieChart.tsx | 124 ----- .../organisms/analytics/ProductTable.tsx | 145 ------ .../organisms/analytics/SummaryCard.tsx | 61 --- components/organisms/analytics/index.ts | 7 - components/organisms/index.ts | 6 - .../organisms/manager/CategoriesTab.tsx | 112 ----- .../organisms/manager/CategoryModal.tsx | 123 ----- components/organisms/manager/ComboModal.tsx | 237 ---------- components/organisms/manager/CombosTab.tsx | 149 ------ components/organisms/manager/Manager.types.ts | 20 +- components/organisms/manager/MenuItemsTab.tsx | 155 +++---- components/organisms/manager/ProductModal.tsx | 60 +-- components/organisms/manager/ProductsTab.tsx | 54 +-- components/organisms/manager/index.ts | 6 - .../organisms/navigation/CategorySidebar.tsx | 39 +- .../organisms/product-grid/ProductGrid.tsx | 38 +- lib/apollo-clients.ts | 52 +++ lib/cart-context.tsx | 57 ++- lib/constants.ts | 391 +++------------- lib/graphql.ts | 45 -- lib/manager-context.tsx | 235 +++++----- lib/menu-context.tsx | 34 -- lib/types.ts | 88 ++-- next.config.ts | 2 +- package.json | 2 + pnpm-lock.yaml | 151 ++++-- 39 files changed, 674 insertions(+), 2791 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json delete mode 100644 app/(manager)/manager/analytics/page.tsx delete mode 100644 components/organisms/analytics/BarChart.tsx delete mode 100644 components/organisms/analytics/LineChart.tsx delete mode 100644 components/organisms/analytics/PieChart.tsx delete mode 100644 components/organisms/analytics/ProductTable.tsx delete mode 100644 components/organisms/analytics/SummaryCard.tsx delete mode 100644 components/organisms/analytics/index.ts delete mode 100644 components/organisms/manager/CategoriesTab.tsx delete mode 100644 components/organisms/manager/CategoryModal.tsx delete mode 100644 components/organisms/manager/ComboModal.tsx delete mode 100644 components/organisms/manager/CombosTab.tsx create mode 100644 lib/apollo-clients.ts delete mode 100644 lib/graphql.ts delete mode 100644 lib/menu-context.tsx diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..dc39919 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -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" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3f57804..2d578df 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,7 +13,10 @@ }, "customizations": { "vscode": { - "extensions": ["esbenp.prettier-vscode"] + "extensions": [ + "esbenp.prettier-vscode", + "anthropic.claude-code" + ] } }, diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 954eacc..b4aeb7a 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -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 diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 2a6f476..3eea42f 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -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 {children}; + return ( + + {children} + + ); } diff --git a/app/(main)/login/password/page.tsx b/app/(main)/login/password/page.tsx index 6fa4c78..3ec28ac 100644 --- a/app/(main)/login/password/page.tsx +++ b/app/(main)/login/password/page.tsx @@ -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) { diff --git a/app/(main)/page.tsx b/app/(main)/page.tsx index 295557e..b8fc64a 100644 --- a/app/(main)/page.tsx +++ b/app/(main)/page.tsx @@ -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( + 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
Loading...
; + if (error) return
Error: {error.message}
; return (
- {/* ── Sidebar ── */} - setIsSidebarOpen((prev) => !prev)} - activeCategory={activeCategory} - onCategoryChange={setActiveCategory} - /> - {/* ── Main content ── */}
{/* ── Section heading + search bar ── */}
- {/* Title + count */} -
-

- {activeCategoryLabel} -

-
- {/* Search bar */} { - if (q && activeCategory !== "all") setActiveCategory("all"); setSearchQuery(q); }} onClear={() => setSearchQuery("")} diff --git a/app/(main)/payment/page.tsx b/app/(main)/payment/page.tsx index edbfd09..0888779 100644 --- a/app/(main)/payment/page.tsx +++ b/app/(main)/payment/page.tsx @@ -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() { - {items.map((item) => ( - - - {item.name} - - - {formatPrice(item.price)} - - -

{item.description}

- - -
- - - 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" - /> - -
- - - - - - ))} + + {name} + + + {formatPrice(price)} + + +

{description}

+ + +
+ + + 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" + /> + +
+ + + + + + ); + }, + )}
diff --git a/app/(manager)/manager/analytics/page.tsx b/app/(manager)/manager/analytics/page.tsx deleted file mode 100644 index 3d86c5b..0000000 --- a/app/(manager)/manager/analytics/page.tsx +++ /dev/null @@ -1,428 +0,0 @@ -"use client"; - -import { - BarChart, - LineChart, - PieChart, - ProductTable, - SummaryCard, -} from "@/components/organisms/analytics"; -import type { PieSlice } from "@/components/organisms/analytics"; -import { - calcChange, - formatCurrency, - formatCurrencyFull, -} from "@/lib/analytics-utils"; -import { - MENU_CATEGORIES, - MOCK_PRODUCT_SALES, - MOCK_REVENUE_DAILY, - MOCK_REVENUE_MONTHLY, - MOCK_REVENUE_WEEKLY, - MOCK_REVENUE_YEARLY, -} from "@/lib/constants"; -import type { AnalyticsPeriod, RevenueDataPoint } from "@/lib/types"; -import Link from "next/link"; -import { useMemo, useState } from "react"; - -// ─── Constants ──────────────────────────────────────────────────────────────── - -const PERIOD_LABELS: Record = { - 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 = { - 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 = { - 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 ( -
- - -
- ); -} - -// ─── Main Page ──────────────────────────────────────────────────────────────── - -export default function AnalyticsPage() { - const [period, setPeriod] = useState("month"); - const [activeChart, setActiveChart] = useState("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 = {}; - 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 ( -
- {/* ── Page Header ── */} -
-
- - - -
- - - -
-

- Financial Statistics & Analytics -

-

- Financial Analytics Dashboard -

-
-
- - {/* Period selector */} -
- {(Object.keys(PERIOD_LABELS) as AnalyticsPeriod[]).map((p) => ( - - ))} - -
-
-
- -
- {/* ── Summary Cards ── */} -
-

- Overview -

-
- - - - -
-
- - {/* ── Revenue Chart ── */} -
-
-

- - Revenue Chart -

-
- {CHART_TYPES.map((t) => ( - - ))} -
-
- - {activeChart === "line" && ( - <> -

- Revenue over time — {PERIOD_LABELS[period]} -

- - - )} - {activeChart === "bar" && ( - <> -

- Comparing revenue of the first and second half of the current - period -

- - - )} - {activeChart === "pie" && ( - <> -

- Revenue share by product category -

- - - )} -
- - {/* ── Top 5 Products ── */} -
-
-

- - Top best-selling products -

- -
-
- {top5.map((p, i) => { - const pct = (p.revenue / top5[0].revenue) * 100; - return ( -
-
-
- - {i + 1} - - - {p.name} - -
-
- - {p.unitsSold} cups - - - {formatCurrency(p.revenue)} - -
-
-
-
-
-
- ); - })} -
-
- - {/* ── Full Product Table ── */} -
-
-

- - Detailed product analytics -

- -
-

- Click column headers to sort. Showing {filteredSales.length}{" "} - products. -

- - - {/* Summary row */} -
-
- Total revenue: - - {formatCurrencyFull(filteredRevenue)} - -
-
- Total profit: - - {formatCurrencyFull(filteredProfit)} - -
-
- Total units: - - {filteredUnits.toLocaleString()} cups - -
-
- - Avg. profit margin:{" "} - - - {avgMargin.toFixed(1)}% - -
-
-
-
-
- ); -} diff --git a/app/(manager)/manager/page.tsx b/app/(manager)/manager/page.tsx index 78bf7c5..618be3a 100644 --- a/app/(manager)/manager/page.tsx +++ b/app/(manager)/manager/page.tsx @@ -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() {
{activeTab === "products" && } - {activeTab === "combos" && } - {activeTab === "categories" && } - {activeTab === "menu-items" && }
diff --git a/app/providers.tsx b/app/providers.tsx index e700781..ccab68e 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -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 ( - - {children} - + {children} ); } diff --git a/components/molecules/cards/Card.types.ts b/components/molecules/cards/Card.types.ts index 0211080..2c60a37 100644 --- a/components/molecules/cards/Card.types.ts +++ b/components/molecules/cards/Card.types.ts @@ -1,5 +1,3 @@ -import type { Product } from "@/lib/types"; - export interface ProductCardProps { image: string; imageAlt?: string; diff --git a/components/organisms/analytics/BarChart.tsx b/components/organisms/analytics/BarChart.tsx deleted file mode 100644 index fa49e84..0000000 --- a/components/organisms/analytics/BarChart.tsx +++ /dev/null @@ -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 ( -
- setHovered(null)} - > - {gridLines.map((g, i) => ( - - - - {formatCurrency(g.val)} - - - ))} - - {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 ( - - setHovered({ set: "prev", idx: i })} - /> - setHovered({ set: "cur", idx: i })} - /> - {i % step === 0 && ( - - {d.label} - - )} - - ); - })} - - {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 ( - - - - {d.label} ({hovered.set === "cur" ? "Hiện tại" : "Trước"}) - - - {formatCurrency(d.revenue)} - - - {d.orders} đơn hàng - - - ); - })()} - - {/* Legend */} - - - Hiện tại - - - - Kỳ trước - - -
- ); -} diff --git a/components/organisms/analytics/LineChart.tsx b/components/organisms/analytics/LineChart.tsx deleted file mode 100644 index a110cf8..0000000 --- a/components/organisms/analytics/LineChart.tsx +++ /dev/null @@ -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(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 ( -
- setHovered(null)} - > - - - - - - - - {gridLines.map((g, i) => ( - - - - {formatCurrency(g.val)} - - - ))} - - - - - {points.map((p, i) => - i % step === 0 ? ( - - {p.data.label} - - ) : null, - )} - - {points.map((p) => ( - 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 ( - - - - {p.data.label} - - - {formatCurrency(p.data.revenue)} - - - {p.data.orders} đơn - - - ); - })()} - -
- ); -} diff --git a/components/organisms/analytics/PieChart.tsx b/components/organisms/analytics/PieChart.tsx deleted file mode 100644 index e096084..0000000 --- a/components/organisms/analytics/PieChart.tsx +++ /dev/null @@ -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(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[]; 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, 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 ( -
- setHovered(null)} - > - {slices.map((s) => ( - setHovered(s.index)} - opacity={hovered !== null && hovered !== s.index ? 0.65 : 1} - /> - ))} - {hovered !== null && ( - - {slices[hovered].percent.toFixed(1)}% - - )} - - - {/* Legend */} -
- {slices.map((s) => ( -
setHovered(s.index)} - onMouseLeave={() => setHovered(null)} - > - - - {s.label} - - - {s.percent.toFixed(1)}% - -
- ))} -
-
- ); -} diff --git a/components/organisms/analytics/ProductTable.tsx b/components/organisms/analytics/ProductTable.tsx deleted file mode 100644 index d9f9a12..0000000 --- a/components/organisms/analytics/ProductTable.tsx +++ /dev/null @@ -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("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) => ( - - ); - - const SortTh = ({ - col, - label, - className = "", - }: { - col: keyof ProductSalesStats; - label: string; - className?: string; - }) => ( - handleSort(col)} - > - {label} {sortIcon(col)} - - ); - - return ( -
- - - - - - - - - - - - - - - - {sorted.map((row, i) => ( - - - - - - - - - - - - ))} - -
- # - - Sản phẩm - - Danh mục - - Giá nhập - - Giá bán -
{i + 1} - {row.name} - - - {categoryName(row.category)} - - - {row.unitsSold.toLocaleString()} - - {formatCurrencyFull(row.revenue)} - - {formatCurrencyFull(row.costPrice)} - - {formatCurrencyFull(row.sellingPrice)} - - {formatCurrencyFull(row.profit)} - - = 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)}% - -
-
- ); -} diff --git a/components/organisms/analytics/SummaryCard.tsx b/components/organisms/analytics/SummaryCard.tsx deleted file mode 100644 index bded36a..0000000 --- a/components/organisms/analytics/SummaryCard.tsx +++ /dev/null @@ -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 ( -
-
- - - - - {title} - -
-

{value}

- {subtitle && ( -

{subtitle}

- )} -
- - - {isPositive ? "+" : ""} - {changePercent.toFixed(1)}% - - - ({isPositive ? "+" : ""} - {formatCurrency(change)}) so với kỳ trước - -
-
- ); -} diff --git a/components/organisms/analytics/index.ts b/components/organisms/analytics/index.ts deleted file mode 100644 index 687d68e..0000000 --- a/components/organisms/analytics/index.ts +++ /dev/null @@ -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"; diff --git a/components/organisms/index.ts b/components/organisms/index.ts index 6c3c553..ff8605e 100644 --- a/components/organisms/index.ts +++ b/components/organisms/index.ts @@ -23,16 +23,10 @@ export { StatusBadge, DeleteConfirm, ProductModal, - CategoryModal, - ComboModal, ProductsTab, - CategoriesTab, - CombosTab, } from "./manager"; export type { ProductModalProps, - CategoryModalProps, - ComboModalProps, DeleteConfirmProps, StatusBadgeProps, } from "./manager"; diff --git a/components/organisms/manager/CategoriesTab.tsx b/components/organisms/manager/CategoriesTab.tsx deleted file mode 100644 index c14e392..0000000 --- a/components/organisms/manager/CategoriesTab.tsx +++ /dev/null @@ -1,112 +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(null); - - const getProductCount = (catId: string) => - products.filter((p) => p.category === catId).length; - - return ( -
-
-

- {categories.length}{" "} - categories -

- -
- -
- {categories.map((cat) => { - const count = getProductCount(cat.id); - return ( -
-
- -
-
-

- {cat.name} -

-

- {count} items -

-
-
- - -
-
- ); - })} - - {categories.length === 0 && ( -
- -

No categories yet

-
- )} -
- - {modalCategory !== null && ( - { - if ("id" in data) { - updateCategory(data as MenuCategory); - } else { - addCategory(data); - } - setModalCategory(null); - }} - onClose={() => setModalCategory(null)} - /> - )} - - {deleteTarget !== null && ( - { - deleteCategory(deleteTarget.id); - setDeleteTarget(null); - }} - onClose={() => setDeleteTarget(null)} - /> - )} -
- ); -} diff --git a/components/organisms/manager/CategoryModal.tsx b/components/organisms/manager/CategoryModal.tsx deleted file mode 100644 index 92c618c..0000000 --- a/components/organisms/manager/CategoryModal.tsx +++ /dev/null @@ -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>({ - 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 ( -
e.target === e.currentTarget && onClose()} - > -
-
-

- {isEdit ? "Edit category" : "Add new category"} -

- -
- -
-
- - 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" - /> -
- -
- -
- {FA_ICONS.map((icon) => ( - - ))} -
-
- -
- - -
-
-
-
- ); -} diff --git a/components/organisms/manager/ComboModal.tsx b/components/organisms/manager/ComboModal.tsx deleted file mode 100644 index c8e65cd..0000000 --- a/components/organisms/manager/ComboModal.tsx +++ /dev/null @@ -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>({ - 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 ( -
e.target === e.currentTarget && onClose()} - > -
-
-

- {isEdit ? "Chỉnh sửa combo" : "Thêm combo mới"} -

- -
- -
-
-
- - setForm({ ...form, name: e.target.value })} - className={inputCls} - placeholder="Ví dụ: Combo Cà Phê Đôi" - /> -
- -
- - - setForm({ ...form, price: Number(e.target.value) }) - } - className={inputCls} - /> -
- -
- -