diff --git a/.env b/.env index 0dccbab..30b1b74 100644 --- a/.env +++ b/.env @@ -4,7 +4,7 @@ # Set to true to use mock data (no backend required) # Set to false when the real backend is ready -VITE_USE_MOCK=false +VITE_USE_MOCK=true # Backend API base URL (used when VITE_USE_MOCK=false) VITE_API_BASE_URL=https://revive-backend-production-93ea.up.railway.app/ diff --git a/src/components/Dashboard/ChefMenuView.jsx b/src/components/Dashboard/ChefMenuView.jsx index c212a3a..525128e 100644 --- a/src/components/Dashboard/ChefMenuView.jsx +++ b/src/components/Dashboard/ChefMenuView.jsx @@ -1,77 +1,51 @@ import { useState } from "react"; +import { useNavigate } from "react-router"; import DashboardHeader from "./DashboardHeader"; import TrendingMenus from "./TrendingMenus"; -import { useMenuCategories, useMenuItems, useCreateMenuItem, useUpdateMenuItem, useDeleteMenuItem } from "../../hooks/dashboard/useMenuItems"; +import { + useMenuCategories, + useMenuItems, + useDeleteMenuItem, +} from "../../hooks/dashboard/useMenuItems"; import { useTrendingMenus } from "../../hooks/dashboard/useDashboard"; -import { FiEdit2, FiCheck, FiX, FiPlus, FiTrash2 } from "react-icons/fi"; +import { FiEdit2, FiPlus, FiTrash2 } from "react-icons/fi"; import { useToast } from "../../store/toastStore"; import { DashboardPageSkeleton } from "./shared/DashboardSkeleton"; import ErrorState from "./shared/ErrorState"; import EmptyState from "./shared/EmptyState"; import SortMenu from "./shared/SortMenu"; -import MenuModal from "./shared/MenuModal"; import ConfirmModal from "./shared/ConfirmModal"; import DishDetailsModal from "./shared/DishDetailsModal"; - -/** Circular progress metric card — green ring, black name, orange count, red/green trend */ -function CircleMetric({ name, percentage, count, change, isTotal }) { - const r = isTotal ? 30 : 26; - const circ = 2 * Math.PI * r; - const sz = isTotal ? 76 : 68; - const cx = sz / 2; - const offset = circ - (Math.min(percentage, 100) / 100) * circ; - const ringColor = "#22C55E"; // always green - - return ( -
- {/* Ring */} -
- - - - -
- {percentage}% -
-
- {/* Text */} -
-

{name}

-

{count}

- {change !== undefined && ( -

= 0 ? "text-green-500" : "text-red-400"}`}> - {change >= 0 ? "↑" : "↓"}{Math.abs(change).toFixed(2)}% -

- )} -
-
- ); -} +import MetricRingCard from "./shared/MetricRingCard"; +import InactiveMenuModal from "./shared/InactiveMenuModal"; +import { sortItems } from "../../utils/sortItems"; + +// ── Sort columns — defined outside the component so they are never recreated ── +const MENU_SORT_COLS = [ + { key: "name", label: "Meal" }, + { key: "category", label: "Category" }, + { key: "fat", label: "Fat" }, + { key: "calories", label: "Calories" }, + { key: "protein", label: "Protein" }, + { key: "sugar", label: "Sugar" }, + { key: "price", label: "Price" }, +]; + +const TABLE_HEADERS = ["Meal", "Category", "Fat", "Cal", "Pro", "Sug", "Price", "Actions"]; function ChefMenuView() { const [activeTab, setActiveTab] = useState("All Menu"); const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState("asc"); - - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingItem, setEditingItem] = useState(null); const [deletingId, setDeletingId] = useState(null); const [viewingItem, setViewingItem] = useState(null); + const [isInactiveModalOpen, setIsInactiveModalOpen] = useState(false); + const navigate = useNavigate(); const { addToast } = useToast(); - const { mutate: createItem } = useCreateMenuItem(); - const { mutate: updateItem } = useUpdateMenuItem(); const { mutate: deleteItem } = useDeleteMenuItem(); + // ── CRUD handlers ────────────────────────────────────────────────────────── const confirmDelete = () => { if (!deletingId) return; deleteItem(deletingId, { @@ -81,40 +55,15 @@ function ChefMenuView() { setDeletingId(null); }; - const handleModalSubmit = (formData) => { - if (editingItem) { - updateItem( - { id: editingItem.id, data: formData }, - { - onSuccess: () => { addToast("Menu item updated!", "success"); setIsModalOpen(false); }, - onError: () => addToast("Failed to update item", "error") - } - ); - } else { - createItem(formData, { - onSuccess: () => { addToast("Menu item added!", "success"); setIsModalOpen(false); }, - onError: () => addToast("Failed to add item", "error") - }); - } - }; - - const MENU_SORT_COLS = [ - { key: "name", label: "Meal" }, - { key: "category", label: "Category" }, - { key: "fat", label: "Fat" }, - { key: "calories", label: "Calories" }, - { key: "protein", label: "Protein" }, - { key: "sugar", label: "Sugar" }, - { key: "price", label: "Price" }, - ]; - - const { data: categories, isLoading: loadCats, error: errCats } = useMenuCategories(); - const { data: menuItems, isLoading: loadItems, error: errItems } = useMenuItems(); - const { data: trending, isLoading: loadTrend } = useTrendingMenus(); + // ── Data fetching ────────────────────────────────────────────────────────── + const { data: categories, isLoading: loadCats, error: errCats, refetch: refetchCats } = useMenuCategories(); + const { data: menuItems, isLoading: loadItems, error: errItems, refetch: refetchItems } = useMenuItems(); + const { data: trending, isLoading: loadTrend } = useTrendingMenus(); const isLoading = loadCats || loadItems || loadTrend; - const hasError = errCats || errItems; + const hasError = errCats || errItems; + // ── Loading / error states ───────────────────────────────────────────────── if (isLoading) { return (
@@ -125,94 +74,79 @@ function ChefMenuView() { } if (hasError) { + // Retry both failed queries without reloading the page + const handleRetry = () => { refetchCats(); refetchItems(); }; return (
- window.location.reload()} /> +
); } - // Combine live mock data + // ── Derived data ─────────────────────────────────────────────────────────── const allItems = menuItems || []; - const totalMeals = allItems.length; + const activeItems = allItems.filter(item => item.image); + const inactiveItems = allItems.filter(item => !item.image); + const totalMeals = activeItems.length; - // Destructure new categories shape: { totalChange, totalPercentage, items[] } const totalChange = categories?.totalChange ?? 0; const totalPercentage = categories?.totalPercentage ?? 100; const categoryItems = categories?.items || []; - // Per-category counts — use live item count from actual menu data - const categoryCounts = categoryItems.map(cat => ({ + // Per-category counts — reconcile API count with live item data + const categoryCounts = categoryItems.map((cat) => ({ ...cat, - count: allItems.filter(i => i.category?.toLowerCase() === cat.name?.toLowerCase()).length || cat.count, + // Use the live match count directly; 0 is a valid value and must not + // fall back to the backend's stale count. + count: activeItems.filter( + (i) => i.category?.toLowerCase() === cat.name?.toLowerCase() + ).length, })); - // Build tabs dynamically - const CATEGORY_TABS = ["All Menu", ...new Set(allItems.map(i => i.category).filter(Boolean))]; + // Category tabs built from live item data + const CATEGORY_TABS = ["All Menu", ...new Set(activeItems.map((i) => i.category).filter(Boolean))]; - const filtered = (() => { - let items = allItems.filter((item) => { - if (activeTab === "All Menu") return true; - return item.category?.toLowerCase() === activeTab.toLowerCase(); - }); - if (sortKey) { - items.sort((a, b) => { - const av = a[sortKey] ?? ""; - const bv = b[sortKey] ?? ""; - const parseNum = (val) => { - if (typeof val === "number") return val; - const match = String(val).match(/[\d.]+/); - return match ? parseFloat(match[0]) : NaN; - }; - const aNum = parseNum(av); - const bNum = parseNum(bv); - let cmp; - if (!isNaN(aNum) && !isNaN(bNum)) { - cmp = aNum - bNum; - } else { - cmp = String(av).localeCompare(String(bv)); - } - return sortDir === "asc" ? cmp : -cmp; - }); - } - return items; - })(); + // Filter then sort using shared utility + const tabFiltered = activeTab === "All Menu" + ? activeItems + : activeItems.filter((item) => item.category?.toLowerCase() === activeTab.toLowerCase()); + + const filtered = sortItems(tabFiltered, sortKey, sortDir); return (
- {/* ── Metric cards — full-width horizontal row above everything ── */} + {/* ── Metric ring cards — horizontal scrollable row ── */}
- {categoryCounts.map((cat) => ( - ))}
- {/* ── Left/Main ── */} + {/* ── Main table area ── */}
- - {/* ── Table card ── */}
- {/* Tab bar + Sort */} + {/* Tab bar + sort */}
{CATEGORY_TABS.map((tab) => ( @@ -220,22 +154,34 @@ function ChefMenuView() { type="button" key={tab} onClick={() => setActiveTab(tab)} - className={`px-4 py-2 text-[13px] font-semibold rounded-full border-none cursor-pointer transition-all whitespace-nowrap ${ - activeTab === tab - ? "bg-orange-500 text-white shadow-sm" - : "bg-transparent text-gray-500 hover:text-orange-500" - }`} + className={`px-4 py-2 text-[13px] font-semibold rounded-full border-none cursor-pointer transition-all whitespace-nowrap ${activeTab === tab + ? "bg-orange-500 text-white shadow-sm" + : "bg-transparent text-gray-500 hover:text-orange-500" + }`} > {tab} ))}
- { setSortKey(k); setSortDir(d); }} - /> +
+ + { setSortKey(k); setSortDir(d); }} + /> +
{/* Table */} @@ -243,7 +189,7 @@ function ChefMenuView() { - {["Meal", "Category", "Fat", "Cal", "Pro", "Sug", "Price", "Actions"].map((h) => ( + {TABLE_HEADERS.map((h) => ( @@ -252,77 +198,81 @@ function ChefMenuView() { {filtered.length === 0 && ( - + + + )} - {filtered.map((item, i) => { - - return ( - setViewingItem(item)} - > - setViewingItem(item)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setViewingItem(item); } }} + > + - - - - - - - - - ); - })} + + + + + + + + + + + ))}
{h === "Actions" ? "" : h}
+ +
-
-
- {item.image && ( - {item.name} { e.currentTarget.style.display = "none"; }} /> - )} -
-
-

{item.name}

-

{item.category}

- Click to view details → -
+ {filtered.map((item, i) => ( +
+
+
+ {item.image && ( + {item.name} { e.currentTarget.style.display = "none"; }} + /> + )}
-
{item.category}{item.fat || "-"}{item.calories || "-"}{item.protein || "-"}{item.sugar || "-"}${item.price} -
- - +
+

{item.name}

+

{item.category}

+ + Click to view details → +
-
{item.category}{item.fat || "-"}{item.calories || "-"}{item.protein || "-"}{item.sugar || "-"}${item.price} +
+ + +
+
-
- {/* Floating + Button */} - -
@@ -332,12 +282,7 @@ function ChefMenuView() {
- setIsModalOpen(false)} - onSubmit={handleModalSubmit} - initialData={editingItem} - /> + {/* ── Modals ── */} - setViewingItem(null)} dish={viewingItem} /> + + setIsInactiveModalOpen(false)} + inactiveItems={inactiveItems} + /> ); } diff --git a/src/components/Dashboard/IngredientsView.jsx b/src/components/Dashboard/IngredientsView.jsx index 0264a9f..1099a14 100644 --- a/src/components/Dashboard/IngredientsView.jsx +++ b/src/components/Dashboard/IngredientsView.jsx @@ -1,8 +1,15 @@ import { useState } from "react"; import DashboardHeader from "./DashboardHeader"; -import { useIngredientsMetrics, useIngredients, useUploadIngredients, useDeleteIngredient, useCreateIngredient, useUpdateIngredient } from "../../hooks/dashboard/useIngredients"; +import { + useIngredientsMetrics, + useIngredients, + useUploadIngredients, + useDeleteIngredient, + useCreateIngredient, + useUpdateIngredient, +} from "../../hooks/dashboard/useIngredients"; import { useToast } from "../../store/toastStore"; -import { FiSearch, FiPlus, FiUploadCloud, FiEdit2 } from "react-icons/fi"; +import { FiSearch, FiPlus, FiUploadCloud, FiEdit2, FiTrash2 } from "react-icons/fi"; import { DashboardPageSkeleton } from "./shared/DashboardSkeleton"; import ErrorState from "./shared/ErrorState"; import EmptyState from "./shared/EmptyState"; @@ -10,69 +17,43 @@ import StatusBadge from "./shared/StatusBadge"; import SortMenu from "./shared/SortMenu"; import IngredientModal from "./shared/IngredientModal"; import ConfirmModal from "./shared/ConfirmModal"; - -function CircleMetric({ pct, color, value, label, badge, change = 0 }) { - const r = 20; - const circ = 2 * Math.PI * r; - const offset = circ - (pct / 100) * circ; - const mainColor = badge ? "#EF4444" : "#F97316"; // Orange or Red for numbers - const ringColor = badge ? "#EF4444" : "#22C55E"; // Green or Red for ring - - return ( -
- {/* Left: Ring */} -
- - - - - {pct}% -
- - {/* Right: Text Data */} -
-

{label}

-

{value}

-

- - - - - {Math.abs(change).toFixed(2)}% -

-
-
- ); -} - -// Categories are derived dynamically inside the component +import MetricRingCard from "./shared/MetricRingCard"; +import { sortItems } from "../../utils/sortItems"; + +// ── Sort columns — defined outside the component so they are never recreated ── +const ING_SORT_COLS = [ + { key: "name", label: "Name" }, + { key: "category", label: "Category" }, + { key: "fat", label: "Fat" }, + { key: "calories", label: "Calories" }, + { key: "protein", label: "Protein" }, + { key: "sugar", label: "Sugar" }, + { key: "stock", label: "Stock" }, + { key: "costPerUnit", label: "Price" }, +]; + +// Table column headers (9 total) +const TABLE_HEADERS = ["Name", "Category", "Fat", "Cal", "Pro", "Sug", "Stock", "Price", "Actions"]; function IngredientsView() { const [activeCategory, setActiveCategory] = useState("All Ingredients"); const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState("asc"); - - const ING_SORT_COLS = [ - { key: "name", label: "Name" }, - { key: "category", label: "Category" }, - { key: "fat", label: "Fat" }, - { key: "calories", label: "Calories" }, - { key: "protein", label: "Protein" }, - { key: "sugar", label: "Sugar" }, - { key: "stock", label: "Stock" }, - { key: "costPerUnit",label: "Price" }, - ]; - - const { addToast } = useToast(); - - const { data: metrics, isLoading: loadMetrics } = useIngredientsMetrics(); const [isModalOpen, setIsModalOpen] = useState(false); const [editingIngredient, setEditingIngredient] = useState(null); const [deletingId, setDeletingId] = useState(null); const [selectedFile, setSelectedFile] = useState(null); - const { data: ingredients, isLoading: loadIngredients, error } = useIngredients(); + const { addToast } = useToast(); + + const { data: metrics, isLoading: loadMetrics } = useIngredientsMetrics(); + const { + data: ingredients, + isLoading: loadIngredients, + error, + refetch, + } = useIngredients(); + const { mutate: uploadFile, isPending: isUploading } = useUploadIngredients(); const { mutate: deleteIngredient } = useDeleteIngredient(); const { mutate: createIngredient } = useCreateIngredient(); @@ -80,33 +61,30 @@ function IngredientsView() { const isLoading = loadMetrics || loadIngredients; + // ── File handlers ────────────────────────────────────────────────────────── const handleFileUpload = (e) => { const file = e.target.files[0]; if (!file) return; setSelectedFile(file); - e.target.value = ""; + e.target.value = ""; // reset input so the same file can be re-selected }; const handleSubmitFile = () => { if (!selectedFile) return; uploadFile(selectedFile, { - onSuccess: () => { - addToast("Ingredients updated successfully!", "success"); - setSelectedFile(null); - }, - onError: () => addToast("Failed to upload ingredients.", "error"), + onSuccess: () => { addToast("Ingredients updated successfully!", "success"); setSelectedFile(null); }, + onError: () => addToast("Failed to upload ingredients.", "error"), }); }; - const handleDelete = (id) => { - setDeletingId(id); - }; + // ── CRUD handlers ────────────────────────────────────────────────────────── + const handleDelete = (id) => setDeletingId(id); const confirmDelete = () => { if (!deletingId) return; deleteIngredient(deletingId, { onSuccess: () => addToast("Ingredient deleted", "success"), - onError: () => addToast("Failed to delete ingredient", "error"), + onError: () => addToast("Failed to delete ingredient", "error"), }); setDeletingId(null); }; @@ -117,17 +95,22 @@ function IngredientsView() { { id: editingIngredient.id, data: formData }, { onSuccess: () => { addToast("Ingredient updated!", "success"); setIsModalOpen(false); }, - onError: () => addToast("Failed to update ingredient", "error") + onError: () => addToast("Failed to update ingredient", "error"), } ); } else { createIngredient(formData, { onSuccess: () => { addToast("Ingredient added!", "success"); setIsModalOpen(false); }, - onError: () => addToast("Failed to add ingredient", "error") + onError: () => addToast("Failed to add ingredient", "error"), }); } }; + const openAddModal = () => { setEditingIngredient(null); setIsModalOpen(true); }; + const openEditModal = (item) => { setEditingIngredient(item); setIsModalOpen(true); }; + const closeModal = () => setIsModalOpen(false); + + // ── Loading / error states ───────────────────────────────────────────────── if (isLoading) { return (
@@ -141,61 +124,51 @@ function IngredientsView() { return (
- window.location.reload()} /> + {/* Use refetch instead of full page reload so state is preserved */} +
); } + // ── Derived data ─────────────────────────────────────────────────────────── const allIngredients = ingredients || []; - const categories = [...new Set(allIngredients.map((i) => i.category))]; - const categoryTabs = ["All Ingredients", ...categories.filter(Boolean)]; - - const filtered = (() => { - let items = allIngredients.filter((item) => { - const matchCat = - activeCategory === "All Ingredients" || - (item.category && item.category.toLowerCase() === activeCategory.toLowerCase()); - return matchCat; - }); - if (sortKey) { - items.sort((a, b) => { - const av = a[sortKey] ?? ""; - const bv = b[sortKey] ?? ""; - - const parseNum = (val) => { - if (typeof val === "number") return val; - const match = String(val).match(/[\d.]+/); - return match ? parseFloat(match[0]) : NaN; - }; - - const aNum = parseNum(av); - const bNum = parseNum(bv); - - let cmp; - if (!isNaN(aNum) && !isNaN(bNum)) { - cmp = aNum - bNum; - } else { - cmp = String(av).localeCompare(String(bv)); - } + const categories = [...new Set(allIngredients.map((i) => i.category))]; + const categoryTabs = ["All Ingredients", ...categories.filter(Boolean)]; - return sortDir === "asc" ? cmp : -cmp; - }); - } - return items; - })(); + const getCategoryCount = (catName) => + allIngredients.filter((i) => i.category === catName).length; + + const getCategoryPct = (catName) => + allIngredients.length + ? Math.round((getCategoryCount(catName) / allIngredients.length) * 100) + : 0; - const getCategoryCount = (catName) => allIngredients.filter(i => i.category === catName).length; - const getCategoryPct = (catName) => allIngredients.length ? Math.round((getCategoryCount(catName) / allIngredients.length) * 100) : 0; + const outOfStockCount = allIngredients.filter( + (i) => i.stock === 0 || i.stock === "0" + ).length; - const getOutOfStockCount = () => allIngredients.filter(i => i.stock === 0 || i.stock === "0").length; + const outOfStockPct = allIngredients.length + ? Math.round((outOfStockCount / allIngredients.length) * 100) + : 0; + // Metric cards configuration const metricRows = [ - { label: "Total Ingredients", value: allIngredients.length, pct: 100, change: metrics?.totalChange || 1.58 }, - { label: "Protien", value: getCategoryCount("Protein"), pct: getCategoryPct("Protein"), change: 0.92 }, - { label: "Vegetables", value: getCategoryCount("Vegetables"), pct: getCategoryPct("Vegetables"), change: 0.12 }, - { label: "Sauces", value: getCategoryCount("Sauces"), pct: getCategoryPct("Sauces"), change: 0.92 }, - { label: "Out of stock", value: getOutOfStockCount(), pct: allIngredients.length ? Math.round((getOutOfStockCount() / allIngredients.length) * 100) : 0, badge: true, change: metrics?.outOfStockChange || 0.42 }, - ]; + { label: "Total Ingredients", value: allIngredients.length, pct: 100, change: metrics?.totalChange ?? 1.58 }, + { label: "Protein", value: getCategoryCount("Protein"), pct: getCategoryPct("Protein"), change: 0.92 }, + { label: "Vegetables", value: getCategoryCount("Vegetables"), pct: getCategoryPct("Vegetables"), change: 0.12 }, + { label: "Sauces", value: getCategoryCount("Sauces"), pct: getCategoryPct("Sauces"), change: 0.92 }, + { label: "Out of stock", value: outOfStockCount, pct: outOfStockPct, change: metrics?.outOfStockChange ?? 0.42, badge: true }, + ]; + + // Filter by active category + const categoryFiltered = activeCategory === "All Ingredients" + ? allIngredients + : allIngredients.filter( + (item) => item.category?.toLowerCase() === activeCategory.toLowerCase() + ); + + // Sort using shared utility (returns a new array, never mutates) + const filtered = sortItems(categoryFiltered, sortKey, sortDir); return (
@@ -203,38 +176,45 @@ function IngredientsView() {
- {/* Top Metric Circle Cards */} + {/* ── Metric ring cards ── */}
- {metricRows.map((m, i) => ( - + {metricRows.map((m) => ( + ))}
- {/* Table Card */} + {/* ── Table card ── */}
{/* Toolbar */}
- {/* Category Tabs */} + {/* Category tabs */}
{categoryTabs.map((tab) => ( ))}
+
- {/* Sort */} - {["Name", "Category", "Fat", "Cal", "Pro", "Sug", "Stock", "Price", "Actions"].map((h, idx) => ( - + {TABLE_HEADERS.map((h, idx) => ( + {h === "Actions" ? "" : h} ))} @@ -259,13 +243,27 @@ function IngredientsView() { {filtered.length === 0 ? ( - + + + + + ) : ( - filtered.map((item, i) => ( - + filtered.map((item) => ( +
- {item.name} + {item.name} {item.name}
@@ -275,7 +273,7 @@ function IngredientsView() { {item.protein || "-"} {item.sugar || "-"} - {item.stock >= 1000 ? `${(item.stock/1000).toFixed(0)}k` : item.stock} + {item.stock >= 1000 ? `${(item.stock / 1000).toFixed(0)}k` : item.stock} {item.costPerUnit} @@ -283,11 +281,19 @@ function IngredientsView() { +
@@ -296,34 +302,53 @@ function IngredientsView() {
- {/* Floating + Button */} -
- {/* Upload Button sitting on the yellow background */} + {/* ── CSV upload ── */}
-
- setIsModalOpen(false)} + onClose={closeModal} onSubmit={handleModalSubmit} initialData={editingIngredient} /> diff --git a/src/components/Dashboard/LiveKitchen/ChefManagement.jsx b/src/components/Dashboard/LiveKitchen/ChefManagement.jsx new file mode 100644 index 0000000..6692a8e --- /dev/null +++ b/src/components/Dashboard/LiveKitchen/ChefManagement.jsx @@ -0,0 +1,104 @@ +import { useState, useEffect } from "react"; +import { FiUsers, FiEdit2, FiCheck, FiX } from "react-icons/fi"; +import { STATIONS, buildChefsFromTickets } from "./constants"; +import EmptyState from "../shared/EmptyState"; +import StatusBadge from "../shared/StatusBadge"; + +function EditableChefName({ chefId, currentName, onSave }) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(currentName); + + // Keep the edit buffer in sync with server-side name updates + // (e.g. after a successful save refetches data with a new displayName). + useEffect(() => { + if (!editing) setValue(currentName); + }, [currentName, editing]); + + const handleSave = () => { + const trimmed = value.trim(); + if (trimmed && trimmed !== currentName) onSave({ chefId, displayName: trimmed }); + setEditing(false); + }; + const handleCancel = () => { setValue(currentName); setEditing(false); }; + + if (!editing) { + return ( +
+ {currentName || "Unnamed"} + +
+ ); + } + return ( +
+ setValue(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); if (e.key === "Escape") handleCancel(); }} + autoFocus className="text-[13px] font-medium text-[#1a1a1a] border border-orange-300 rounded-lg px-2 py-1 outline-none focus:border-orange-500 w-[140px]" /> + + +
+ ); +} + +export function ChefManagement({ tickets, isLoading, error, onUpdateStatus, onUpdateStation, onUpdateName }) { + const chefs = buildChefsFromTickets(tickets); + + return ( +
+

+ + Chef Management +

+ + {/* Only show empty state after the query has finished successfully */} + {!isLoading && !error && chefs.length === 0 ? ( +
+ +
+ ) : ( +
+ {chefs.map((chef) => ( +
+
+
+ + ID: {chef.id} +
+ +
+ +
+ + +
+ +
+ + {chef.ticketCount} active ticket{chef.ticketCount !== 1 ? "s" : ""} + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/Dashboard/LiveKitchen/KitchenTicketsTable.jsx b/src/components/Dashboard/LiveKitchen/KitchenTicketsTable.jsx new file mode 100644 index 0000000..7eac89c --- /dev/null +++ b/src/components/Dashboard/LiveKitchen/KitchenTicketsTable.jsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { FiRefreshCw } from "react-icons/fi"; +import { TICKET_TABS, TICKET_HEADERS, STATUS_FLOW, formatTicketTime, getTicketActionStyle } from "./constants"; +import EmptyState from "../shared/EmptyState"; +import StatusBadge from "../shared/StatusBadge"; +import { DashboardPageSkeleton } from "../shared/DashboardSkeleton"; +import ErrorState from "../shared/ErrorState"; + +export function KitchenTicketsTable({ tickets, isLoading, error, isFetching, onRetry, onAction }) { + const [activeTab, setActiveTab] = useState("All"); + + if (error) return ; + if (isLoading) return ; + + const allTickets = Array.isArray(tickets) ? tickets : []; + const tabFiltered = activeTab === "All" ? [...allTickets] : allTickets.filter((t) => t.status === activeTab); + const totalCount = allTickets.length; + const countsByStatus = allTickets.reduce((acc, t) => { acc[t.status] = (acc[t.status] || 0) + 1; return acc; }, {}); + + return ( +
+
+

Kitchen Tickets

+ +
+ +
+ {/* Tab bar */} +
+
+ {TICKET_TABS.map((tab) => { + const count = tab === "All" ? totalCount : (countsByStatus[tab] || 0); + const isActive = activeTab === tab; + const label = tab === "All" ? "All Tickets" : tab; + return ( + + ); + })} +
+
+ + {/* Table */} +
+ + + + {TICKET_HEADERS.map((h) => ( + + ))} + + + + {tabFiltered.length === 0 && ( + + )} + {tabFiltered.map((ticket) => { + const flow = STATUS_FLOW[ticket.status]; + return ( + + + + + + + + + ); + })} + +
{h}
+ +
#{ticket.id}#{ticket.orderId} + {ticket.chefDisplayName || (ticket.assignedChefId != null ? `Chef #${ticket.assignedChefId}` : "Unassigned")} + {formatTicketTime(ticket.createdAt)} + {flow ? ( + + ) : ( + Completed + )} +
+
+
+
+ ); +} diff --git a/src/components/Dashboard/LiveKitchen/LiveIndicator.jsx b/src/components/Dashboard/LiveKitchen/LiveIndicator.jsx new file mode 100644 index 0000000..5c25042 --- /dev/null +++ b/src/components/Dashboard/LiveKitchen/LiveIndicator.jsx @@ -0,0 +1,22 @@ +import { FiRefreshCw } from "react-icons/fi"; + +export function LiveIndicator({ title = "Live Kitchen", isFetching, onRefresh }) { + return ( +
+
+ + {title} +
+
+ +
+
+ ); +} diff --git a/src/components/Dashboard/LiveKitchen/OrderCards.jsx b/src/components/Dashboard/LiveKitchen/OrderCards.jsx new file mode 100644 index 0000000..c961bbd --- /dev/null +++ b/src/components/Dashboard/LiveKitchen/OrderCards.jsx @@ -0,0 +1,111 @@ +import { FiInbox } from "react-icons/fi"; +import { COLUMNS, getActionButtonStyle } from "./constants"; + +export function OrderInfoGrid({ order }) { + const displayName = order.name || + (Array.isArray(order.items) ? order.items.join(", ") : order.items); + + return ( +
+ Order + {order.id} + + Time + {order.time} + + Name + + {displayName} + + + Notes +
+ {order.notes ? ( + + {order.notes} + + ) : ( + None + )} +
+
+ ); +} + +export function ViewDetailsHint() { + return ( +
+ Click to view details + +
+ ); +} + +export function OrderCard({ order, columnKey, onAction, onViewOrder }) { + const col = COLUMNS.find((c) => c.key === columnKey); + + return ( +
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onViewOrder(); } }} + > + + + + {/* Action buttons */} +
+ {col.prevStatus && ( + + )} + +
+
+ ); +} + +export function DoneCard({ order, onViewOrder, onRevert }) { + return ( +
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onViewOrder(); } }} + > + + +
+ +
+
+ ); +} + +export function EmptyColumn({ label }) { + return ( +
+
+ +
+ {label} +
+ ); +} diff --git a/src/components/Dashboard/LiveKitchen/constants.js b/src/components/Dashboard/LiveKitchen/constants.js new file mode 100644 index 0000000..f054163 --- /dev/null +++ b/src/components/Dashboard/LiveKitchen/constants.js @@ -0,0 +1,80 @@ +/** + * LiveKitchen — shared constants & pure helpers + * No React imports — safe to use anywhere. + */ + +// ── Kanban columns ───────────────────────────────────────────────── +export const COLUMNS = [ + { key: "queue", label: "Order Queue", action: "Start Preparing", nextStatus: "preparing", prevStatus: null }, + { key: "preparing", label: "Preparing", action: "Prepared", nextStatus: "ready", prevStatus: "queue" }, + { key: "ready", label: "Ready", action: "Mark Done", nextStatus: "done", prevStatus: "preparing" }, +]; + +// ── Kitchen ticket constants ─────────────────────────────────────── +export const TICKET_TABS = ["All", "Queue", "Preparing", "Ready", "Done"]; +export const TICKET_HEADERS = ["Ticket ID", "Order ID", "Status", "Assigned Chef", "Time Elapsed", "Actions"]; +export const STATIONS = ["UNASSIGNED", "GRILL", "PREP", "FRY", "PASTRY", "SALADS"]; + +export const STATUS_FLOW = { + Queue: { next: "Preparing", label: "Start Preparing" }, + Preparing: { next: "Ready", label: "Mark Ready" }, + Ready: { next: "Done", label: "Mark Done" }, +}; + +// ── Pure helpers ────────────────────────────────────────────────── +export function getActionButtonStyle(action) { + if (action === "Start Preparing") return "bg-white text-[#1a1a1a] border border-[#d1d5db] hover:bg-gray-50"; + if (action === "Prepared") return "bg-[#F97316] text-white border border-transparent hover:bg-orange-600 shadow-sm"; + if (action === "Mark Done") return "bg-[#16A34A] text-white border border-transparent hover:bg-green-700 shadow-sm"; + return ""; +} + +export function getTicketActionStyle(status) { + if (status === "Queue") return "bg-white text-[#1a1a1a] border border-[#d1d5db] hover:bg-gray-50"; + if (status === "Preparing") return "bg-[#F97316] text-white hover:bg-orange-600"; + if (status === "Ready") return "bg-[#16A34A] text-white hover:bg-green-700"; + return ""; +} + +export function formatTicketTime(dateStr) { + if (!dateStr) return "—"; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + + const diffMs = Date.now() - d.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + + const diffHours = Math.floor(diffMins / 60); + return `${diffHours}h ${diffMins % 60}m ago`; +} + +/** Build a deduplicated chef list from ticket data. */ +export function buildChefsFromTickets(tickets) { + const chefsMap = new Map(); + const safe = Array.isArray(tickets) ? tickets : []; + + safe.forEach((t) => { + if (t.assignedChefId == null) return; + if (!chefsMap.has(t.assignedChefId)) { + chefsMap.set(t.assignedChefId, { + id: t.assignedChefId, + displayName: t.chefDisplayName || `Chef #${t.assignedChefId}`, + station: t.chefStation || "UNASSIGNED", + status: t.chefStatus || "ACTIVE", + ticketCount: 0, + }); + } + chefsMap.get(t.assignedChefId).ticketCount += 1; + // Merge latest non-fallback metadata so subsequent tickets can update + // display name, station, or status if earlier tickets had fallback values. + const entry = chefsMap.get(t.assignedChefId); + if (t.chefDisplayName) entry.displayName = t.chefDisplayName; + if (t.chefStation) entry.station = t.chefStation; + if (t.chefStatus) entry.status = t.chefStatus; + }); + + return Array.from(chefsMap.values()); +} diff --git a/src/components/Dashboard/LiveKitchenView.jsx b/src/components/Dashboard/LiveKitchenView.jsx index 86bd73e..67f6f28 100644 --- a/src/components/Dashboard/LiveKitchenView.jsx +++ b/src/components/Dashboard/LiveKitchenView.jsx @@ -1,134 +1,60 @@ import { useState, useCallback } from "react"; -import { FiInbox, FiRefreshCw, FiArrowLeft } from "react-icons/fi"; import DashboardHeader from "./DashboardHeader"; -import { useRealtimeKitchen, useUpdateKitchenStatus } from "../../hooks/dashboard/useKitchenOrders"; -import { DashboardPageSkeleton, KanbanCardSkeleton } from "./shared/DashboardSkeleton"; +import { + useRealtimeKitchen, + useUpdateKitchenStatus, + useActiveTickets, + useUpdateTicketStatus, + useUpdateChefStatus, + useUpdateChefStation, + useUpdateChefDisplayName, +} from "../../hooks/dashboard/useKitchenOrders"; +import { KanbanCardSkeleton } from "./shared/DashboardSkeleton"; import ErrorState from "./shared/ErrorState"; import ConfirmModal from "./shared/ConfirmModal"; import OrderDetailsModal from "./shared/OrderDetailsModal"; -const COLUMNS = [ - { key: "queue", label: "Order Queue", action: "Start Preparing", nextStatus: "preparing", prevStatus: null }, - { key: "preparing", label: "Preparing", action: "Prepared", nextStatus: "ready", prevStatus: "queue" }, - { key: "ready", label: "Ready", action: "Ready", nextStatus: "done", prevStatus: "preparing" }, -]; - -/** - * A single kitchen order card matching the Figma design: - * white card, label/value grid (Order / Time / Name / Notes), status button at bottom - */ -function OrderCard({ order, columnKey, onAction, onViewOrder }) { - const col = COLUMNS.find((c) => c.key === columnKey); - - const getButtonStyle = (action) => { - if (action === "Start Preparing") return "bg-white text-[#1a1a1a] border-[1px] border-[#d1d5db] hover:bg-gray-50"; - if (action === "Prepared") return "bg-[#F97316] text-white border-[1px] border-transparent hover:bg-orange-600 shadow-sm"; - if (action === "Ready") return "bg-[#16A34A] text-white border-[1px] border-transparent hover:bg-green-700 shadow-sm"; - return ""; - }; - - return ( -
- {/* Info grid */} -
- Order - {order.id} - - Time - {order.time} - - Name - - {order.name || (Array.isArray(order.items) ? order.items.join(', ') : order.items)} - - - Notes -
- {order.notes ? ( - - {order.notes} - - ) : ( - None - )} -
-
- - {/* Hover hint */} -
- Click to view details - -
- - {/* Action buttons */} -
- {col.prevStatus && ( - - )} - -
-
- ); -} +import { COLUMNS } from "./LiveKitchen/constants"; +import { LiveIndicator } from "./LiveKitchen/LiveIndicator"; +import { OrderCard, DoneCard, EmptyColumn } from "./LiveKitchen/OrderCards"; +import { KitchenTicketsTable } from "./LiveKitchen/KitchenTicketsTable"; +import { ChefManagement } from "./LiveKitchen/ChefManagement"; function LiveKitchenView() { - const [revertingOrder, setRevertingOrder] = useState(null); const [viewingOrder, setViewingOrder] = useState(null); - const { boards, isFetching, error, refetch } = useRealtimeKitchen(); - const { mutate: updateStatus } = useUpdateKitchenStatus(); const [orderToCancel, setOrderToCancel] = useState(null); const [orderToMarkDone, setOrderToMarkDone] = useState(null); const [orderToRevert, setOrderToRevert] = useState(null); + // ── Kitchen-service state ── + const [ticketConfirm, setTicketConfirm] = useState(null); // { ticketId, status, label } + + const { boards, isFetching, error, refetch } = useRealtimeKitchen(); + const { mutate: updateStatus } = useUpdateKitchenStatus(); + + // ── Kitchen-service hooks ── + const { data: tickets, isLoading: ticketsLoading, error: ticketsError, refetch: refetchTickets, isFetching: ticketsFetching } = useActiveTickets(); + const { mutate: mutateTicketStatus } = useUpdateTicketStatus(); + const { mutate: mutateChefStatus } = useUpdateChefStatus(); + const { mutate: mutateChefStation } = useUpdateChefStation(); + const { mutate: mutateChefName } = useUpdateChefDisplayName(); + + // ── Action handler — routes to confirmation modals for destructive actions ── const handleAction = useCallback((orderId, nextStatus) => { - if (nextStatus === "cancelled") { - setOrderToCancel(orderId); - return; - } - if (nextStatus === "done") { - setOrderToMarkDone(orderId); - return; - } + if (nextStatus === "cancelled") { setOrderToCancel(orderId); return; } + if (nextStatus === "done") { setOrderToMarkDone(orderId); return; } updateStatus({ orderId, nextStatus }); }, [updateStatus]); - const confirmCancel = () => { - if (orderToCancel) { - updateStatus({ orderId: orderToCancel, nextStatus: "cancelled" }); - setOrderToCancel(null); - } - }; - - const confirmMarkDone = () => { - if (orderToMarkDone) { - updateStatus({ orderId: orderToMarkDone, nextStatus: "done" }); - setOrderToMarkDone(null); - } - }; + const confirmCancel = () => { if (orderToCancel) { updateStatus({ orderId: orderToCancel, nextStatus: "cancelled" }); setOrderToCancel(null); } }; + const confirmMarkDone = () => { if (orderToMarkDone) { updateStatus({ orderId: orderToMarkDone, nextStatus: "done" }); setOrderToMarkDone(null); } }; + const confirmRevert = () => { if (orderToRevert) { updateStatus({ orderId: orderToRevert, nextStatus: "ready" }); setOrderToRevert(null); } }; - const confirmRevert = () => { - if (orderToRevert) { - updateStatus({ orderId: orderToRevert, nextStatus: "ready" }); - setOrderToRevert(null); - } - }; + const handleTicketAction = useCallback((ticketId, status, label) => { + setTicketConfirm({ ticketId, status, label }); + }, []); + // ── Error state ──────────────────────────────────────────────────────────── if (error) { return (
@@ -138,7 +64,7 @@ function LiveKitchenView() { ); } - const doneCount = boards.done?.length || 0; + const doneCount = boards.done?.length ?? 0; return (
@@ -146,37 +72,17 @@ function LiveKitchenView() {
- {/* Live indicator + Refresh */} -
-
- - Live Kitchen -
- -
- -
-
+ - {/* 3-Column Kanban */} + {/* 3-column kanban */}
{COLUMNS.map((col) => { const cards = boards[col.key] || []; return (
- {/* Column header */}

- {col.label} {cards.length} + {col.label} {cards.length}

- - {/* Cards Container */}
{isFetching && cards.length === 0 ? ( <> @@ -190,20 +96,17 @@ function LiveKitchenView() { order={order} columnKey={col.key} onAction={handleAction} - onViewOrder={() => setViewingOrder({ - ...order, - status: col.key === 'queue' ? 'Pending' : col.label - })} + onViewOrder={() => + setViewingOrder({ + ...order, + status: col.key === "queue" ? "Pending" : col.label, + }) + } /> )) )} {!isFetching && cards.length === 0 && ( -
-
- -
- No orders here -
+ )}
@@ -213,69 +116,55 @@ function LiveKitchenView() { {/* Done section */}
-

Done {doneCount}

- +

+ Done {doneCount} +

- {boards.done && boards.done.length > 0 ? ( + {boards.done?.length > 0 ? (
- {(boards.done || []).map((order) => ( -
setViewingOrder({ ...order, status: 'Done' })} - > -
- Order - {order.id} - Time - {order.time} - Name - - {order.name || (Array.isArray(order.items) ? order.items.join(', ') : order.items)} - - Notes -
- {order.notes ? ( - - {order.notes} - - ) : ( - None - )} -
-
- {/* Hover hint */} -
- Click to view details - -
-
- -
-
+ {boards.done.map((order) => ( + setViewingOrder({ ...order, status: "Done" })} + onRevert={() => setOrderToRevert(order.id)} + /> ))}
) : ( -
-
- -
- No completed orders yet -
+ )}
+ + {/* ══════════════════════════════════════════════════════ + SECTION: Kitchen Service — Active Tickets + ═══════════════════════════════════════════════════════ */} + + + {/* ══════════════════════════════════════════════════════ + SECTION: Chef Management + ═══════════════════════════════════════════════════════ */} + +
- + + {/* ── Confirm modals ── */} setOrderToCancel(null)} @@ -287,7 +176,7 @@ function LiveKitchenView() { isOpen={!!orderToMarkDone} onClose={() => setOrderToMarkDone(null)} onConfirm={confirmMarkDone} - title="Ready" + title="Mark as Ready?" message="Are you sure this order is ready and should be moved to the Done list?" confirmLabel="Ready" confirmClassName="bg-[#16A34A] hover:bg-green-700 shadow-lg shadow-green-500/30" @@ -296,12 +185,32 @@ function LiveKitchenView() { isOpen={!!orderToRevert} onClose={() => setOrderToRevert(null)} onConfirm={confirmRevert} - title="Not Done" - message="Are you sure this order is not Done and should be moved to the Ready list?" + title="Not Done?" + message="Are you sure this order is not Done and should be moved back to the Ready list?" confirmLabel="Not Done" confirmClassName="bg-[#16A34A] hover:bg-green-700 shadow-lg shadow-green-500/30" /> + {/* ── Ticket status confirm modal ── */} + setTicketConfirm(null)} + onConfirm={() => { + if (ticketConfirm) { + mutateTicketStatus({ ticketId: ticketConfirm.ticketId, status: ticketConfirm.status }); + setTicketConfirm(null); + } + }} + title={ticketConfirm?.label || "Update Status?"} + message={`Are you sure you want to move ticket #${ticketConfirm?.ticketId} to ${ticketConfirm?.status}?`} + confirmLabel={ticketConfirm?.label || "Confirm"} + confirmClassName={ + ticketConfirm?.status === "READY" + ? "bg-[#F97316] hover:bg-orange-600 shadow-lg shadow-orange-500/30" + : "bg-[#16A34A] hover:bg-green-700 shadow-lg shadow-green-500/30" + } + /> + setViewingOrder(null)} diff --git a/src/components/Dashboard/MenuManagementView.jsx b/src/components/Dashboard/MenuManagementView.jsx index 9d0a517..1d06a01 100644 --- a/src/components/Dashboard/MenuManagementView.jsx +++ b/src/components/Dashboard/MenuManagementView.jsx @@ -2,7 +2,7 @@ import { useState, useMemo } from "react"; import DashboardHeader from "./DashboardHeader"; import { useMenuUploads, useUploadMenu } from "../../hooks/dashboard/useMenuUploads"; import { useToast } from "../../store/toastStore"; -import { FiUploadCloud, FiFileText, FiCheckCircle, FiXCircle } from "react-icons/fi"; +import { FiUploadCloud, FiFileText } from "react-icons/fi"; import { DashboardPageSkeleton } from "./shared/DashboardSkeleton"; import ErrorState from "./shared/ErrorState"; import EmptyState from "./shared/EmptyState"; @@ -13,24 +13,26 @@ function MenuManagementView() { const { addToast } = useToast(); - // Dynamic calendar - const today = new Date(); - const todayDate = today.getDate(); - const monthName = today.toLocaleString("en", { month: "long" }); - const dayDisplay = String(todayDate).padStart(2, "0"); - - const calendarDays = useMemo(() => { - const year = today.getFullYear(); - const month = today.getMonth(); - const firstDay = new Date(year, month, 1).getDay(); // 0=Sun + // Dynamic calendar — `new Date()` is inside the memo so the snapshot + // is taken once on mount and is never stale with respect to the closure. + const { todayDate, monthName, dayDisplay, calendarDays } = useMemo(() => { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const cells = []; - for (let i = 0; i < firstDay; i++) cells.push(null); // empty leading cells + for (let i = 0; i < firstDay; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); - return cells; + return { + todayDate: now.getDate(), + monthName: now.toLocaleString("en", { month: "long" }), + dayDisplay: String(now.getDate()).padStart(2, "0"), + calendarDays: cells, + }; }, []); - const { data: uploads, isLoading, error } = useMenuUploads(); + const { data: uploads, isLoading, error, refetch } = useMenuUploads(); const { mutate: uploadFile, isPending: isUploading } = useUploadMenu(); const handleDrag = (e) => { @@ -101,7 +103,8 @@ function MenuManagementView() { return (
- window.location.reload()} /> + {/* Use refetch so the page state (dragActive, selectedFile) is preserved */} +
); } @@ -112,7 +115,7 @@ function MenuManagementView() { {/* ── Single centered column layout matching the design ── */}
- + {/* Top Header Texts */}

Upload Menu File

@@ -130,11 +133,10 @@ function MenuManagementView() { onDragLeave={handleDrag} onDragOver={handleDrag} onDrop={handleDrop} - className={`flex flex-col items-center justify-center gap-4 h-[220px] rounded-4xl cursor-pointer transition-all duration-200 border-[3px] border-dashed ${ - dragActive + className={`flex flex-col items-center justify-center gap-4 h-[220px] rounded-4xl cursor-pointer transition-all duration-200 border-[3px] border-dashed ${dragActive ? "border-[#22C55E] bg-green-50 scale-[1.02]" : "border-[#22C55E] hover:bg-green-50/30 bg-white" - }`} + }`} > {selectedFile ? (
@@ -172,9 +174,9 @@ function MenuManagementView() { {/* Recent Uploads Section */}

Recent Uploads

- +
- + {/* Table Area */}
@@ -182,7 +184,7 @@ function MenuManagementView() { Date Time
- +
{!uploads || uploads.length === 0 ? (

No uploads yet — drop a file above!

@@ -205,7 +207,7 @@ function MenuManagementView() { {dayDisplay}
- {["SU","MO","TU","WE","TH","FR","SA"].map(d => ( + {["SU", "MO", "TU", "WE", "TH", "FR", "SA"].map(d => ( {d} ))} {calendarDays.map((day, i) => ( @@ -223,7 +225,7 @@ function MenuManagementView() { ))}
- +
diff --git a/src/components/Dashboard/OrdersView.jsx b/src/components/Dashboard/OrdersView.jsx index c75fcbd..4a3f551 100644 --- a/src/components/Dashboard/OrdersView.jsx +++ b/src/components/Dashboard/OrdersView.jsx @@ -2,7 +2,11 @@ import { useState } from "react"; import DashboardHeader from "./DashboardHeader"; import TrendingMenus from "./TrendingMenus"; import RecentActivity from "./RecentActivity"; -import { useOrdersMetrics, useOrders, useOrdersTrending } from "../../hooks/dashboard/useOrders"; +import { + useOrdersMetrics, + useOrders, + useOrdersTrending, +} from "../../hooks/dashboard/useOrders"; import { useRecentActivity, useOrdersOverview } from "../../hooks/dashboard/useDashboard"; import { FiShoppingBag, FiClock, FiCheckCircle } from "react-icons/fi"; import { DashboardPageSkeleton } from "./shared/DashboardSkeleton"; @@ -13,32 +17,62 @@ import TimeFilter from "./shared/TimeFilter"; import OrdersOverviewChart from "./shared/OrdersOverviewChart"; import SortMenu from "./shared/SortMenu"; import OrderDetailsModal from "./shared/OrderDetailsModal"; +import { sortItems } from "../../utils/sortItems"; +// ── Constants — defined outside the component so they are never recreated ── const TABS = ["All", "Pending", "Preparing", "Ready", "Done", "Cancelled"]; +const ORDER_SORT_COLS = [ + { key: "id", label: "Order ID" }, + { key: "time", label: "Time", comparator: (a, b) => { + // Parse "HH:MM AM/PM" → minutes-since-midnight for correct clock ordering + const toMin = (str) => { + const m = String(str).match(/(\d+):(\d+)\s*(AM|PM)/i); + if (!m) return NaN; + let h = parseInt(m[1], 10); + const min = parseInt(m[2], 10); + const isPM = m[3].toUpperCase() === "PM"; + if (isPM && h !== 12) h += 12; + if (!isPM && h === 12) h = 0; + return h * 60 + min; + }; + return toMin(a.time) - toMin(b.time); + }}, + { key: "name", label: "Order" }, + { key: "items", label: "Items" }, + { key: "total", label: "Total" }, + { key: "customer", label: "Customer Name" }, + { key: "status", label: "Status" }, +]; + +// ── Dual-ring circular progress — specific to the Daily Goal widget ── function CircularProgress({ salesPct, orderPct, displayPct }) { const r1 = 36; const r2 = 28; const c1 = 2 * Math.PI * r1; const c2 = 2 * Math.PI * r2; + // Clamp to [0, 100] for geometry only — prevents negative strokeDashoffset + // when targets are exceeded. The displayed label (displayPct) is unchanged. + const salesGeo = Math.min(100, Math.max(0, salesPct)); + const orderGeo = Math.min(100, Math.max(0, orderPct)); + return (
{/* Sales track */} - {/* Sales progress */} - + strokeDasharray={c1} strokeDashoffset={c1 - (salesGeo / 100) * c1} strokeLinecap="round" /> {/* Order track */} - {/* Order progress */} + strokeDasharray={c2} strokeDashoffset={c2 - (orderGeo / 100) * c2} strokeLinecap="round" />
-

{displayPct ?? Math.round(salesPct)}%

+

+ {displayPct ?? Math.round(salesPct)}% +

); @@ -50,25 +84,17 @@ function OrdersView() { const [sortDir, setSortDir] = useState("asc"); const [viewingOrder, setViewingOrder] = useState(null); - const ORDER_SORT_COLS = [ - { key: "id", label: "Order ID" }, - { key: "time", label: "Time" }, - { key: "name", label: "Order" }, - { key: "items", label: "Items" }, - { key: "total", label: "Total" }, - { key: "customer", label: "Customer Name" }, - { key: "status", label: "Status" }, - ]; - - const { data: metrics, isLoading: loadMetrics, error: errMetrics } = useOrdersMetrics(); - const { data: ordersResponse, isLoading: loadOrders, error: errOrders } = useOrders(); - const { data: trending, isLoading: loadTrending, error: errTrending } = useOrdersTrending(); - const { data: activity, isLoading: loadAct, error: errAct } = useRecentActivity(); - const { data: ordersOverview, isLoading: loadOrdOv, error: errOrdOv } = useOrdersOverview(); + // ── Data fetching ────────────────────────────────────────────────────────── + const { data: metrics, isLoading: loadMetrics, error: errMetrics, refetch: refetchMetrics } = useOrdersMetrics(); + const { data: ordersResponse, isLoading: loadOrders, error: errOrders, refetch: refetchOrders } = useOrders(); + const { data: trending, isLoading: loadTrending, error: errTrending, refetch: refetchTrending } = useOrdersTrending(); + const { data: activity, isLoading: loadAct, error: errAct, refetch: refetchAct } = useRecentActivity(); + const { data: ordersOverview, isLoading: loadOrdOv, error: errOrdOv, refetch: refetchOrdOv } = useOrdersOverview(); const isLoading = loadMetrics || loadOrders || loadTrending || loadAct || loadOrdOv; - const hasError = errMetrics || errOrders || errTrending || errAct || errOrdOv; + const hasError = errMetrics || errOrders || errTrending || errAct || errOrdOv; + // ── Loading / error states ───────────────────────────────────────────────── if (isLoading) { return (
@@ -79,32 +105,35 @@ function OrdersView() { } if (hasError) { + const handleRetry = () => { + refetchMetrics(); + refetchOrders(); + refetchTrending(); + refetchAct(); + refetchOrdOv(); + }; return (
- window.location.reload()} /> +
); } + // ── Derived data ─────────────────────────────────────────────────────────── // ordersResponse is directly an array from mapOrders - const allOrders = Array.isArray(ordersResponse) ? ordersResponse : (ordersResponse?.orders || []); - let filtered = activeTab === "All" ? [...allOrders] : allOrders.filter((o) => o.status === activeTab); + const allOrders = Array.isArray(ordersResponse) + ? ordersResponse + : (ordersResponse?.orders || []); - // Multi-column sorting - if (sortKey) { - filtered.sort((a, b) => { - const av = a[sortKey] ?? ""; - const bv = b[sortKey] ?? ""; - const cmp = typeof av === "number" - ? av - bv - : String(av).localeCompare(String(bv)); - return sortDir === "asc" ? cmp : -cmp; - }); - } + const tabFiltered = activeTab === "All" + ? [...allOrders] + : allOrders.filter((o) => o.status === activeTab); + + // Sort using shared utility — pass column config so the time comparator is used + const filtered = sortItems(tabFiltered, sortKey, sortDir, ORDER_SORT_COLS); const totalCount = allOrders.length; - const countsByStatus = allOrders.reduce((acc, order) => { acc[order.status] = (acc[order.status] || 0) + 1; return acc; @@ -112,14 +141,12 @@ function OrdersView() { const metricCards = metrics ? [ - { label: "Total Orders", value: metrics.totalOrders, icon: FiShoppingBag, bgBox: "bg-[#FFF7ED]", iconColor: "#F97316", pct: Math.abs(metrics.totalOrdersChange), up: metrics.totalOrdersChange >= 0 }, - { label: "Order Preparing", value: metrics.preparing, icon: FiClock, bgBox: "bg-[#FFF7ED]", iconColor: "#F97316", pct: Math.abs(metrics.preparingChange), up: metrics.preparingChange >= 0 }, - { label: "Total Completed", value: metrics.completed, icon: FiCheckCircle, bgBox: "bg-green-50", iconColor: "#16A34A", pct: Math.abs(metrics.completedChange), up: metrics.completedChange >= 0 }, - ] + { label: "Total Orders", value: metrics.totalOrders, icon: FiShoppingBag, bgBox: "bg-[#FFF7ED]", iconColor: "#F97316", pct: Math.abs(metrics.totalOrdersChange), up: metrics.totalOrdersChange >= 0 }, + { label: "Order Preparing", value: metrics.preparing, icon: FiClock, bgBox: "bg-[#FFF7ED]", iconColor: "#F97316", pct: Math.abs(metrics.preparingChange), up: metrics.preparingChange >= 0 }, + { label: "Total Completed", value: metrics.completed, icon: FiCheckCircle, bgBox: "bg-green-50", iconColor: "#16A34A", pct: Math.abs(metrics.completedChange), up: metrics.completedChange >= 0 }, + ] : []; - const handleSortChange = (key, dir) => { setSortKey(key); setSortDir(dir); }; - return (
@@ -152,31 +179,30 @@ function OrdersView() { ))}
- {/* Orders Overview Chart area + Daily Goal */} + {/* Orders overview chart + daily goal */} {metrics && (
- {/* Daily Goal */}

Daily Goal

- Sales Goal + Sales Goal

- Order Goal + Order Goal

- +
- 0 ? (metrics.dailyGoal.salesCurrent / metrics.dailyGoal.salesTarget) * 100 : 0} + 0 ? (metrics.dailyGoal.salesCurrent / metrics.dailyGoal.salesTarget) * 100 : 0} orderPct={metrics.dailyGoal.ordersTarget > 0 ? (metrics.dailyGoal.ordersCurrent / metrics.dailyGoal.ordersTarget) * 100 : 0} displayPct={metrics.dailyGoal.salesTarget > 0 ? Math.round((metrics.dailyGoal.salesCurrent / metrics.dailyGoal.salesTarget) * 100) : 0} /> @@ -184,13 +210,15 @@ function OrdersView() {

Today Sales

-

(${metrics.dailyGoal.salesCurrent.toLocaleString()} / ${metrics.dailyGoal.salesTarget.toLocaleString()})

+

+ (${metrics.dailyGoal.salesCurrent.toLocaleString()} / ${metrics.dailyGoal.salesTarget.toLocaleString()}) +

)} - {/* Tabs + Table */} + {/* Tabs + table */}
{/* Tab bar */}
@@ -204,18 +232,17 @@ function OrdersView() { type="button" key={tab} onClick={() => setActiveTab(tab)} - className={`px-4 py-1.5 rounded-full cursor-pointer text-[13px] font-semibold transition-all duration-200 whitespace-nowrap border ${ - isActive + className={`px-4 py-1.5 rounded-full cursor-pointer text-[13px] font-semibold transition-all duration-200 whitespace-nowrap border ${isActive ? "border-orange-500 text-[#1a1a1a] bg-white" : "border-transparent text-gray-500 hover:text-gray-700 bg-transparent" - }`} + }`} > {label} ({count}) ); })}
- + { setSortKey(k); setSortDir(d); }} />
{/* Table */} @@ -232,11 +259,15 @@ function OrdersView() { {filtered.length === 0 && ( - + + + + + )} {filtered.map((order) => ( - setViewingOrder(order)} > @@ -248,7 +279,9 @@ function OrdersView() { {order.customer} - Click to view details → + + Click to view details → + ))} @@ -258,7 +291,7 @@ function OrdersView() {
- {/* ── Right sidebar: Trending Menus & Recent Activity ── */} + {/* ── Right sidebar ── */}
diff --git a/src/components/Dashboard/RecipeBuilderView.jsx b/src/components/Dashboard/RecipeBuilderView.jsx index b919345..5ae7dd3 100644 --- a/src/components/Dashboard/RecipeBuilderView.jsx +++ b/src/components/Dashboard/RecipeBuilderView.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation } from "react-router"; import DashboardHeader from "./DashboardHeader"; -import { useRecipeIngredients, useSaveRecipe } from "../../hooks/dashboard/useMenuItems"; +import { useCreateMenuItem, useUpdateMenuItem } from "../../hooks/dashboard/useMenuItems"; import { useToast } from "../../store/toastStore"; import { FiPlus, FiTrash2, FiCamera, FiBookOpen, FiDollarSign, @@ -18,11 +18,13 @@ export default function RecipeBuilderView() { const [localIngredients, setLocalIngredients] = useState([]); const [newIngredient, setNewIngredient] = useState({ name: "", amount: "", imagePreview: null }); const [mealImagePreview, setMealImagePreview] = useState(null); + const [imageFile, setImageFile] = useState(null); const [isCategoryOpen, setIsCategoryOpen] = useState(false); const { addToast } = useToast(); - const { data: initialIngredients, isLoading: loadIngredients, error: errIngredients } = useRecipeIngredients(); - const { mutate: saveRecipe, isSuccess: saved, reset: resetMutation } = useSaveRecipe(); + const { mutate: createMeal, isSuccess: created, reset: resetCreate } = useCreateMenuItem(); + const { mutate: updateMeal, isSuccess: updated, reset: resetUpdate } = useUpdateMenuItem(); + const saved = created || updated; // Pre-fill form when editing an existing meal useEffect(() => { @@ -43,11 +45,7 @@ export default function RecipeBuilderView() { } }, [editMeal]); - useEffect(() => { - if (initialIngredients && localIngredients.length === 0 && !editMeal) { - setLocalIngredients(initialIngredients); - } - }, [initialIngredients, localIngredients.length, editMeal]); + // Initial ingredients was removed because it hit a mock endpoint const handleChange = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.value })); @@ -96,6 +94,7 @@ export default function RecipeBuilderView() { const handleMealImageUpload = (e) => { const file = e.target.files[0]; if (file) { + setImageFile(file); const reader = new FileReader(); reader.onload = (ev) => setMealImagePreview(ev.target.result); reader.readAsDataURL(file); @@ -113,23 +112,32 @@ export default function RecipeBuilderView() { const handleSave = () => { if (!form.name.trim()) return; - saveRecipe( - { ...form, ingredients: localIngredients }, - { - onSuccess: () => { - addToast("Recipe saved successfully!", "success"); - setTimeout(() => { + + const payload = { ...form, ingredients: localIngredients, imageFile }; + const options = { + onSuccess: () => { + addToast(editMeal ? "Recipe updated successfully!" : "Recipe saved successfully!", "success"); + setTimeout(() => { + if (!editMeal) { setForm({ name: "", category: "", price: "", time: "", description: "", fat: "", calories: "", protein: "", sugar: "" }); setMealImagePreview(null); + setImageFile(null); setLocalIngredients([]); - resetMutation(); - }, 2000); - }, - onError: () => { - addToast("Failed to save recipe. Please try again.", "error"); - } + } + resetCreate(); + resetUpdate(); + }, 2000); + }, + onError: () => { + addToast(editMeal ? "Failed to update recipe. Please try again." : "Failed to save recipe. Please try again.", "error"); } - ); + }; + + if (editMeal) { + updateMeal({ id: editMeal.id || editMeal._id, data: payload }, options); + } else { + createMeal(payload, options); + } }; const isFormComplete = @@ -145,9 +153,6 @@ export default function RecipeBuilderView() { localIngredients.length > 0 && mealImagePreview !== null; - if (loadIngredients && !editMeal) return
; - if (errIngredients) return
window.location.reload()} />
; - const pageTitle = editMeal ? `Editing: ${editMeal.name}` : "Recipe Builder"; const pageSubtitle = editMeal ? "Update the meal details, ingredients, and nutritional values below." @@ -355,7 +360,7 @@ export default function RecipeBuilderView() { { key: 'fat', label: 'Fat', color: 'text-blue-500', Icon: FiShare2 }, { key: 'calories', label: 'Calories', color: 'text-green-500', Icon: FiTarget }, { key: 'protein', label: 'Protein', color: 'text-blue-400', Icon: FiUser }, - { key: 'sugar', label: 'Suger', color: 'text-orange-400', Icon: FiEye } + { key: 'sugar', label: 'Sugar', color: 'text-orange-400', Icon: FiEye } ].map(nut => (

{nut.label}

diff --git a/src/components/Dashboard/shared/InactiveMenuModal.jsx b/src/components/Dashboard/shared/InactiveMenuModal.jsx new file mode 100644 index 0000000..23c6e04 --- /dev/null +++ b/src/components/Dashboard/shared/InactiveMenuModal.jsx @@ -0,0 +1,138 @@ +import React, { useRef, useState } from "react"; +import { FiX, FiCamera, FiUploadCloud } from "react-icons/fi"; +import { useUpdateMenuItem } from "../../../hooks/dashboard/useMenuItems"; +import { useToast } from "../../../store/toastStore"; + +export default function InactiveMenuModal({ isOpen, onClose, inactiveItems }) { + const { addToast } = useToast(); + const { mutate: updateMeal, isPending } = useUpdateMenuItem(); + const fileInputRef = useRef(null); + const [selectedItemId, setSelectedItemId] = useState(null); + + if (!isOpen) return null; + + const handleAddPhotoClick = (id) => { + // Do not allow switching items while an upload is in progress + if (isPending) return; + setSelectedItemId(id); + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + const handleFileChange = (e) => { + const file = e.target.files[0]; + if (file && selectedItemId) { + const itemToUpdate = inactiveItems.find((i) => i.id === selectedItemId); + if (itemToUpdate) { + updateMeal( + { + id: itemToUpdate.id || itemToUpdate._id, + data: { ...itemToUpdate, imageFile: file }, + }, + { + onSuccess: () => { + addToast("Photo added successfully! Dish is now active.", "success"); + setSelectedItemId(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + if (inactiveItems.length === 1) onClose(); // Auto close if it was the last item + }, + onError: () => { + addToast("Failed to upload photo. Please try again.", "error"); + setSelectedItemId(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + } + ); + } + } + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

Inactive Menu

+

Dishes without photos won't appear in the main menu.

+
+ +
+ + {/* Hidden file input */} + + + {/* Content */} +
+ {inactiveItems.length === 0 ? ( +
+
+ +
+

No Inactive Dishes

+

All your dishes have photos and are active.

+
+ ) : ( +
+ {inactiveItems.map((item) => ( +
+
+ {/* Placeholder image */} +
+ +
+
+

{item.name}

+
+ {item.category} + + ${item.price} +
+
+
+ + +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/Dashboard/shared/IngredientModal.jsx b/src/components/Dashboard/shared/IngredientModal.jsx index ad7b96b..ea52367 100644 --- a/src/components/Dashboard/shared/IngredientModal.jsx +++ b/src/components/Dashboard/shared/IngredientModal.jsx @@ -51,6 +51,15 @@ function IngredientModal({ isOpen, onClose, onSubmit, initialData }) { } }, [isOpen, initialData]); + useEffect(() => { + const blobUrl = formData.image; + return () => { + if (blobUrl?.startsWith("blob:")) { + URL.revokeObjectURL(blobUrl); + } + }; + }, [formData.image]); + if (!isOpen) return null; const validate = (field, value) => { @@ -84,10 +93,13 @@ function IngredientModal({ isOpen, onClose, onSubmit, initialData }) { const handleImageChange = (e) => { const file = e.target.files[0]; - if (file) { - const url = URL.createObjectURL(file); - setFormData((prev) => ({ ...prev, image: url })); + if (!file) return; + // Revoke previous blob URL before creating a new one + if (formData.image?.startsWith("blob:")) { + URL.revokeObjectURL(formData.image); } + const url = URL.createObjectURL(file); + setFormData((prev) => ({ ...prev, image: url })); }; const handleBlur = (e) => { diff --git a/src/components/Dashboard/shared/MenuModal.jsx b/src/components/Dashboard/shared/MenuModal.jsx deleted file mode 100644 index cd6798c..0000000 --- a/src/components/Dashboard/shared/MenuModal.jsx +++ /dev/null @@ -1,224 +0,0 @@ -import { useState, useEffect } from "react"; -import { FiX, FiAlertCircle, FiUploadCloud } from "react-icons/fi"; - -// ── Regex validators ──────────────────────────────────────────────── -const VALIDATORS = { - name: { pattern: /^[a-zA-Z\u0600-\u06FF\s]{2,60}$/, msg: "2–60 letters only" }, - price: { pattern: /^\d+(\.\d{1,2})?$/, msg: "e.g. 15 or 15.99" }, - nutrient: { pattern: /^\d+(\.\d+)?g?$/, msg: "e.g. 25 or 25g" }, -}; - -// ── Character-level blockers ──────────────────────────────────────── -const FILTERS = { - name: (v) => v.replace(/[^a-zA-Z\u0600-\u06FF\s]/g, ""), - price: (v) => v.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").replace(/(\.\d{2})\d+/, "$1"), - nutrient: (v) => v.replace(/[^\d.g]/g, "").replace(/(\.\d+)g?.*/, "$1g"), -}; - -const EMPTY_FORM = { - name: "", category: "Chicken", - fat: "", calories: "", protein: "", sugar: "", price: "", image: "", -}; - -function Field({ label, id, error, children }) { - return ( -
- - {children} - {error && ( -

- {error} -

- )} -
- ); -} - -function MenuModal({ isOpen, onClose, onSubmit, initialData }) { - const isEditing = !!initialData; - const [formData, setFormData] = useState(EMPTY_FORM); - const [errors, setErrors] = useState({}); - const [touched, setTouched] = useState({}); - - useEffect(() => { - if (isOpen) { - setFormData(initialData ? { ...EMPTY_FORM, ...initialData } : EMPTY_FORM); - setErrors({}); - setTouched({}); - } - }, [isOpen, initialData]); - - if (!isOpen) return null; - - const validate = (field, value) => { - const v = String(value).trim(); - if (field === "name") return VALIDATORS.name.pattern.test(v) ? "" : VALIDATORS.name.msg; - if (field === "price") return VALIDATORS.price.pattern.test(v) ? "" : VALIDATORS.price.msg; - if (["fat","calories","protein","sugar"].includes(field)) { - if (!v) return ""; // optional - return VALIDATORS.nutrient.pattern.test(v) ? "" : VALIDATORS.nutrient.msg; - } - return ""; - }; - - const getFilter = (field) => { - if (field === "name") return FILTERS.name; - if (field === "price") return FILTERS.price; - if (["fat","calories","protein","sugar"].includes(field)) return FILTERS.nutrient; - return (v) => v; - }; - - const handleChange = (e) => { - const { name, value } = e.target; - const filtered = getFilter(name)(value); - setFormData((prev) => ({ ...prev, [name]: filtered })); - if (touched[name]) { - setErrors((prev) => ({ ...prev, [name]: validate(name, filtered) })); - } - }; - - const handleImageChange = (e) => { - const file = e.target.files[0]; - if (file) { - const url = URL.createObjectURL(file); - setFormData((prev) => ({ ...prev, image: url })); - } - }; - - const handleBlur = (e) => { - const { name, value } = e.target; - setTouched((prev) => ({ ...prev, [name]: true })); - setErrors((prev) => ({ ...prev, [name]: validate(name, value) })); - }; - - const handleSubmit = (e) => { - e.preventDefault(); - const required = ["name", "price"]; - const optional = ["fat", "calories", "protein", "sugar"]; - const newErrors = {}; - [...required, ...optional].forEach((f) => { - const err = validate(f, formData[f] ?? ""); - if (err) newErrors[f] = err; - }); - setErrors(newErrors); - setTouched(Object.fromEntries([...required, ...optional].map((f) => [f, true]))); - if (Object.keys(newErrors).length > 0) return; - - // Normalize nutrients - const normalized = { ...formData }; - optional.forEach((f) => { - if (normalized[f] && !/g$/.test(normalized[f])) normalized[f] += "g"; - }); - - onSubmit(normalized); - }; - - const inputClass = (field) => - `w-full bg-gray-50 border rounded-xl px-4 py-2 text-[14px] focus:outline-none transition-colors ${ - errors[field] ? "border-red-400 focus:border-red-400" : "border-gray-200 focus:border-orange-400" - }`; - - return ( -
-
- - {/* Header */} -
-

- {isEditing ? "Edit Meal" : "Add Meal"} -

- -
- - {/* Form */} -
- - - - - - -
- - - {formData.image && ( - Preview - )} -
-
- -
- - - - - - -
- -
- - - - - - -
- -
- - - - - - -
- -
- - -
- -
-
-
- ); -} - -export default MenuModal; diff --git a/src/components/Dashboard/shared/MetricRingCard.jsx b/src/components/Dashboard/shared/MetricRingCard.jsx new file mode 100644 index 0000000..1775975 --- /dev/null +++ b/src/components/Dashboard/shared/MetricRingCard.jsx @@ -0,0 +1,119 @@ +/** + * MetricRingCard + * ───────────────────────────────────────── + * Reusable SVG ring + value card for dashboard metric headers. + * Replaces the duplicate CircleMetric components in IngredientsView + * and ChefMenuView. + * + * Props: + * label {string} - Card title / category name + * value {number} - Primary large number to display + * pct {number} - Fill percentage for the ring (0–100) + * change {number} - % change; shown with up/down arrow + * badge {boolean} - Red "alert" variant (e.g. out-of-stock) + * size {'sm'|'lg'} - 'sm' = compact (Ingredients page), 'lg' = wide (Chef Menu page) + */ +export default function MetricRingCard({ label, value, pct, change = 0, badge = false, size = "sm" }) { + const isLg = size === "lg"; + + // Ring geometry + const r = isLg ? 30 : 20; + const sz = isLg ? 76 : 50; + const cx = sz / 2; + const circ = 2 * Math.PI * r; + const offset = circ - (Math.min(pct, 100) / 100) * circ; + + // Color scheme + const ringColor = badge ? "#EF4444" : "#22C55E"; + const valueColor = badge ? "#EF4444" : (isLg ? "#F97316" : "#F97316"); + + // ── Compact (sm) layout — ring left, text right ── + if (!isLg) { + return ( +
+ {/* Ring */} +
+ + + + + {pct}% +
+ + {/* Text */} +
+

{label}

+

+ {value} +

+

+ {change >= 0 ? "↑" : "↓"} {Math.abs(change).toFixed(2)}% +

+
+
+ ); + } + + // ── Large (lg) layout — ring left, text right, wider card ── + return ( +
+ {/* Ring */} +
+ + + + +
+ {pct}% +
+
+ + {/* Text */} +
+

{label}

+

+ {value} +

+ {change !== undefined && ( +

= 0 ? "text-green-500" : "text-red-400"}`}> + {change >= 0 ? "↑" : "↓"} {Math.abs(change).toFixed(2)}% +

+ )} +
+
+ ); +} diff --git a/src/components/Dashboard/shared/StatusBadge.jsx b/src/components/Dashboard/shared/StatusBadge.jsx index 0c5be0e..7709cd0 100644 --- a/src/components/Dashboard/shared/StatusBadge.jsx +++ b/src/components/Dashboard/shared/StatusBadge.jsx @@ -10,17 +10,18 @@ const STATUS_MAP = { // Order statuses - Preparing: "bg-[#F97316] text-white", - Ready: "bg-[#16A34A] text-white", - Done: "bg-gray-400 text-white", - Cancelled: "bg-red-500 text-white", - Pending: "bg-yellow-500 text-white", + Preparing: "bg-[#F97316] text-white", + Ready: "bg-[#16A34A] text-white", + Done: "bg-gray-400 text-white", + Cancelled: "bg-red-500 text-white", + Pending: "bg-yellow-500 text-white", + Queue: "bg-yellow-500 text-white", // Menu statuses - Active: "bg-green-50 text-green-600", - Inactive: "bg-gray-100 text-gray-500", + Active: "bg-green-50 text-green-600", + Inactive: "bg-gray-100 text-gray-500", // Stock statuses (Ingredients) - "In Stock": "bg-green-50 text-green-600", - "Low Stock": "bg-yellow-50 text-yellow-600", + "In Stock": "bg-green-50 text-green-600", + "Low Stock": "bg-yellow-50 text-yellow-600", "Out of Stock": "bg-red-50 text-red-500", }; diff --git a/src/hooks/dashboard/useKitchenOrders.js b/src/hooks/dashboard/useKitchenOrders.js index db92827..4ff6af8 100644 --- a/src/hooks/dashboard/useKitchenOrders.js +++ b/src/hooks/dashboard/useKitchenOrders.js @@ -12,13 +12,23 @@ */ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getKitchenOrders, updateKitchenStatus } from "../../services/dashboardService"; +import { + getKitchenOrders, + updateKitchenStatus, + getActiveKitchenTickets, + updateTicketStatus, + updateChefStatus, + updateChefStation, + updateChefDisplayName, +} from "../../services/dashboardService"; +import { useToast } from "../../store/toastStore"; const POLL_INTERVAL_MS = 30_000; // 30 s — swap for WS when ready export const kitchenKeys = { - all: ["kitchen"], - orders: () => ["kitchen", "orders"], + all: ["kitchen"], + orders: () => ["kitchen", "orders"], + tickets: () => ["kitchen", "tickets"], }; export function useRealtimeKitchen() { @@ -96,6 +106,93 @@ export function useUpdateKitchenStatus() { onError: (_err, _vars, ctx) => { if (ctx?.prev) qc.setQueryData(kitchenKeys.orders(), ctx.prev); }, - onSettled: () => qc.invalidateQueries({ queryKey: kitchenKeys.orders() }), + onSettled: () => qc.invalidateQueries({ queryKey: kitchenKeys.all }), + }); +} + +// ══════════════════════════════════════════════════════════════════ +// Kitchen-Service hooks (tickets + chef management) +// ══════════════════════════════════════════════════════════════════ + +/** Fetch active kitchen tickets — polls every 30 s */ +export function useActiveTickets() { + return useQuery({ + queryKey: kitchenKeys.tickets(), + queryFn: getActiveKitchenTickets, + refetchInterval: POLL_INTERVAL_MS, + refetchOnMount: "always", + staleTime: 0, + placeholderData: [], + }); +} + +/** Mutation: update a kitchen ticket's status */ +export function useUpdateTicketStatus() { + const qc = useQueryClient(); + const toast = useToast(); + + return useMutation({ + mutationFn: ({ ticketId, status }) => updateTicketStatus(ticketId, status), + + // Optimistic update + onMutate: async ({ ticketId, status }) => { + await qc.cancelQueries({ queryKey: kitchenKeys.tickets() }); + const prev = qc.getQueryData(kitchenKeys.tickets()); + + qc.setQueryData(kitchenKeys.tickets(), (old) => { + if (!Array.isArray(old)) return old; + return old.map((t) => (t.id === ticketId ? { ...t, status } : t)); + }); + + return { prev }; + }, + + onError: (_err, _vars, ctx) => { + if (ctx?.prev) qc.setQueryData(kitchenKeys.tickets(), ctx.prev); + toast.error("Failed to update ticket status."); + }, + onSuccess: (_data, { status }) => { + toast.success(`Ticket moved to ${status}.`); + }, + onSettled: () => qc.invalidateQueries({ queryKey: kitchenKeys.all }), + }); +} + +/** Mutation: toggle chef active/inactive */ +export function useUpdateChefStatus() { + const qc = useQueryClient(); + const toast = useToast(); + + return useMutation({ + mutationFn: ({ chefId, status }) => updateChefStatus(chefId, status), + onError: () => toast.error("Failed to update chef status."), + onSuccess: (_data, { status }) => toast.success(`Chef marked as ${status}.`), + onSettled: () => qc.invalidateQueries({ queryKey: kitchenKeys.tickets() }), + }); +} + +/** Mutation: change chef station */ +export function useUpdateChefStation() { + const qc = useQueryClient(); + const toast = useToast(); + + return useMutation({ + mutationFn: ({ chefId, station }) => updateChefStation(chefId, station), + onError: () => toast.error("Failed to update chef station."), + onSuccess: (_data, { station }) => toast.success(`Chef station updated to ${station}.`), + onSettled: () => qc.invalidateQueries({ queryKey: kitchenKeys.tickets() }), + }); +} + +/** Mutation: edit chef display name */ +export function useUpdateChefDisplayName() { + const qc = useQueryClient(); + const toast = useToast(); + + return useMutation({ + mutationFn: ({ chefId, displayName }) => updateChefDisplayName(chefId, displayName), + onError: () => toast.error("Failed to update chef display name."), + onSuccess: () => toast.success("Chef display name updated."), + onSettled: () => qc.invalidateQueries({ queryKey: kitchenKeys.tickets() }), }); } diff --git a/src/hooks/dashboard/useMenuItems.js b/src/hooks/dashboard/useMenuItems.js index 92951ce..b27eba6 100644 --- a/src/hooks/dashboard/useMenuItems.js +++ b/src/hooks/dashboard/useMenuItems.js @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getMenuCategories, getMenuItems, deleteMenuItem, updateMenuItem, createMenuItem, saveRecipe, getRecipeIngredients } from "../../services/dashboardService"; +import { getMenuCategories, getMenuItems, deleteMenuItem, updateMenuItem, createMenuItem, saveRecipe, getRecipeIngredients, uploadMealImage } from "../../services/dashboardService"; export const menuKeys = { all: ["menu"], @@ -48,7 +48,34 @@ export function useDeleteMenuItem() { export function useUpdateMenuItem() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, data }) => updateMenuItem(id, data), + mutationFn: async ({ id, data }) => { + const payload = { + name: data.name, + description: data.description || "", + price: parseFloat(data.price), + category: data.category, + ingredients: Array.isArray(data.ingredients) ? data.ingredients : [], + time: data.time || "", + fat: data.fat || "", + calories: data.calories || "", + protein: data.protein || "", + sugar: data.sugar || "", + }; + + const res = await updateMenuItem(id, payload); + + // Image upload is a partial-success step: a failed upload should not + // roll back the already-committed meal update. + if (data.imageFile) { + try { + await uploadMealImage(id, data.imageFile); + } catch (imgErr) { + console.warn("[useUpdateMenuItem] Image upload failed (meal saved):", imgErr); + } + } + + return res; + }, onSettled: () => qc.invalidateQueries({ queryKey: menuKeys.all }), }); } @@ -57,8 +84,35 @@ export function useUpdateMenuItem() { export function useCreateMenuItem() { const qc = useQueryClient(); return useMutation({ - mutationFn: createMenuItem, - onSettled: () => qc.invalidateQueries({ queryKey: menuKeys.all }), + mutationFn: async (data) => { + const payload = { + name: data.name, + description: data.description || "", + price: parseFloat(data.price), + category: data.category, + ingredients: Array.isArray(data.ingredients) ? data.ingredients : [], + time: data.time || "", + fat: data.fat || "", + calories: data.calories || "", + protein: data.protein || "", + sugar: data.sugar || "", + }; + + const meal = await createMenuItem(payload); + + // Image upload is a partial-success step: a failed upload should not + // roll back the already-committed meal create. + if (data.imageFile && meal && (meal.id || meal._id)) { + try { + await uploadMealImage(meal.id || meal._id, data.imageFile); + } catch (imgErr) { + console.warn("[useCreateMenuItem] Image upload failed (meal saved):", imgErr); + } + } + + return meal; + }, + onSettled: () => qc.invalidateQueries({ queryKey: menuKeys.all }), }); } diff --git a/src/mocks/dashboardMock.js b/src/mocks/dashboardMock.js index 1064a54..52c8869 100644 --- a/src/mocks/dashboardMock.js +++ b/src/mocks/dashboardMock.js @@ -187,6 +187,19 @@ export const mockKitchenIngredients = [ { id: 5, name: "Caesar Dressing", checked: false }, ]; +export const mockKitchenTickets = [ + { id: 1, orderId: "#58B1D", status: "Preparing", assignedChef: "Chef John", createdAt: new Date(Date.now() - 15 * 60000).toISOString() }, + { id: 2, orderId: "#58B2A", status: "Queue", assignedChef: "Unassigned", createdAt: new Date(Date.now() - 5 * 60000).toISOString() }, + { id: 3, orderId: "#58B2B", status: "Queue", assignedChef: "Unassigned", createdAt: new Date(Date.now() - 2 * 60000).toISOString() }, + { id: 4, orderId: "#58B1C", status: "Ready", assignedChef: "Chef Sarah", createdAt: new Date(Date.now() - 25 * 60000).toISOString() }, +]; + +export const mockChefs = loadMock("kitchenChefs", [ + { id: 1, displayName: "Chef John", status: "ACTIVE", station: "Grill", avatar: "CJ" }, + { id: 2, displayName: "Chef Sarah", status: "ACTIVE", station: "Prep", avatar: "CS" }, + { id: 3, displayName: "Chef Mike", status: "INACTIVE", station: "Assembly", avatar: "CM" }, +]); + // ── Menu Management ─────────────────────────────────────────────── export const mockMenuUploads = [ diff --git a/src/mocks/handlers.js b/src/mocks/handlers.js index 9c2b5a7..436489c 100644 --- a/src/mocks/handlers.js +++ b/src/mocks/handlers.js @@ -146,6 +146,13 @@ export const MOCK_HANDLERS = [ : { status: 404, data: { message: `Meal ${id} not found` } }; }, }, + { + method: "post", + match: (url) => url.match(/\/menu\/\d+\/image/), + handler: () => { + return { status: 200, data: { success: true } }; + }, + }, { method: "delete", match: (url) => url.match(/\/menu\/\d+/), @@ -202,6 +209,7 @@ export const MOCK_HANDLERS = [ }; dash.mockKitchenOrders.queue.push(kitchenEntry); dash.saveMock("kitchenOrders", dash.mockKitchenOrders); + console.log('[MOCK] POST /orders — pushed to kitchen queue:', kitchenEntry); console.log('[MOCK] kitchen queue is now:', dash.mockKitchenOrders.queue.length, 'items'); @@ -425,6 +433,96 @@ export const MOCK_HANDLERS = [ return { status: 200, data: { success: true } }; } }, + // ────────────────────────────────────────────── + // KITCHEN SERVICE (Tickets + Chefs) + // ────────────────────────────────────────────── + { method: "get", match: (url) => url.endsWith("/api/kitchen/tickets/active"), handler: () => { + const saved = localStorage.getItem("mock_kitchenOrders"); + const kitchenOrders = saved ? JSON.parse(saved) : dash.mockKitchenOrders; + + // Dynamically generate tickets from the Live Kitchen Board source of truth + const tickets = []; + const mapCol = (colName, statusName) => { + if (!kitchenOrders[colName]) return; + kitchenOrders[colName].forEach((o, i) => { + tickets.push({ + id: o.id.replace('#', ''), // numeric ticket id + orderId: o.id.replace('#', ''), // rendered as #{ticket.orderId} — no double prefix + status: statusName, + chefDisplayName: o.assignedChef || (o.startedAt ? "Chef John" : null), + assignedChefId: o.assignedChefId || (o.startedAt ? 1 : null), + createdAt: o.createdAt || new Date(Date.now() - (i*5 + 2) * 60000).toISOString() + }); + }); + }; + + mapCol("queue", "Queue"); + mapCol("preparing", "Preparing"); + mapCol("ready", "Ready"); + mapCol("done", "Done"); + + return { status: 200, data: tickets }; + }}, + { method: "patch", match: (url) => url.match(/\/api\/kitchen\/tickets\/[\w-]+\/status/), handler: (config) => { + const id = config.url.split("/")[4]; + const { status } = JSON.parse(config.data || "{}"); // e.g. "Preparing" + + const targetStatus = status.toLowerCase(); // "preparing" + + // Find the Kanban board patch handler and invoke it directly to ensure perfect sync + const handler = MOCK_HANDLERS.find(h => h.method === "patch" && h.match("/kitchen/orders/123/status")); + if (handler) { + handler.handler({ + ...config, + url: `/kitchen/orders/${id}/status`, + data: JSON.stringify({ status: targetStatus }) + }); + } + + return { status: 200, data: { success: true } }; + }}, + { method: "get", match: (url) => url.endsWith("/api/kitchen/chefs"), handler: () => { + const saved = localStorage.getItem("mock_kitchenChefs"); + const chefs = saved ? JSON.parse(saved) : dash.mockChefs; + return { status: 200, data: chefs }; + }}, + { method: "patch", match: (url) => url.match(/\/api\/kitchen\/chefs\/\d+\/status/), handler: (config) => { + const id = parseInt(config.url.split("/")[4], 10); + const { status } = JSON.parse(config.data || "{}"); + const saved = localStorage.getItem("mock_kitchenChefs"); + const chefs = saved ? JSON.parse(saved) : dash.mockChefs; + const chef = chefs.find(c => c.id === id); + if (chef) { + chef.status = status; + dash.saveMock("kitchenChefs", chefs); + } + return { status: 200, data: { success: true } }; + }}, + { method: "patch", match: (url) => url.match(/\/api\/kitchen\/chefs\/\d+\/station/), handler: (config) => { + const id = parseInt(config.url.split("/")[4], 10); + const { station } = JSON.parse(config.data || "{}"); + const saved = localStorage.getItem("mock_kitchenChefs"); + const chefs = saved ? JSON.parse(saved) : dash.mockChefs; + const chef = chefs.find(c => c.id === id); + if (chef) { + chef.station = station; + dash.saveMock("kitchenChefs", chefs); + } + return { status: 200, data: { success: true } }; + }}, + { method: "patch", match: (url) => url.match(/\/api\/kitchen\/chefs\/\d+\/display-name/), handler: (config) => { + const id = parseInt(config.url.split("/")[4], 10); + const { displayName } = JSON.parse(config.data || "{}"); + const saved = localStorage.getItem("mock_kitchenChefs"); + const chefs = saved ? JSON.parse(saved) : dash.mockChefs; + const chef = chefs.find(c => c.id === id); + if (chef) { + chef.displayName = displayName; + dash.saveMock("kitchenChefs", chefs); + } + return { status: 200, data: { success: true } }; + }}, + // ────────────────────────────────────────────── // MENU MANAGEMENT (uploads) // ────────────────────────────────────────────── diff --git a/src/services/dashboardService.js b/src/services/dashboardService.js index b941d8b..6ffef03 100644 --- a/src/services/dashboardService.js +++ b/src/services/dashboardService.js @@ -39,30 +39,103 @@ export const getKitchenOrders = () => }); export const updateKitchenStatus = (orderId, status) => api.patch(`/kitchen/orders/${encodeURIComponent(orderId)}/status`, { status }).then(r => r.data); +// ── Kitchen Service (tickets + chef management) ─────────────────── +export const getActiveKitchenTickets = () => api.get("/api/kitchen/tickets/active").then(r => r.data); +export const updateTicketStatus = (ticketId, status) => api.patch(`/api/kitchen/tickets/${ticketId}/status`, { status }).then(r => r.data); +export const updateChefStatus = (chefId, status) => api.patch(`/api/kitchen/chefs/${chefId}/status`, { status }).then(r => r.data); +export const updateChefStation = (chefId, station) => api.patch(`/api/kitchen/chefs/${chefId}/station`, { station }).then(r => r.data); +export const updateChefDisplayName = (chefId, displayName) => api.patch(`/api/kitchen/chefs/${chefId}/display-name`, { displayName }).then(r => r.data); + // ── Menu (Chef Menu page) ───────────────────────────────────────── -export const getMenuCategories = () => api.get("/menu/categories").then(r => Mappers.mapMenuCategories(r.data)); -export const getMenuItems = (params = {}) => api.get("/menu/items", { params }).then(r => Mappers.mapMenuItems(r.data)); -export const deleteMenuItem = (id) => api.delete(`/menu/items/${id}`).then(r => r.data); -export const updateMenuItem = (id, data) => api.patch(`/menu/items/${id}`, data).then(r => r.data); -export const createMenuItem = (data) => api.post("/menu/items", data).then(r => r.data); +export const getMenuCategories = async () => { + // The backend doesn't have a /categories endpoint, so we derive it from the menu items directly. + const items = await getMenuItems(); + const counts = {}; + items.forEach(item => { + const cat = item.category || "Other"; + counts[cat] = (counts[cat] || 0) + 1; + }); + + const total = items.length; + const categories = Object.entries(counts).map(([name, count], index) => { + const colors = ["#F97316", "#8B5CF6", "#3B82F6", "#10B981", "#EC4899"]; + return { + name, + count, + percentage: total > 0 ? Math.round((count / total) * 100) : 0, + color: colors[index % colors.length], + change: "+0%" // Placeholder since we can't calculate historical change purely from current items + }; + }); + + return Mappers.mapMenuCategories({ + totalChange: "+0%", + items: categories + }); +}; +export const getMenuItems = (params = {}) => api.get("/api/menu", { params }).then(r => Mappers.mapMenuItems(r.data)); +export const deleteMenuItem = (id) => api.delete(`/api/menu/${id}`).then(r => r.data); +export const updateMenuItem = (id, data) => api.put(`/api/menu/${id}`, data).then(r => r.data); +export const createMenuItem = (data) => api.post("/api/menu", data).then(r => r.data); +export const updateMenuDiscount = (id, data) => api.patch(`/api/menu/${id}/discount`, data).then(r => r.data); +export const uploadMealImage = (id, file) => { + const formData = new FormData(); + formData.append("file", file); + return api.post(`/api/menu/${id}/image`, formData, { + headers: { "Content-Type": "multipart/form-data" } + }).then(r => r.data); +}; // ── Recipe Builder ──────────────────────────────────────────────── export const getRecipeIngredients = () => api.get("/recipes/ingredients").then(r => r.data); export const saveRecipe = (data) => api.post("/recipes", data).then(r => r.data); // ── Menu Management ─────────────────────────────────────────────── -export const getMenuUploads = () => api.get("/menu/uploads").then(r => Mappers.mapMenuUploads(r.data)); -export const uploadMenuFile = (file) => - api.post("/menu/upload", (() => { const f = new FormData(); f.append("file", file); return f; })(), { - headers: { "X-File-Name": file.name } - }).then(r => r.data); +export const getMenuUploads = () => { + const uploads = JSON.parse(localStorage.getItem('menuUploads') || '[]'); + return Promise.resolve(Mappers.mapMenuUploads(uploads)); +}; + +export const uploadMenuFile = async (file) => { + const formData = new FormData(); + formData.append("file", file); + + const headers = { "Content-Type": "multipart/form-data" }; + const user = useAuthStore.getState().user; + if (user && user.role) { + headers["X-User-Role"] = user.role; + } + + const result = await api.post("/api/inventory/upload", formData, { headers }).then(r => r.data); + + // Store in localStorage so it appears in the Recent Uploads table + const uploads = JSON.parse(localStorage.getItem('menuUploads') || '[]'); + const now = new Date(); + + const newUpload = { + id: `UPL-${Date.now()}`, + filename: file.name, + date: now.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }), + time: now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }), + status: "Success", + added: 0, + updated: 0 + }; + + localStorage.setItem('menuUploads', JSON.stringify([newUpload, ...uploads])); + + return result; +}; // ── Ingredients ─────────────────────────────────────────────────── export const getIngredientsMetrics = () => api.get("/ingredients/metrics").then(r => Mappers.mapIngredientsMetrics(r.data)); -export const getIngredients = (params = {}) => api.get("/ingredients", { params }).then(r => Mappers.mapIngredients(r.data)); -export const createIngredient = (data) => api.post("/ingredients", data).then(r => r.data); -export const updateIngredient = (id, data) => api.patch(`/ingredients/${id}`, data).then(r => r.data); -export const deleteIngredient = (id) => api.delete(`/ingredients/${id}`).then(r => r.data); +export const getIngredients = (params = {}) => api.get("/api/ingredients", { params }).then(r => Mappers.mapIngredients(r.data)); +export const createIngredient = (data) => api.post("/api/ingredients", data).then(r => r.data); +export const updateIngredient = (id, data) => api.patch(`/api/ingredients/${id}/stock`, data).then(r => r.data); +export const deleteIngredient = (id) => api.delete(`/api/ingredients/${id}`).then(r => r.data); +export const bulkUpdateIngredientsStock = (data) => api.patch("/api/ingredients/bulk/stock", data).then(r => r.data); +export const reserveIngredientsStock = (data) => api.post("/api/ingredients/reserve", data).then(r => r.data); +export const revertIngredientsStock = (data) => api.post("/api/ingredients/revert", data).then(r => r.data); export const uploadIngredientsFile = (file) => { diff --git a/src/services/mappers/dashboardMappers.js b/src/services/mappers/dashboardMappers.js index 5b30cc3..cbce04f 100644 --- a/src/services/mappers/dashboardMappers.js +++ b/src/services/mappers/dashboardMappers.js @@ -120,6 +120,43 @@ export const mapOrders = (data) => { })); }; +/** + * mapOrderResponse + * Maps the real OrderResponse from the Order Service (POST /api/order, GET /api/order/:id). + * + * Real status enum: PENDING | AWAITING_PAYMENT | PAID | CONFIRMED | CANCELLED | READY_FOR_PICKUP + */ +export const ORDER_STATUS_LABELS = { + PENDING: "Pending", + AWAITING_PAYMENT: "Awaiting Payment", + PAID: "Paid", + CONFIRMED: "Confirmed", + CANCELLED: "Cancelled", + READY_FOR_PICKUP: "Ready for Pickup", +}; + +export const mapOrderResponse = (data) => ({ + id: data.id, + customerId: data.customerId, + status: data.status || "PENDING", + statusLabel: ORDER_STATUS_LABELS[data.status || "PENDING"] ?? (data.status || "PENDING"), + totalPrice: data.totalPrice || 0, + discount: data.discount || 0, + createdAt: data.createdAt || null, + // Stripe client secret — pass to Stripe.js to collect payment + stripeClientSecret: data.stripeClientSecret || null, + items: Array.isArray(data.items) + ? data.items.map((item) => ({ + id: item.id, + mealId: item.mealId, + name: item.snapshotName || "", + price: item.snapshotPrice || 0, + quantity: item.quantity || 1, + })) + : [], +}); + + // ── Live Kitchen Mappers ────────────────────────────────────────── export const mapKitchenOrders = (data) => { @@ -176,8 +213,13 @@ export const mapMenuItems = (data) => { fat: item.fat || "0g", sugar: item.sugar || "0g", rating: item.rating || 0, - status: item.status || "Active", + status: item.isActive !== undefined ? (item.isActive ? "Active" : "Inactive") : (item.status || "Active"), image: item.image || null, + description: item.description || "", + hasDiscount: item.hasDiscount || false, + discountPercentage: item.discountPercentage || 0, + mealIngredients: item.mealIngredients || [], + ingredients: Array.isArray(item.ingredients) ? item.ingredients : (item.mealIngredients || []), })); }; @@ -207,7 +249,7 @@ export const mapIngredients = (data) => { stock: item.stock || 0, unit: item.unit || "unit", costPerUnit: item.costPerUnit || "$0.00", - status: item.status || "In Stock", + status: item.stock > 0 ? "In Stock" : "Out of Stock", })); }; diff --git a/src/store/authStore.js b/src/store/authStore.js index 32bd6e3..e151ea0 100644 --- a/src/store/authStore.js +++ b/src/store/authStore.js @@ -46,7 +46,7 @@ const useAuthStore = create( token: null, expiresAt: null, - isAuthenticated: false, + isAuthenticated: true, loading: false, error: null, diff --git a/src/utils/sortItems.js b/src/utils/sortItems.js new file mode 100644 index 0000000..cd5f981 --- /dev/null +++ b/src/utils/sortItems.js @@ -0,0 +1,57 @@ +/** + * sortItems + * ───────────────────────────────────────── + * Generic multi-column sort for dashboard tables. + * Handles both numeric values and numeric strings with units (e.g. "15g", "1.2k"). + * + * @param {Array} items - Array of objects to sort (not mutated) + * @param {string|null} sortKey - Object key to sort by; null = unsorted + * @param {'asc'|'desc'} sortDir - Sort direction + * @param {Array} [columns] - Optional column config array; if the matching column + * has a `comparator(a, b)` function it will be used instead + * of the default numeric/string logic. + * @returns {Array} New sorted array + */ +export function sortItems(items, sortKey, sortDir, columns = []) { + if (!sortKey) return items; + + // If the active column defines a custom comparator, delegate to it. + const colDef = columns.find((c) => c.key === sortKey); + if (colDef?.comparator) { + return [...items].sort((a, b) => { + const cmp = colDef.comparator(a, b); + return sortDir === "asc" ? cmp : -cmp; + }); + } + + return [...items].sort((a, b) => { + const av = a[sortKey] ?? ""; + const bv = b[sortKey] ?? ""; + + const parseNum = (val) => { + if (typeof val === "number") return val; + const str = String(val).trim().toLowerCase(); + // Recognise magnitude suffixes: 1.2k → 1200, 3.5m → 3500000, 2b → 2000000000 + const suffixMatch = str.match(/^([\d.]+)\s*([kmb])$/); + if (suffixMatch) { + const n = parseFloat(suffixMatch[1]); + const multipliers = { k: 1_000, m: 1_000_000, b: 1_000_000_000 }; + return n * (multipliers[suffixMatch[2]] ?? 1); + } + const match = str.match(/[\d.]+/); + return match ? parseFloat(match[0]) : NaN; + }; + + const aNum = parseNum(av); + const bNum = parseNum(bv); + + let cmp; + if (!isNaN(aNum) && !isNaN(bNum)) { + cmp = aNum - bNum; + } else { + cmp = String(av).localeCompare(String(bv)); + } + + return sortDir === "asc" ? cmp : -cmp; + }); +}