|
|
| |
| |
| |
| |
|
|
| import { useState, useEffect, useCallback, useRef } from "react"; |
|
|
| |
| const STORAGE_KEY = "restoflo_v3"; |
| const POLL_INTERVAL = 3000; |
|
|
| const MODULES = [ |
| { id: "dashboard", label: "Dashboard", icon: "⬛", group: "Main" }, |
| { id: "pos", label: "Point of Sale", icon: "🧾", group: "Main" }, |
| { id: "tables", label: "Table Map", icon: "🪑", group: "Main" }, |
| { id: "kitchen", label: "Kitchen / KOT", icon: "🍳", group: "Main" }, |
| { id: "orders", label: "Orders", icon: "📋", group: "Operations" }, |
| { id: "billing", label: "Billing", icon: "💳", group: "Operations" }, |
| { id: "menu", label: "Menu Items", icon: "🍽", group: "Operations" }, |
| { id: "categories", label: "Categories", icon: "🏷", group: "Operations" }, |
| { id: "inventory", label: "Inventory", icon: "📦", group: "Operations" }, |
| { id: "reports", label: "Reports", icon: "📊", group: "Finance" }, |
| { id: "accounting", label: "Accounting", icon: "📒", group: "Finance" }, |
| { id: "expenses", label: "Expenses", icon: "💸", group: "Finance" }, |
| { id: "payroll", label: "Payroll", icon: "💰", group: "Finance" }, |
| { id: "crm", label: "CRM / Customers", icon: "👤", group: "Sales" }, |
| { id: "subscriptions", label: "Subscriptions", icon: "🔄", group: "Sales" }, |
| { id: "ecommerce", label: "eCommerce", icon: "🛒", group: "Sales" }, |
| { id: "website", label: "Website Builder", icon: "🌐", group: "Websites" }, |
| { id: "livechat", label: "Live Chat", icon: "💬", group: "Websites" }, |
| { id: "elearning", label: "eLearning", icon: "🎓", group: "Websites" }, |
| { id: "employees", label: "Employees", icon: "🧑💼", group: "HR" }, |
| { id: "recruitment", label: "Recruitment", icon: "📣", group: "HR" }, |
| { id: "timeoff", label: "Time Off", icon: "🏖", group: "HR" }, |
| { id: "appraisals", label: "Appraisals", icon: "⭐", group: "HR" }, |
| { id: "fleet", label: "Fleet", icon: "🚗", group: "HR" }, |
| { id: "marketing", label: "Email Marketing", icon: "📧", group: "Marketing" }, |
| { id: "sms", label: "SMS Marketing", icon: "📱", group: "Marketing" }, |
| { id: "events", label: "Events", icon: "🎫", group: "Marketing" }, |
| { id: "surveys", label: "Surveys", icon: "📝", group: "Marketing" }, |
| { id: "project", label: "Projects", icon: "🗂", group: "Services" }, |
| { id: "helpdesk", label: "Helpdesk", icon: "🎫", group: "Services" }, |
| { id: "appointments", label: "Appointments", icon: "📅", group: "Services" }, |
| { id: "purchase", label: "Purchase", icon: "🛍", group: "Supply Chain" }, |
| { id: "quality", label: "Quality Control", icon: "✅", group: "Supply Chain" }, |
| { id: "maintenance", label: "Maintenance", icon: "🔧", group: "Supply Chain" }, |
| { id: "discuss", label: "Discuss", icon: "💭", group: "Productivity" }, |
| { id: "calendar", label: "Calendar", icon: "📆", group: "Productivity" }, |
| { id: "users", label: "Users & Staff", icon: "👥", group: "Admin" }, |
| { id: "access", label: "Access Control", icon: "🔐", group: "Admin" }, |
| { id: "settings", label: "Settings", icon: "⚙️", group: "Admin" }, |
| ]; |
|
|
| const ADMIN_MODULES = MODULES.map(m => m.id); |
| const INITIAL_PERMS = ["pos", "tables", "kitchen", "orders", "menu"]; |
|
|
| const COLORS = ["#875BF7", "#039855", "#EF4444", "#F59E0B", "#3B82F6", "#EC4899", "#14B8A6", "#8B5CF6"]; |
|
|
| function initDB() { |
| return { |
| users: [ |
| { id: "admin", name: "Admin User", role: "admin", pass: "admin123", email: "admin@restoflo.com", phone: "", color: "#875BF7", permissions: ADMIN_MODULES, active: true, pin: "0000" }, |
| { id: "waiter1", name: "Ravi Kumar", role: "waiter", pass: "pass123", email: "ravi@restoflo.com", phone: "+91 98100 11111", color: "#3B82F6", permissions: INITIAL_PERMS, active: true, pin: "1111" }, |
| { id: "waiter2", name: "Priya Sharma", role: "waiter", pass: "pass456", email: "priya@restoflo.com", phone: "+91 98100 22222", color: "#039855", permissions: ["pos","tables","kitchen"], active: true, pin: "2222" }, |
| { id: "cashier1", name: "Arun Singh", role: "cashier", pass: "cash789", email: "arun@restoflo.com", phone: "+91 98100 33333", color: "#F59E0B", permissions: ["billing","orders","reports","accounting"], active: true, pin: "3333" }, |
| { id: "manager1", name: "Sunita Reddy", role: "manager", pass: "mgr456", email: "sunita@restoflo.com", phone: "+91 98100 44444", color: "#EC4899", permissions: ["dashboard","pos","tables","kitchen","orders","billing","menu","categories","inventory","reports","crm","employees"], active: true, pin: "4444" }, |
| ], |
| categories: [ |
| { id: "starter", name: "Starters", icon: "🥗", color: "#039855", sortOrder: 1 }, |
| { id: "main", name: "Main Course", icon: "🍛", color: "#F59E0B", sortOrder: 2 }, |
| { id: "biryani", name: "Biryani", icon: "🍚", color: "#875BF7", sortOrder: 3 }, |
| { id: "breads", name: "Breads", icon: "🫓", color: "#EF4444", sortOrder: 4 }, |
| { id: "drinks", name: "Drinks", icon: "🥤", color: "#3B82F6", sortOrder: 5 }, |
| { id: "desserts",name: "Desserts", icon: "🍰", color: "#EC4899", sortOrder: 6 }, |
| ], |
| menuItems: [ |
| { id: 1, name: "Chicken Tikka", cat: "starter", price: 280, tax: 5, type: "nonveg", emoji: "🍗", desc: "Tender marinated chicken grilled in tandoor", status: "active", cost: 120, stock: 999 }, |
| { id: 2, name: "Paneer Tikka", cat: "starter", price: 220, tax: 5, type: "veg", emoji: "🧀", desc: "Cottage cheese marinated and grilled", status: "active", cost: 90, stock: 999 }, |
| { id: 3, name: "Veg Spring Roll", cat: "starter", price: 160, tax: 5, type: "veg", emoji: "🥗", desc: "Crispy rolls with fresh vegetables", status: "active", cost: 60, stock: 999 }, |
| { id: 4, name: "Crispy Prawns", cat: "starter", price: 380, tax: 5, type: "nonveg", emoji: "🦐", desc: "Golden fried prawns with cocktail sauce", status: "active", cost: 180, stock: 999 }, |
| { id: 5, name: "Chicken Biryani", cat: "biryani", price: 320, tax: 5, type: "nonveg", emoji: "🍚", desc: "Aromatic basmati with slow-cooked chicken", status: "active", cost: 130, stock: 999 }, |
| { id: 6, name: "Veg Biryani", cat: "biryani", price: 260, tax: 5, type: "veg", emoji: "🌾", desc: "Fragrant rice with seasonal vegetables", status: "active", cost: 90, stock: 999 }, |
| { id: 7, name: "Mutton Biryani", cat: "biryani", price: 420, tax: 5, type: "nonveg", emoji: "🍲", desc: "Rich slow-cooked mutton dum biryani", status: "active", cost: 190, stock: 999 }, |
| { id: 8, name: "Prawn Biryani", cat: "biryani", price: 480, tax: 5, type: "nonveg", emoji: "🦐", desc: "Coastal style prawn biryani", status: "active", cost: 220, stock: 999 }, |
| { id: 9, name: "Butter Chicken", cat: "main", price: 300, tax: 5, type: "nonveg", emoji: "🍛", desc: "Creamy tomato-based chicken curry", status: "active", cost: 120, stock: 999 }, |
| { id: 10, name: "Paneer Butter Masala", cat: "main", price: 260, tax: 5, type: "veg", emoji: "🧀", desc: "Rich buttery paneer in tomato gravy", status: "active", cost: 100, stock: 999 }, |
| { id: 11, name: "Dal Tadka", cat: "main", price: 180, tax: 5, type: "veg", emoji: "🫕", desc: "Yellow lentils with aromatic tempering", status: "active", cost: 50, stock: 999 }, |
| { id: 12, name: "Rogan Josh", cat: "main", price: 380, tax: 5, type: "nonveg", emoji: "🍖", desc: "Kashmiri slow-cooked lamb curry", status: "active", cost: 160, stock: 999 }, |
| { id: 13, name: "Naan", cat: "breads", price: 40, tax: 5, type: "veg", emoji: "🫓", desc: "Soft leavened tandoor flatbread", status: "active", cost: 12, stock: 999 }, |
| { id: 14, name: "Tandoori Roti", cat: "breads", price: 30, tax: 5, type: "veg", emoji: "🥙", desc: "Whole wheat tandoor bread", status: "active", cost: 8, stock: 999 }, |
| { id: 15, name: "Garlic Naan", cat: "breads", price: 60, tax: 5, type: "veg", emoji: "🧄", desc: "Naan topped with garlic and butter", status: "active", cost: 18, stock: 999 }, |
| { id: 16, name: "Mango Lassi", cat: "drinks", price: 80, tax: 5, type: "veg", emoji: "🥭", desc: "Chilled mango yogurt smoothie", status: "active", cost: 25, stock: 999 }, |
| { id: 17, name: "Masala Chai", cat: "drinks", price: 40, tax: 5, type: "veg", emoji: "☕", desc: "Spiced Indian milk tea", status: "active", cost: 10, stock: 999 }, |
| { id: 18, name: "Fresh Lime Soda", cat: "drinks", price: 60, tax: 5, type: "veg", emoji: "🍋", desc: "Refreshing lime soda with mint", status: "active", cost: 15, stock: 999 }, |
| { id: 19, name: "Gulab Jamun", cat: "desserts",price: 100, tax: 5, type: "veg", emoji: "🍮", desc: "Soft milk dumplings in rose syrup", status: "active", cost: 30, stock: 999 }, |
| { id: 20, name: "Kulfi", cat: "desserts",price: 120, tax: 5, type: "veg", emoji: "🍦", desc: "Traditional Indian ice cream", status: "active", cost: 35, stock: 999 }, |
| ], |
| tables: Array.from({ length: 16 }, (_, i) => ({ |
| num: i + 1, |
| status: ["free","free","occupied","free","occupied","reserved","free","free","occupied","free","free","reserved","free","occupied","free","free"][i], |
| pax: [2,4,6,2,4,4,2,6,4,2,4,8,2,4,4,6][i], |
| section: i < 6 ? "Indoor" : i < 12 ? "Outdoor" : "Private", |
| currentOrder: null, |
| })), |
| orders: [ |
| { id: "ORD-0001", table: 3, items: [{ id: 5, name: "Chicken Biryani", qty: 2, price: 320, tax: 5 }, { id: 16, name: "Mango Lassi", qty: 2, price: 80, tax: 5 }], subtotal: 800, taxAmount: 40, total: 840, waiter: "waiter1", waiterName: "Ravi Kumar", status: "served", time: Date.now() - 3600000, note: "", paymentMethod: null }, |
| { id: "ORD-0002", table: 6, items: [{ id: 9, name: "Butter Chicken", qty: 1, price: 300, tax: 5 }, { id: 13, name: "Naan", qty: 3, price: 40, tax: 5 }], subtotal: 420, taxAmount: 21, total: 441, waiter: "waiter2", waiterName: "Priya Sharma", status: "preparing", time: Date.now() - 1800000, note: "Less spice", paymentMethod: null }, |
| { id: "ORD-0003", table: 1, items: [{ id: 2, name: "Paneer Tikka", qty: 1, price: 220, tax: 5 }, { id: 6, name: "Veg Biryani", qty: 2, price: 260, tax: 5 }], subtotal: 740, taxAmount: 37, total: 777, waiter: "waiter1", waiterName: "Ravi Kumar", status: "new", time: Date.now() - 600000, note: "", paymentMethod: null }, |
| { id: "ORD-0004", table: 9, items: [{ id: 11, name: "Dal Tadka", qty: 1, price: 180, tax: 5 }, { id: 14, name: "Tandoori Roti", qty: 4, price: 30, tax: 5 }], subtotal: 300, taxAmount: 15, total: 315, waiter: "waiter1", waiterName: "Ravi Kumar", status: "billed", time: Date.now() - 7200000, note: "", paymentMethod: "Cash" }, |
| ], |
| inventory: [ |
| { id: 1, name: "Chicken", category: "Protein", unit: "kg", stock: 15, reorderAt: 5, cost: 180, supplier: "Fresh Farms" }, |
| { id: 2, name: "Basmati Rice", category: "Grains", unit: "kg", stock: 50, reorderAt: 10, cost: 90, supplier: "Grain Hub" }, |
| { id: 3, name: "Paneer", category: "Dairy", unit: "kg", stock: 8, reorderAt: 3, cost: 280, supplier: "Dairy Fresh" }, |
| { id: 4, name: "Cooking Oil", category: "Oils", unit: "liters",stock: 25, reorderAt: 8, cost: 120, supplier: "Oil Co" }, |
| { id: 5, name: "Onions", category: "Vegetables",unit: "kg", stock: 3, reorderAt: 5, cost: 30, supplier: "Veggie Mart" }, |
| { id: 6, name: "Tomatoes", category: "Vegetables",unit: "kg", stock: 12, reorderAt: 4, cost: 40, supplier: "Veggie Mart" }, |
| { id: 7, name: "Mutton", category: "Protein", unit: "kg", stock: 6, reorderAt: 3, cost: 450, supplier: "Meat House" }, |
| { id: 8, name: "Prawns", category: "Seafood", unit: "kg", stock: 4, reorderAt: 2, cost: 520, supplier: "Sea Fresh" }, |
| ], |
| customers: [ |
| { id: 1, name: "Ramesh Iyer", phone: "+91 98001 11111", email: "ramesh@gmail.com", visits: 12, totalSpent: 8400, lastVisit: "Today", tag: "VIP", loyaltyPts: 840 }, |
| { id: 2, name: "Sunita Mehta", phone: "+91 98001 22222", email: "sunita@gmail.com", visits: 5, totalSpent: 3200, lastVisit: "Yesterday", tag: "Regular", loyaltyPts: 320 }, |
| { id: 3, name: "Kiran Rao", phone: "+91 98001 33333", email: "kiran@gmail.com", visits: 28, totalSpent: 19600, lastVisit: "2 days ago", tag: "VIP", loyaltyPts: 1960 }, |
| { id: 4, name: "Deepa Nair", phone: "+91 98001 44444", email: "deepa@gmail.com", visits: 2, totalSpent: 1200, lastVisit: "Last week", tag: "New", loyaltyPts: 120 }, |
| ], |
| expenses: [ |
| { id: 1, desc: "Gas Cylinder", amount: 2800, category: "Utilities", date: "2026-03-25", status: "paid", receipt: true }, |
| { id: 2, desc: "Staff Uniform", amount: 4500, category: "HR", date: "2026-03-20", status: "paid", receipt: true }, |
| { id: 3, desc: "Cleaning Supplies",amount: 1200, category: "Ops", date: "2026-03-28", status: "pending", receipt: false }, |
| ], |
| settings: { |
| restaurantName: "The Grand Restoflo", |
| address: "123, MG Road, Bengaluru - 560001", |
| phone: "+91 80 4567 8900", |
| gst: "29AABCU9603R1Z1", |
| fssai: "12345678901234", |
| currency: "₹", |
| gstRate: 5, |
| serviceCharge: 2, |
| logo: "🍽", |
| theme: "light", |
| kotPrinter: "Kitchen Printer 1", |
| timezone: "Asia/Kolkata", |
| branches: [{ id: "main", name: "Main Branch - MG Road", active: true }], |
| }, |
| orderCounter: 5, |
| lastUpdated: Date.now(), |
| }; |
| } |
|
|
| |
| async function loadDB() { |
| try { |
| const result = await window.storage.get(STORAGE_KEY, true); |
| if (result?.value) return JSON.parse(result.value); |
| } catch {} |
| return null; |
| } |
|
|
| async function saveDB(db) { |
| try { |
| const data = { ...db, lastUpdated: Date.now() }; |
| await window.storage.set(STORAGE_KEY, JSON.stringify(data), true); |
| return data; |
| } catch (e) { |
| console.error("Storage save failed", e); |
| return db; |
| } |
| } |
|
|
| |
| const fmt = (n) => "₹" + Number(n || 0).toLocaleString("en-IN"); |
| const fmtTime = (ts) => new Date(ts).toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit" }); |
| const fmtDate = (ts) => new Date(ts).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" }); |
| const uid = () => Math.random().toString(36).slice(2, 9); |
| const statusColor = { new: "#F59E0B", preparing: "#3B82F6", served: "#039855", billed: "#6B7280", cancelled: "#EF4444" }; |
| const statusBg = { new: "#FEF3C7", preparing: "#DBEAFE", served: "#D1FAE5", billed: "#F3F4F6", cancelled: "#FEE2E2" }; |
|
|
| |
| export default function App() { |
| const [db, setDb] = useState(null); |
| const [loading, setLoading] = useState(true); |
| const [currentUser, setCurrentUser] = useState(null); |
| const [page, setPage] = useState("dashboard"); |
| const [sidebarOpen, setSidebarOpen] = useState(true); |
| const [toast, setToast] = useState([]); |
| const [modal, setModal] = useState(null); |
| const [posState, setPosState] = useState({ items: [], table: null, note: "" }); |
| const lastUpdatedRef = useRef(0); |
|
|
| |
| useEffect(() => { |
| (async () => { |
| const stored = await loadDB(); |
| setDb(stored || initDB()); |
| setLoading(false); |
| })(); |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (!currentUser) return; |
| const interval = setInterval(async () => { |
| try { |
| const result = await window.storage.get(STORAGE_KEY, true); |
| if (result?.value) { |
| const remote = JSON.parse(result.value); |
| if (remote.lastUpdated > lastUpdatedRef.current) { |
| lastUpdatedRef.current = remote.lastUpdated; |
| setDb(remote); |
| } |
| } |
| } catch {} |
| }, POLL_INTERVAL); |
| return () => clearInterval(interval); |
| }, [currentUser]); |
|
|
| const mutate = useCallback(async (updater) => { |
| setDb(prev => { |
| const next = typeof updater === "function" ? updater(prev) : updater; |
| saveDB(next).then(saved => { |
| lastUpdatedRef.current = saved.lastUpdated; |
| setDb(saved); |
| }); |
| return next; |
| }); |
| }, []); |
|
|
| const notify = useCallback((msg, type = "success") => { |
| const id = uid(); |
| setToast(t => [...t, { id, msg, type }]); |
| setTimeout(() => setToast(t => t.filter(x => x.id !== id)), 3500); |
| }, []); |
|
|
| if (loading) return ( |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100vh", background: "#F8F9FC", fontFamily: "system-ui" }}> |
| <div style={{ textAlign: "center" }}> |
| <div style={{ fontSize: 48, marginBottom: 16 }}>🍽</div> |
| <div style={{ fontSize: 18, fontWeight: 700, color: "#111" }}>RestofLo</div> |
| <div style={{ fontSize: 13, color: "#888", marginTop: 6 }}>Loading your restaurant...</div> |
| <div style={{ marginTop: 20, width: 200, height: 3, background: "#E5E7EB", borderRadius: 99, overflow: "hidden" }}> |
| <div style={{ height: "100%", width: "60%", background: "#875BF7", borderRadius: 99, animation: "pulse 1.5s ease-in-out infinite" }} /> |
| </div> |
| </div> |
| </div> |
| ); |
|
|
| if (!currentUser) return <LoginScreen db={db} onLogin={u => { setCurrentUser(u); lastUpdatedRef.current = db.lastUpdated; }} />; |
|
|
| const canAccess = (mod) => currentUser.role === "admin" || currentUser.permissions.includes(mod); |
| const gotoPage = (p) => { if (canAccess(p)) { setPage(p); } else notify("Access denied for this module", "error"); }; |
|
|
| const theme = db.settings?.theme === "dark" ? darkTheme : lightTheme; |
|
|
| return ( |
| <div style={{ display: "flex", flexDirection: "column", height: "100vh", background: theme.bg, color: theme.text, fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", overflow: "hidden" }}> |
| <style>{` |
| @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap'); |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| ::-webkit-scrollbar { width: 4px; height: 4px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: #D1D5DB; border-radius: 99px; } |
| input, select, textarea { font-family: inherit; } |
| @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .page-anim { animation: fadeIn 0.25s ease; } |
| .hover-row:hover { background: ${theme.hover} !important; } |
| .sidebar-item:hover { background: ${theme.sidebarHover} !important; } |
| .menu-card:hover { box-shadow: 0 4px 20px rgba(135,91,247,0.2); transform: translateY(-2px); border-color: #875BF7 !important; } |
| .btn-primary:hover { background: #7C3AED !important; } |
| .table-tile:hover { transform: scale(1.04); } |
| `}</style> |
| |
| {/* TOPBAR */} |
| <Topbar db={db} theme={theme} currentUser={currentUser} page={page} sidebarOpen={sidebarOpen} |
| setSidebarOpen={setSidebarOpen} onLogout={() => setCurrentUser(null)} onPageChange={gotoPage} /> |
| |
| <div style={{ display: "flex", flex: 1, overflow: "hidden" }}> |
| {/* SIDEBAR */} |
| {sidebarOpen && ( |
| <Sidebar theme={theme} page={page} currentUser={currentUser} canAccess={canAccess} onPageChange={gotoPage} |
| newKOT={db.orders.filter(o => o.status === "new").length} |
| db={db} /> |
| )} |
| |
| {/* MAIN */} |
| <main style={{ flex: 1, overflow: "auto", background: theme.mainBg }}> |
| <PageRenderer page={page} db={db} mutate={mutate} notify={notify} currentUser={currentUser} |
| theme={theme} modal={modal} setModal={setModal} posState={posState} setPosState={setPosState} |
| canAccess={canAccess} gotoPage={gotoPage} /> |
| </main> |
| </div> |
| |
| {/* TOAST */} |
| <div style={{ position: "fixed", top: 16, right: 16, zIndex: 9999, display: "flex", flexDirection: "column", gap: 8 }}> |
| {toast.map(t => ( |
| <div key={t.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", background: t.type === "error" ? "#FEF2F2" : t.type === "info" ? "#EFF6FF" : "#F0FDF4", border: `1px solid ${t.type === "error" ? "#FECACA" : t.type === "info" ? "#BFDBFE" : "#BBF7D0"}`, borderLeft: `4px solid ${t.type === "error" ? "#EF4444" : t.type === "info" ? "#3B82F6" : "#22C55E"}`, borderRadius: 10, fontSize: 13, fontWeight: 500, color: "#111", minWidth: 260, boxShadow: "0 4px 20px rgba(0,0,0,0.1)", animation: "slideIn 0.3s ease" }}> |
| <span>{t.type === "error" ? "❌" : t.type === "info" ? "ℹ️" : "✅"}</span> |
| {t.msg} |
| </div> |
| ))} |
| </div> |
| |
| {/* MODAL */} |
| {modal && <ModalLayer modal={modal} setModal={setModal} db={db} mutate={mutate} notify={notify} theme={theme} currentUser={currentUser} />} |
| </div> |
| ); |
| } |
|
|
| |
| const lightTheme = { |
| bg: "#FFFFFF", mainBg: "#F8F9FC", surface: "#FFFFFF", surface2: "#F3F4F6", |
| border: "#E5E7EB", text: "#111827", text2: "#6B7280", text3: "#9CA3AF", |
| sidebar: "#FFFFFF", sidebarBorder: "#E5E7EB", sidebarHover: "#F5F3FF", |
| sidebarActive: "#EDE9FE", sidebarActiveText: "#875BF7", |
| topbar: "#FFFFFF", topbarBorder: "#E5E7EB", |
| hover: "#F9FAFB", accent: "#875BF7", accentBg: "#EDE9FE", |
| card: "#FFFFFF", cardBorder: "#E5E7EB", |
| input: "#FFFFFF", inputBorder: "#D1D5DB", |
| tableHeader: "#F9FAFB", |
| badge: { green: { bg: "#D1FAE5", text: "#065F46" }, red: { bg: "#FEE2E2", text: "#991B1B" }, orange: { bg: "#FEF3C7", text: "#92400E" }, blue: { bg: "#DBEAFE", text: "#1E40AF" }, purple: { bg: "#EDE9FE", text: "#5B21B6" }, gray: { bg: "#F3F4F6", text: "#374151" } } |
| }; |
| const darkTheme = { |
| bg: "#0F1117", mainBg: "#141821", surface: "#1C2030", surface2: "#252A3A", |
| border: "#2D3548", text: "#F1F5F9", text2: "#94A3B8", text3: "#64748B", |
| sidebar: "#1C2030", sidebarBorder: "#2D3548", sidebarHover: "#252A3A", |
| sidebarActive: "#312E81", sidebarActiveText: "#A78BFA", |
| topbar: "#1C2030", topbarBorder: "#2D3548", |
| hover: "#252A3A", accent: "#875BF7", accentBg: "#312E81", |
| card: "#1C2030", cardBorder: "#2D3548", |
| input: "#252A3A", inputBorder: "#2D3548", |
| tableHeader: "#252A3A", |
| badge: { green: { bg: "#064E3B", text: "#6EE7B7" }, red: { bg: "#7F1D1D", text: "#FCA5A5" }, orange: { bg: "#78350F", text: "#FCD34D" }, blue: { bg: "#1E3A5F", text: "#93C5FD" }, purple: { bg: "#312E81", text: "#C4B5FD" }, gray: { bg: "#374151", text: "#D1D5DB" } } |
| }; |
|
|
| |
| function LoginScreen({ db, onLogin }) { |
| const [role, setRole] = useState("admin"); |
| const [uid, setUid] = useState("admin"); |
| const [pass, setPass] = useState("admin123"); |
| const [err, setErr] = useState(""); |
| const [showPass, setShowPass] = useState(false); |
|
|
| const login = () => { |
| const u = db.users.find(x => x.id === uid.trim() && x.pass === pass.trim() && x.active); |
| if (!u) { setErr("Invalid credentials. Please try again."); return; } |
| onLogin(u); |
| }; |
|
|
| const demoUsers = db.users.filter(u => u.id !== "admin"); |
|
|
| return ( |
| <div style={{ minHeight: "100vh", background: "linear-gradient(135deg, #F8F9FC 0%, #EDE9FE 100%)", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Plus Jakarta Sans', system-ui" }}> |
| <style>{`@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); * { box-sizing: border-box; margin: 0; padding: 0; }`}</style> |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 0, maxWidth: 900, width: "90vw", background: "#fff", borderRadius: 24, boxShadow: "0 32px 80px rgba(0,0,0,0.15)", overflow: "hidden" }}> |
| {/* Left panel */} |
| <div style={{ background: "linear-gradient(160deg, #875BF7 0%, #312E81 100%)", padding: "48px 40px", display: "flex", flexDirection: "column", justifyContent: "space-between", color: "#fff" }}> |
| <div> |
| <div style={{ fontSize: 32, marginBottom: 8 }}>🍽</div> |
| <div style={{ fontSize: 28, fontWeight: 800, letterSpacing: "-1px" }}>RestofLo</div> |
| <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>Restaurant Management System</div> |
| </div> |
| <div> |
| <div style={{ fontSize: 13, fontWeight: 600, opacity: 0.8, marginBottom: 14, textTransform: "uppercase", letterSpacing: "0.5px" }}>Quick Access — Demo Users</div> |
| {demoUsers.map(u => ( |
| <div key={u.id} onClick={() => { setUid(u.id); setPass(u.pass); setRole("staff"); }} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", background: "rgba(255,255,255,0.12)", borderRadius: 10, marginBottom: 8, cursor: "pointer", border: "1px solid rgba(255,255,255,0.1)", transition: ".15s" }}> |
| <div style={{ width: 32, height: 32, borderRadius: "50%", background: u.color, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, fontWeight: 700 }}>{u.name[0]}</div> |
| <div> |
| <div style={{ fontSize: 13, fontWeight: 600 }}>{u.name}</div> |
| <div style={{ fontSize: 11, opacity: 0.7 }}>{u.id} · {u.role}</div> |
| </div> |
| </div> |
| ))} |
| </div> |
| <div style={{ fontSize: 11, opacity: 0.5 }}>© 2026 RestofLo. All rights reserved.</div> |
| </div> |
| |
| {/* Right panel */} |
| <div style={{ padding: "48px 40px", display: "flex", flexDirection: "column", justifyContent: "center" }}> |
| <div style={{ marginBottom: 32 }}> |
| <div style={{ fontSize: 24, fontWeight: 800, color: "#111", letterSpacing: "-0.5px" }}>Sign in</div> |
| <div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>Access your restaurant dashboard</div> |
| </div> |
| |
| {err && <div style={{ background: "#FEF2F2", border: "1px solid #FECACA", borderRadius: 8, padding: "10px 14px", fontSize: 13, color: "#991B1B", marginBottom: 16 }}>⚠️ {err}</div>} |
| |
| <div style={{ marginBottom: 16 }}> |
| <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#374151", marginBottom: 6, textTransform: "uppercase", letterSpacing: "0.5px" }}>Login ID</label> |
| <input value={uid} onChange={e => { setUid(e.target.value); setErr(""); }} onKeyDown={e => e.key === "Enter" && login()} |
| placeholder="admin" style={{ width: "100%", padding: "12px 14px", border: "1.5px solid #D1D5DB", borderRadius: 10, fontSize: 14, outline: "none", transition: ".2s" }} |
| onFocus={e => e.target.style.borderColor = "#875BF7"} onBlur={e => e.target.style.borderColor = "#D1D5DB"} /> |
| </div> |
| <div style={{ marginBottom: 24, position: "relative" }}> |
| <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#374151", marginBottom: 6, textTransform: "uppercase", letterSpacing: "0.5px" }}>Password</label> |
| <input type={showPass ? "text" : "password"} value={pass} onChange={e => { setPass(e.target.value); setErr(""); }} onKeyDown={e => e.key === "Enter" && login()} |
| placeholder="••••••••" style={{ width: "100%", padding: "12px 40px 12px 14px", border: "1.5px solid #D1D5DB", borderRadius: 10, fontSize: 14, outline: "none" }} |
| onFocus={e => e.target.style.borderColor = "#875BF7"} onBlur={e => e.target.style.borderColor = "#D1D5DB"} /> |
| <span onClick={() => setShowPass(s => !s)} style={{ position: "absolute", right: 12, top: 36, cursor: "pointer", fontSize: 16, color: "#888" }}>{showPass ? "🙈" : "👁"}</span> |
| </div> |
| |
| <button onClick={login} className="btn-primary" style={{ width: "100%", padding: "13px", background: "#875BF7", color: "#fff", border: "none", borderRadius: 10, fontSize: 14, fontWeight: 700, cursor: "pointer", transition: ".2s" }}> |
| Sign In → |
| </button> |
| |
| <div style={{ marginTop: 20, padding: 14, background: "#F5F3FF", borderRadius: 10, fontSize: 12, color: "#5B21B6" }}> |
| <strong>Admin:</strong> admin / admin123 |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function Topbar({ db, theme: t, currentUser, page, sidebarOpen, setSidebarOpen, onLogout, onPageChange }) { |
| const [dropOpen, setDropOpen] = useState(false); |
| const pageTitle = MODULES.find(m => m.id === page)?.label || "Dashboard"; |
|
|
| return ( |
| <div style={{ height: 56, background: t.topbar, borderBottom: `1px solid ${t.topbarBorder}`, display: "flex", alignItems: "center", padding: "0 16px", gap: 12, flexShrink: 0, position: "relative", zIndex: 100 }}> |
| <button onClick={() => setSidebarOpen(s => !s)} style={{ width: 32, height: 32, border: "none", background: "transparent", cursor: "pointer", fontSize: 18, color: t.text2, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center" }}> |
| ☰ |
| </button> |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }}> |
| <span style={{ fontSize: 20 }}>{db.settings?.logo || "🍽"}</span> |
| <span style={{ fontWeight: 800, fontSize: 16, color: "#875BF7" }}>{db.settings?.restaurantName || "RestofLo"}</span> |
| </div> |
| <div style={{ width: 1, height: 20, background: t.border, margin: "0 4px" }} /> |
| <div style={{ fontSize: 13, color: t.text2, fontWeight: 500 }}>{pageTitle}</div> |
| |
| <div style={{ flex: 1 }} /> |
| |
| {/* Search */} |
| <div style={{ display: "flex", alignItems: "center", gap: 8, background: t.surface2, border: `1px solid ${t.border}`, borderRadius: 8, padding: "0 12px", height: 34, minWidth: 200 }}> |
| <span style={{ fontSize: 13, color: t.text3 }}>🔍</span> |
| <input placeholder="Search anything..." style={{ border: "none", background: "transparent", fontSize: 13, color: t.text, outline: "none", width: "100%" }} /> |
| </div> |
| |
| {/* KOT badge */} |
| {currentUser.role === "admin" && ( |
| <button onClick={() => onPageChange("kitchen")} style={{ position: "relative", padding: "6px 12px", background: "#FEF3C7", border: "1px solid #FCD34D", borderRadius: 8, cursor: "pointer", fontSize: 13, fontWeight: 600, color: "#92400E", display: "flex", alignItems: "center", gap: 6 }}> |
| 🍳 Kitchen |
| {db.orders.filter(o => o.status === "new").length > 0 && ( |
| <span style={{ background: "#EF4444", color: "#fff", fontSize: 10, fontWeight: 700, padding: "1px 6px", borderRadius: 99 }}>{db.orders.filter(o => o.status === "new").length}</span> |
| )} |
| </button> |
| )} |
| |
| {/* User */} |
| <div style={{ position: "relative" }}> |
| <div onClick={() => setDropOpen(s => !s)} style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", padding: "4px 8px", borderRadius: 8, border: `1px solid ${t.border}` }}> |
| <div style={{ width: 28, height: 28, borderRadius: "50%", background: currentUser.color, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 12, fontWeight: 700, color: "#fff" }}>{currentUser.name[0]}</div> |
| <div> |
| <div style={{ fontSize: 12, fontWeight: 600, color: t.text }}>{currentUser.name}</div> |
| <div style={{ fontSize: 10, color: t.text3 }}>{currentUser.role}</div> |
| </div> |
| <span style={{ fontSize: 10, color: t.text3 }}>▼</span> |
| </div> |
| {dropOpen && ( |
| <div style={{ position: "absolute", right: 0, top: "calc(100% + 6px)", background: t.surface, border: `1px solid ${t.border}`, borderRadius: 10, boxShadow: "0 8px 32px rgba(0,0,0,0.12)", minWidth: 180, zIndex: 999, padding: 6 }}> |
| <div style={{ padding: "8px 12px", fontSize: 12, color: t.text2 }}>Signed in as <strong>{currentUser.id}</strong></div> |
| <div style={{ height: 1, background: t.border, margin: "4px 0" }} /> |
| {["settings", "access"].map(p => currentUser.role === "admin" && ( |
| <div key={p} onClick={() => { onPageChange(p); setDropOpen(false); }} style={{ padding: "8px 12px", fontSize: 13, cursor: "pointer", borderRadius: 6, color: t.text }} className="hover-row">{p === "settings" ? "⚙️ Settings" : "🔐 Access Control"}</div> |
| ))} |
| <div onClick={() => { onLogout(); setDropOpen(false); }} style={{ padding: "8px 12px", fontSize: 13, cursor: "pointer", borderRadius: 6, color: "#EF4444" }} className="hover-row">🚪 Sign Out</div> |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function Sidebar({ theme: t, page, currentUser, canAccess, onPageChange, newKOT, db }) { |
| const groups = [...new Set(MODULES.map(m => m.group))]; |
| return ( |
| <div style={{ width: 220, background: t.sidebar, borderRight: `1px solid ${t.sidebarBorder}`, overflow: "auto", flexShrink: 0 }}> |
| {groups.map(g => { |
| const items = MODULES.filter(m => m.group === g && (currentUser.role === "admin" || canAccess(m.id))); |
| if (!items.length) return null; |
| return ( |
| <div key={g} style={{ padding: "12px 8px 4px" }}> |
| <div style={{ fontSize: 10, fontWeight: 700, color: t.text3, textTransform: "uppercase", letterSpacing: "0.8px", padding: "0 8px 6px" }}>{g}</div> |
| {items.map(m => { |
| const active = page === m.id; |
| return ( |
| <div key={m.id} onClick={() => onPageChange(m.id)} className="sidebar-item" |
| style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 10px", borderRadius: 7, cursor: "pointer", marginBottom: 1, background: active ? t.sidebarActive : "transparent", color: active ? t.sidebarActiveText : t.text2, fontSize: 13, fontWeight: active ? 600 : 500, transition: ".15s", position: "relative" }}> |
| <span style={{ fontSize: 14, width: 18, textAlign: "center" }}>{m.icon}</span> |
| <span style={{ flex: 1 }}>{m.label}</span> |
| {m.id === "kitchen" && newKOT > 0 && <span style={{ background: "#EF4444", color: "#fff", fontSize: 9, fontWeight: 700, padding: "1px 5px", borderRadius: 99 }}>{newKOT}</span>} |
| </div> |
| ); |
| })} |
| </div> |
| ); |
| })} |
| <div style={{ height: 24 }} /> |
| </div> |
| ); |
| } |
|
|
| |
| function PageRenderer({ page, db, mutate, notify, currentUser, theme, modal, setModal, posState, setPosState, canAccess, gotoPage }) { |
| const props = { db, mutate, notify, currentUser, theme: theme, t: theme, setModal, canAccess, gotoPage }; |
| const pages = { |
| dashboard: <DashboardPage {...props} />, |
| pos: <POSPage {...props} posState={posState} setPosState={setPosState} />, |
| tables: <TablesPage {...props} />, |
| kitchen: <KitchenPage {...props} />, |
| orders: <OrdersPage {...props} />, |
| billing: <BillingPage {...props} />, |
| menu: <MenuPage {...props} />, |
| categories: <CategoriesPage {...props} />, |
| inventory: <InventoryPage {...props} />, |
| reports: <ReportsPage {...props} />, |
| accounting: <AccountingPage {...props} />, |
| expenses: <ExpensesPage {...props} />, |
| payroll: <PayrollPage {...props} />, |
| crm: <CRMPage {...props} />, |
| employees: <EmployeesPage {...props} />, |
| users: <UsersPage {...props} />, |
| access: <AccessPage {...props} />, |
| settings: <SettingsPage {...props} />, |
| }; |
| return ( |
| <div className="page-anim" style={{ minHeight: "100%" }}> |
| {pages[page] || <GenericPage title={MODULES.find(m => m.id === page)?.label || page} icon={MODULES.find(m => m.id === page)?.icon} theme={theme} />} |
| </div> |
| ); |
| } |
|
|
| |
| function PageHeader({ title, subtitle, actions, t }) { |
| return ( |
| <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 24 }}> |
| <div> |
| <h2 style={{ fontSize: 20, fontWeight: 800, color: t.text, letterSpacing: "-0.3px" }}>{title}</h2> |
| {subtitle && <div style={{ fontSize: 13, color: t.text2, marginTop: 2 }}>{subtitle}</div>} |
| </div> |
| {actions && <div style={{ display: "flex", gap: 8 }}>{actions}</div>} |
| </div> |
| ); |
| } |
|
|
| function Btn({ children, onClick, variant = "primary", size = "md", disabled = false, style: sx = {} }) { |
| const base = { border: "none", cursor: disabled ? "not-allowed" : "pointer", fontFamily: "inherit", fontWeight: 600, borderRadius: 8, display: "inline-flex", alignItems: "center", gap: 6, transition: ".15s", opacity: disabled ? 0.5 : 1 }; |
| const sizes = { sm: { fontSize: 12, padding: "6px 12px" }, md: { fontSize: 13, padding: "8px 16px" }, lg: { fontSize: 14, padding: "11px 20px" } }; |
| const variants = { primary: { background: "#875BF7", color: "#fff" }, secondary: { background: "#F3F4F6", color: "#374151", border: "1px solid #E5E7EB" }, danger: { background: "#FEE2E2", color: "#991B1B", border: "1px solid #FECACA" }, success: { background: "#D1FAE5", color: "#065F46", border: "1px solid #A7F3D0" }, ghost: { background: "transparent", color: "#875BF7", border: "1px solid #EDE9FE" } }; |
| return <button onClick={onClick} disabled={disabled} className={variant === "primary" ? "btn-primary" : ""} style={{ ...base, ...sizes[size], ...variants[variant], ...sx }}>{children}</button>; |
| } |
|
|
| function Input({ label, value, onChange, placeholder, type = "text", style: sx = {} }) { |
| return ( |
| <div style={{ marginBottom: 14 }}> |
| {label && <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#374151", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.4px" }}>{label}</label>} |
| <input type={type} value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} |
| style={{ width: "100%", padding: "10px 12px", border: "1.5px solid #D1D5DB", borderRadius: 8, fontSize: 13, outline: "none", fontFamily: "inherit", ...sx }} |
| onFocus={e => e.target.style.borderColor = "#875BF7"} onBlur={e => e.target.style.borderColor = "#D1D5DB"} /> |
| </div> |
| ); |
| } |
|
|
| function Select({ label, value, onChange, options, style: sx = {} }) { |
| return ( |
| <div style={{ marginBottom: 14 }}> |
| {label && <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#374151", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.4px" }}>{label}</label>} |
| <select value={value} onChange={e => onChange(e.target.value)} style={{ width: "100%", padding: "10px 12px", border: "1.5px solid #D1D5DB", borderRadius: 8, fontSize: 13, outline: "none", fontFamily: "inherit", background: "#fff", cursor: "pointer", ...sx }}> |
| {options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)} |
| </select> |
| </div> |
| ); |
| } |
|
|
| function Badge({ label, color = "gray", t }) { |
| const c = t?.badge?.[color] || lightTheme.badge[color]; |
| return <span style={{ display: "inline-flex", alignItems: "center", padding: "2px 9px", borderRadius: 99, fontSize: 11, fontWeight: 600, background: c.bg, color: c.text }}>{label}</span>; |
| } |
|
|
| function Card({ children, style: sx = {}, t }) { |
| return <div style={{ background: t?.card || "#fff", border: `1px solid ${t?.cardBorder || "#E5E7EB"}`, borderRadius: 12, padding: 20, ...sx }}>{children}</div>; |
| } |
|
|
| function StatCard({ icon, value, label, change, changeUp, color = "#875BF7", t }) { |
| return ( |
| <Card t={t} style={{ position: "relative", overflow: "hidden" }}> |
| <div style={{ position: "absolute", top: -16, right: -16, width: 80, height: 80, borderRadius: "50%", background: color, opacity: 0.07 }} /> |
| <div style={{ fontSize: 22, marginBottom: 10 }}>{icon}</div> |
| <div style={{ fontSize: 26, fontWeight: 800, color: t?.text || "#111", fontFamily: "inherit", letterSpacing: "-0.5px" }}>{value}</div> |
| <div style={{ fontSize: 12, color: t?.text2 || "#888", marginTop: 3 }}>{label}</div> |
| {change && <div style={{ marginTop: 8, display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 8px", borderRadius: 99, fontSize: 11, fontWeight: 600, background: changeUp ? "#D1FAE5" : "#FEE2E2", color: changeUp ? "#065F46" : "#991B1B" }}>{changeUp ? "↑" : "↓"} {change}</div>} |
| </Card> |
| ); |
| } |
|
|
| function Table({ cols, rows, t }) { |
| return ( |
| <div style={{ overflowX: "auto" }}> |
| <table style={{ width: "100%", borderCollapse: "collapse" }}> |
| <thead> |
| <tr style={{ background: t?.tableHeader || "#F9FAFB" }}> |
| {cols.map((c, i) => <th key={i} style={{ padding: "10px 14px", textAlign: "left", fontSize: 11, fontWeight: 700, color: t?.text3 || "#9CA3AF", textTransform: "uppercase", letterSpacing: "0.5px", whiteSpace: "nowrap", borderBottom: `1px solid ${t?.border || "#E5E7EB"}` }}>{c}</th>)} |
| </tr> |
| </thead> |
| <tbody> |
| {rows.map((row, i) => ( |
| <tr key={i} className="hover-row" style={{ borderBottom: `1px solid ${t?.border || "#E5E7EB"}` }}> |
| {row.map((cell, j) => <td key={j} style={{ padding: "11px 14px", fontSize: 13, color: t?.text || "#111" }}>{cell}</td>)} |
| </tr> |
| ))} |
| {rows.length === 0 && <tr><td colSpan={cols.length} style={{ padding: "40px 14px", textAlign: "center", color: t?.text3 || "#9CA3AF", fontSize: 13 }}>No data found</td></tr>} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
|
|
| function Toggle({ checked, onChange }) { |
| return ( |
| <div onClick={() => onChange(!checked)} style={{ width: 40, height: 22, borderRadius: 99, background: checked ? "#875BF7" : "#D1D5DB", cursor: "pointer", position: "relative", transition: ".2s", flexShrink: 0 }}> |
| <div style={{ position: "absolute", width: 16, height: 16, borderRadius: "50%", background: "#fff", top: 3, left: checked ? 21 : 3, transition: ".2s", boxShadow: "0 1px 3px rgba(0,0,0,0.2)" }} /> |
| </div> |
| ); |
| } |
|
|
| |
| function DashboardPage({ db, mutate, notify, t, setModal }) { |
| const todayOrders = db.orders; |
| const totalRevenue = todayOrders.filter(o => o.status === "billed").reduce((a, o) => a + o.total, 0); |
| const activeOrders = todayOrders.filter(o => !["billed","cancelled"].includes(o.status)).length; |
| const occupiedTables = db.tables.filter(t => t.status === "occupied").length; |
| const newKOT = todayOrders.filter(o => o.status === "new").length; |
|
|
| const hourlyData = [0,0,0,0,1200,2400,4800,3600,6000,8000,9200,10400,7200,6800,5400,4200,3800,5600,6200,4400,3200,1800,800,0]; |
| const hours = ["6am","7am","8am","9am","10am","11am","12pm","1pm","2pm","3pm","4pm","5pm","6pm","7pm","8pm","9pm","10pm","11pm"]; |
| const peakHours = hourlyData.slice(4, 22); |
| const maxH = Math.max(...peakHours); |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Dashboard" subtitle={`${fmtDate(Date.now())} · Live Overview`} t={t} |
| actions={<Btn onClick={() => notify("Refreshed!", "info")} variant="secondary" size="sm">↻ Refresh</Btn>} /> |
| |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}> |
| <StatCard icon="💰" value={fmt(totalRevenue)} label="Revenue Today" change="+12.4%" changeUp t={t} color="#875BF7" /> |
| <StatCard icon="📋" value={activeOrders} label="Active Orders" change={`${newKOT} new`} changeUp={newKOT > 0} t={t} color="#F59E0B" /> |
| <StatCard icon="🪑" value={`${occupiedTables}/${db.tables.length}`} label="Tables Occupied" t={t} color="#3B82F6" /> |
| <StatCard icon="👥" value={db.users.filter(u => u.active && u.role !== "admin").length} label="Staff Active" change="All on duty" changeUp t={t} color="#039855" /> |
| </div> |
| |
| <div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16, marginBottom: 16 }}> |
| <Card t={t}> |
| <div style={{ fontWeight: 700, fontSize: 14, marginBottom: 16, color: t.text }}>Hourly Sales — Today</div> |
| <div style={{ display: "flex", alignItems: "flex-end", gap: 4, height: 100 }}> |
| {peakHours.map((v, i) => ( |
| <div key={i} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 3 }}> |
| <div style={{ width: "100%", borderRadius: "3px 3px 0 0", background: i === 8 ? "#875BF7" : "#EDE9FE", height: maxH ? (v / maxH) * 88 : 4, minHeight: 4, transition: ".5s" }} /> |
| <div style={{ fontSize: 9, color: t.text3, whiteSpace: "nowrap" }}>{hours[i]}</div> |
| </div> |
| ))} |
| </div> |
| </Card> |
| <Card t={t}> |
| <div style={{ fontWeight: 700, fontSize: 14, marginBottom: 16, color: t.text }}>Table Status</div> |
| {["free","occupied","reserved"].map(s => { |
| const count = db.tables.filter(x => x.status === s).length; |
| const colors = { free: "#039855", occupied: "#F59E0B", reserved: "#875BF7" }; |
| return ( |
| <div key={s} style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}> |
| <div style={{ width: 8, height: 8, borderRadius: "50%", background: colors[s] }} /> |
| <div style={{ flex: 1, fontSize: 13, color: t.text, textTransform: "capitalize" }}>{s}</div> |
| <div style={{ fontWeight: 700, fontSize: 14, color: t.text }}>{count}</div> |
| <div style={{ width: 80, height: 6, background: t.surface2, borderRadius: 99 }}> |
| <div style={{ height: "100%", width: `${(count / db.tables.length) * 100}%`, background: colors[s], borderRadius: 99, transition: ".5s" }} /> |
| </div> |
| </div> |
| ); |
| })} |
| </Card> |
| </div> |
| |
| <Card t={t}> |
| <div style={{ fontWeight: 700, fontSize: 14, marginBottom: 16, color: t.text }}>Recent Orders</div> |
| <Table t={t} cols={["Order #", "Table", "Items", "Amount", "Waiter", "Status", "Time"]} |
| rows={db.orders.slice(0, 8).map(o => [ |
| <strong>{o.id}</strong>, |
| `Table ${o.table}`, |
| `${o.items.length} items`, |
| <strong style={{ color: "#875BF7" }}>{fmt(o.total)}</strong>, |
| o.waiterName, |
| <span style={{ display: "inline-flex", alignItems: "center", padding: "2px 9px", borderRadius: 99, fontSize: 11, fontWeight: 600, background: statusBg[o.status], color: statusColor[o.status] }}>{o.status}</span>, |
| fmtTime(o.time), |
| ])} /> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| function POSPage({ db, mutate, notify, t, posState, setPosState, currentUser }) { |
| const [activeCat, setActiveCat] = useState("all"); |
| const [search, setSearch] = useState(""); |
| const items = db.menuItems.filter(m => m.status === "active" && (activeCat === "all" || m.cat === activeCat) && (!search || m.name.toLowerCase().includes(search.toLowerCase()))); |
|
|
| const addItem = (item) => { |
| setPosState(s => { |
| const ex = s.items.find(x => x.id === item.id); |
| if (ex) return { ...s, items: s.items.map(x => x.id === item.id ? { ...x, qty: x.qty + 1 } : x) }; |
| return { ...s, items: [...s.items, { id: item.id, name: item.name, price: item.price, tax: item.tax, qty: 1 }] }; |
| }); |
| }; |
| const removeItem = (id) => setPosState(s => { |
| const ex = s.items.find(x => x.id === id); |
| if (ex?.qty > 1) return { ...s, items: s.items.map(x => x.id === id ? { ...x, qty: x.qty - 1 } : x) }; |
| return { ...s, items: s.items.filter(x => x.id !== id) }; |
| }); |
|
|
| const subtotal = posState.items.reduce((a, i) => a + i.price * i.qty, 0); |
| const taxAmt = Math.round(subtotal * (db.settings.gstRate / 100)); |
| const svcAmt = Math.round(subtotal * (db.settings.serviceCharge / 100)); |
| const total = subtotal + taxAmt + svcAmt; |
|
|
| const placeOrder = async () => { |
| if (!posState.table) { notify("Select a table first", "error"); return; } |
| if (!posState.items.length) { notify("Add items to order", "error"); return; } |
| const orderId = `ORD-${String(db.orderCounter).padStart(4, "0")}`; |
| const order = { |
| id: orderId, table: parseInt(posState.table), |
| items: posState.items.map(i => ({ ...i })), |
| subtotal, taxAmount: taxAmt, total, |
| waiter: currentUser.id, waiterName: currentUser.name, |
| status: "new", time: Date.now(), note: posState.note, paymentMethod: null, |
| }; |
| await mutate(db => ({ |
| ...db, |
| orders: [order, ...db.orders], |
| orderCounter: db.orderCounter + 1, |
| tables: db.tables.map(t => t.num === parseInt(posState.table) ? { ...t, status: "occupied", currentOrder: orderId } : t), |
| })); |
| setPosState({ items: [], table: null, note: "" }); |
| notify(`Order ${orderId} placed for Table ${posState.table}! ✅`, "success"); |
| }; |
|
|
| return ( |
| <div style={{ display: "flex", height: "100%", overflow: "hidden" }}> |
| {/* Left */} |
| <div style={{ flex: 1, overflow: "auto", padding: 20, borderRight: `1px solid ${t.border}` }}> |
| {/* Controls */} |
| <div style={{ display: "flex", gap: 10, marginBottom: 16, alignItems: "center" }}> |
| <div style={{ display: "flex", alignItems: "center", gap: 6, background: t.surface2, border: `1px solid ${t.border}`, borderRadius: 8, padding: "0 12px", flex: 1 }}> |
| <span style={{ color: t.text3, fontSize: 13 }}>🔍</span> |
| <input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search menu..." style={{ border: "none", background: "transparent", padding: "9px 0", fontSize: 13, color: t.text, outline: "none", width: "100%" }} /> |
| </div> |
| <select value={posState.table || ""} onChange={e => setPosState(s => ({ ...s, table: e.target.value }))} |
| style={{ padding: "9px 12px", border: `1.5px solid ${posState.table ? "#875BF7" : t.border}`, borderRadius: 8, fontSize: 13, fontFamily: "inherit", background: t.card, color: t.text, cursor: "pointer", outline: "none" }}> |
| <option value="">Select Table</option> |
| {db.tables.map(tbl => <option key={tbl.num} value={tbl.num}>Table {tbl.num} ({tbl.section} · {tbl.status})</option>)} |
| </select> |
| </div> |
| |
| {/* Categories */} |
| <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 16 }}> |
| {[{ id: "all", name: "All Items", icon: "🍽" }, ...db.categories].map(c => ( |
| <button key={c.id} onClick={() => setActiveCat(c.id)} style={{ padding: "6px 14px", borderRadius: 99, border: `1.5px solid ${activeCat === c.id ? "#875BF7" : t.border}`, background: activeCat === c.id ? "#875BF7" : t.card, color: activeCat === c.id ? "#fff" : t.text2, fontSize: 13, fontWeight: 600, cursor: "pointer", transition: ".15s" }}> |
| {c.icon} {c.name} |
| </button> |
| ))} |
| </div> |
| |
| {/* Menu grid */} |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(155px, 1fr))", gap: 10 }}> |
| {items.map(item => ( |
| <div key={item.id} onClick={() => addItem(item)} className="menu-card" style={{ background: t.card, border: `1.5px solid ${t.cardBorder}`, borderRadius: 12, padding: 14, cursor: "pointer", transition: ".2s", position: "relative" }}> |
| <div style={{ position: "absolute", top: 8, right: 8, width: 12, height: 12, border: `2px solid ${item.type === "veg" ? "#039855" : "#EF4444"}`, borderRadius: 2, display: "flex", alignItems: "center", justifyContent: "center" }}> |
| <div style={{ width: 6, height: 6, borderRadius: "50%", background: item.type === "veg" ? "#039855" : "#EF4444" }} /> |
| </div> |
| <div style={{ fontSize: 28, marginBottom: 8 }}>{item.emoji}</div> |
| <div style={{ fontSize: 13, fontWeight: 700, color: t.text, marginBottom: 3, lineHeight: 1.3 }}>{item.name}</div> |
| <div style={{ fontSize: 11, color: t.text3, marginBottom: 8 }}>{item.desc?.slice(0, 40)}...</div> |
| <div style={{ fontSize: 16, fontWeight: 800, color: "#875BF7" }}>{fmt(item.price)}</div> |
| </div> |
| ))} |
| </div> |
| </div> |
| |
| {/* Order Panel */} |
| <div style={{ width: 340, display: "flex", flexDirection: "column", background: t.surface }}> |
| <div style={{ padding: "16px 20px", borderBottom: `1px solid ${t.border}` }}> |
| <div style={{ fontSize: 15, fontWeight: 800, color: t.text }}>Current Order</div> |
| {posState.table && <div style={{ fontSize: 12, color: "#875BF7", fontWeight: 600, marginTop: 3 }}>Table {posState.table}</div>} |
| </div> |
| |
| <div style={{ flex: 1, overflow: "auto", padding: "10px 16px" }}> |
| {posState.items.length === 0 ? ( |
| <div style={{ textAlign: "center", padding: "60px 20px", color: t.text3 }}> |
| <div style={{ fontSize: 36, marginBottom: 10 }}>🛒</div> |
| <div style={{ fontSize: 13 }}>Tap a menu item to add</div> |
| </div> |
| ) : posState.items.map(item => ( |
| <div key={item.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 0", borderBottom: `1px solid ${t.border}` }}> |
| <div style={{ flex: 1 }}> |
| <div style={{ fontSize: 13, fontWeight: 600, color: t.text }}>{item.name}</div> |
| <div style={{ fontSize: 12, color: t.text2 }}>{fmt(item.price)} each</div> |
| </div> |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> |
| <button onClick={() => removeItem(item.id)} style={{ width: 24, height: 24, borderRadius: 6, border: `1px solid ${t.border}`, background: t.surface2, cursor: "pointer", fontSize: 14, fontWeight: 700, color: t.text, display: "flex", alignItems: "center", justifyContent: "center" }}>−</button> |
| <span style={{ fontSize: 13, fontWeight: 700, minWidth: 20, textAlign: "center", color: t.text }}>{item.qty}</span> |
| <button onClick={() => addItem({ id: item.id, name: item.name, price: item.price, tax: item.tax })} style={{ width: 24, height: 24, borderRadius: 6, border: "1px solid #875BF7", background: "#EDE9FE", cursor: "pointer", fontSize: 14, fontWeight: 700, color: "#875BF7", display: "flex", alignItems: "center", justifyContent: "center" }}>+</button> |
| </div> |
| <div style={{ fontSize: 13, fontWeight: 700, color: "#875BF7", minWidth: 55, textAlign: "right" }}>{fmt(item.price * item.qty)}</div> |
| </div> |
| ))} |
| |
| {posState.items.length > 0 && ( |
| <textarea value={posState.note} onChange={e => setPosState(s => ({ ...s, note: e.target.value }))} placeholder="Special instructions..." style={{ width: "100%", marginTop: 12, padding: "8px 10px", border: `1px solid ${t.border}`, borderRadius: 8, fontSize: 12, fontFamily: "inherit", color: t.text, background: t.surface2, resize: "none", height: 56, outline: "none" }} /> |
| )} |
| </div> |
| |
| {posState.items.length > 0 && ( |
| <div style={{ borderTop: `1px solid ${t.border}`, padding: "14px 20px" }}> |
| {[["Subtotal", fmt(subtotal)], [`GST (${db.settings.gstRate}%)`, fmt(taxAmt)], [`Service (${db.settings.serviceCharge}%)`, fmt(svcAmt)]].map(([k, v]) => ( |
| <div key={k} style={{ display: "flex", justifyContent: "space-between", fontSize: 13, color: t.text2, marginBottom: 6 }}> |
| <span>{k}</span><span>{v}</span> |
| </div> |
| ))} |
| <div style={{ display: "flex", justifyContent: "space-between", fontSize: 17, fontWeight: 800, color: t.text, marginTop: 8, paddingTop: 10, borderTop: `1px solid ${t.border}` }}> |
| <span>TOTAL</span><span style={{ color: "#875BF7" }}>{fmt(total)}</span> |
| </div> |
| </div> |
| )} |
| |
| <div style={{ padding: "12px 16px", display: "flex", gap: 8 }}> |
| <Btn onClick={() => setPosState({ items: [], table: null, note: "" })} variant="secondary" style={{ flex: 1 }} disabled={!posState.items.length}>Clear</Btn> |
| <Btn onClick={() => notify("KOT sent to kitchen 🍳", "success")} variant="ghost" style={{ flex: 1 }} disabled={!posState.items.length}>KOT</Btn> |
| <Btn onClick={placeOrder} variant="primary" style={{ flex: 1 }} disabled={!posState.items.length}>Place →</Btn> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function TablesPage({ db, mutate, notify, t }) { |
| const sections = [...new Set(db.tables.map(t => t.section))]; |
| const toggle = async (num) => { |
| const tbl = db.tables.find(x => x.num === num); |
| const next = tbl.status === "free" ? "occupied" : tbl.status === "occupied" ? "reserved" : "free"; |
| await mutate(db => ({ ...db, tables: db.tables.map(x => x.num === num ? { ...x, status: next } : x) })); |
| notify(`Table ${num} → ${next}`, "info"); |
| }; |
| const sColors = { free: { bg: "#D1FAE5", border: "#6EE7B7", text: "#065F46" }, occupied: { bg: "#FEF3C7", border: "#FCD34D", text: "#92400E" }, reserved: { bg: "#EDE9FE", border: "#C4B5FD", text: "#5B21B6" } }; |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Table Map" subtitle="Click a table to cycle status: Free → Occupied → Reserved" t={t} |
| actions={<><Btn variant="secondary" size="sm">+ Add Table</Btn><Btn variant="primary" size="sm">Floor Plan View</Btn></>} /> |
|
|
| <div style={{ display: "flex", gap: 12, marginBottom: 20 }}> |
| {Object.entries(sColors).map(([s, c]) => ( |
| <div key={s} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, fontWeight: 600, color: c.text }}> |
| <div style={{ width: 10, height: 10, borderRadius: 2, background: c.bg, border: `1.5px solid ${c.border}` }} /> {s} ({db.tables.filter(x => x.status === s).length}) |
| </div> |
| ))} |
| </div> |
|
|
| {sections.map(sec => ( |
| <div key={sec} style={{ marginBottom: 28 }}> |
| <div style={{ fontSize: 13, fontWeight: 700, color: t.text2, marginBottom: 12, textTransform: "uppercase", letterSpacing: "0.5px" }}>{sec}</div> |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}> |
| {db.tables.filter(tbl => tbl.section === sec).map(tbl => { |
| const c = sColors[tbl.status]; |
| return ( |
| <div key={tbl.num} onClick={() => toggle(tbl.num)} className="table-tile" |
| style={{ width: 96, height: 96, borderRadius: 12, border: `2px solid ${c.border}`, background: c.bg, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", cursor: "pointer", transition: ".2s", position: "relative" }}> |
| <div style={{ position: "absolute", top: 5, right: 7, fontSize: 9, color: c.text }}>👥{tbl.pax}</div> |
| <div style={{ fontSize: 24, fontWeight: 800, color: c.text }}>{tbl.num}</div> |
| <div style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.5px", color: c.text, marginTop: 4 }}>{tbl.status}</div> |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
| function KitchenPage({ db, mutate, notify, t }) { |
| const active = db.orders.filter(o => !["billed", "cancelled"].includes(o.status)); |
| const update = async (id, status) => { |
| await mutate(db => ({ ...db, orders: db.orders.map(o => o.id === id ? { ...o, status } : o) })); |
| notify(`Order ${id} → ${status}`, "info"); |
| }; |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Kitchen Display System — KOT" subtitle={`${active.length} active orders · Auto-refreshes every 3s`} t={t} |
| actions={<Btn variant="secondary" size="sm">🖨 Print All KOT</Btn>} /> |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 14 }}> |
| {active.map(o => { |
| const elapsed = Math.round((Date.now() - o.time) / 60000); |
| return ( |
| <div key={o.id} style={{ background: t.card, border: `1.5px solid ${statusColor[o.status]}40`, borderTop: `4px solid ${statusColor[o.status]}`, borderRadius: 12, overflow: "hidden" }}> |
| <div style={{ padding: "12px 14px", background: statusBg[o.status], display: "flex", justifyContent: "space-between", alignItems: "center" }}> |
| <div> |
| <div style={{ fontSize: 14, fontWeight: 800, color: statusColor[o.status] }}>{o.id}</div> |
| <div style={{ fontSize: 11, color: "#888" }}>Table {o.table} · {o.waiterName}</div> |
| </div> |
| <div style={{ textAlign: "right" }}> |
| <span style={{ display: "inline-flex", padding: "2px 8px", borderRadius: 99, fontSize: 11, fontWeight: 700, background: statusColor[o.status], color: "#fff" }}>{o.status.toUpperCase()}</span> |
| <div style={{ fontSize: 11, color: elapsed > 15 ? "#EF4444" : "#888", marginTop: 3, fontWeight: elapsed > 15 ? 700 : 400 }}>⏱ {elapsed}m ago</div> |
| </div> |
| </div> |
| <div style={{ padding: "12px 14px" }}> |
| {o.items.map((item, i) => ( |
| <div key={i} style={{ display: "flex", gap: 10, padding: "6px 0", borderBottom: `1px solid ${t.border}`, alignItems: "center" }}> |
| <span style={{ fontWeight: 800, color: "#875BF7", fontSize: 15, minWidth: 24 }}>{item.qty}×</span> |
| <span style={{ fontSize: 13, color: t.text, flex: 1 }}>{item.name}</span> |
| </div> |
| ))} |
| {o.note && <div style={{ marginTop: 8, padding: "6px 10px", background: "#FFFBEB", borderRadius: 6, fontSize: 12, color: "#92400E" }}>📝 {o.note}</div>} |
| </div> |
| <div style={{ padding: "10px 14px", borderTop: `1px solid ${t.border}`, display: "flex", gap: 6 }}> |
| {o.status === "new" && <Btn onClick={() => update(o.id, "preparing")} variant="primary" size="sm" style={{ flex: 1 }}>Start Cooking</Btn>} |
| {o.status === "preparing" && <Btn onClick={() => update(o.id, "served")} variant="success" size="sm" style={{ flex: 1 }}>Mark Ready ✓</Btn>} |
| {o.status === "served" && <div style={{ flex: 1, textAlign: "center", fontSize: 12, fontWeight: 700, color: "#039855" }}>✅ Ready to Serve</div>} |
| <Btn onClick={() => update(o.id, "cancelled")} variant="danger" size="sm">✕</Btn> |
| </div> |
| </div> |
| ); |
| })} |
| {active.length === 0 && <div style={{ gridColumn: "1/-1", textAlign: "center", padding: "80px 20px", color: t.text3 }}><div style={{ fontSize: 48, marginBottom: 12 }}>🎉</div><div style={{ fontSize: 14 }}>No active kitchen orders</div></div>} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function OrdersPage({ db, mutate, notify, t, setModal }) { |
| const [filter, setFilter] = useState("all"); |
| const filtered = filter === "all" ? db.orders : db.orders.filter(o => o.status === filter); |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="All Orders" subtitle={`${db.orders.length} total orders`} t={t} /> |
| <div style={{ display: "flex", gap: 6, marginBottom: 20 }}> |
| {["all","new","preparing","served","billed","cancelled"].map(s => ( |
| <button key={s} onClick={() => setFilter(s)} style={{ padding: "6px 14px", borderRadius: 99, border: `1.5px solid ${filter === s ? "#875BF7" : t.border}`, background: filter === s ? "#875BF7" : t.card, color: filter === s ? "#fff" : t.text2, fontSize: 12, fontWeight: 600, cursor: "pointer" }}> |
| {s === "all" ? "All" : s} {s !== "all" ? `(${db.orders.filter(o => o.status === s).length})` : ""} |
| </button> |
| ))} |
| </div> |
| <Card t={t} style={{ padding: 0 }}> |
| <Table t={t} cols={["Order #","Table","Items","Amount","Waiter","Status","Time","Actions"]} |
| rows={filtered.map(o => [ |
| <strong style={{ color: "#875BF7" }}>{o.id}</strong>, |
| `Table ${o.table}`, |
| o.items.map(i => `${i.qty}× ${i.name}`).join(", ").slice(0, 40) + (o.items.map(i => `${i.qty}×${i.name}`).join(", ").length > 40 ? "…" : ""), |
| <strong style={{ color: "#875BF7" }}>{fmt(o.total)}</strong>, |
| o.waiterName, |
| <span style={{ padding: "2px 9px", borderRadius: 99, fontSize: 11, fontWeight: 600, background: statusBg[o.status], color: statusColor[o.status] }}>{o.status}</span>, |
| fmtTime(o.time), |
| <div style={{ display: "flex", gap: 4 }}> |
| {o.status === "served" && <Btn onClick={() => setModal({ type: "bill", order: o })} variant="primary" size="sm">Bill</Btn>} |
| {o.status !== "billed" && o.status !== "cancelled" && <Btn onClick={() => mutate(db => ({ ...db, orders: db.orders.map(x => x.id === o.id ? { ...x, status: "cancelled" } : x) }))} variant="danger" size="sm">✕</Btn>} |
| </div>, |
| ])} /> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| function BillingPage({ db, mutate, notify, t, setModal }) { |
| const pending = db.orders.filter(o => o.status === "served"); |
| const billed = db.orders.filter(o => o.status === "billed"); |
|
|
| const settle = async (orderId, method) => { |
| await mutate(db => ({ |
| ...db, |
| orders: db.orders.map(o => o.id === orderId ? { ...o, status: "billed", paymentMethod: method } : o), |
| tables: db.tables.map(t => { |
| const o = db.orders.find(x => x.id === orderId); |
| return o && t.num === o.table ? { ...t, status: "free", currentOrder: null } : t; |
| }), |
| })); |
| notify(`Payment settled via ${method} ✅`, "success"); |
| }; |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Billing" subtitle="Process payments and generate invoices" t={t} /> |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14, marginBottom: 24 }}> |
| <StatCard icon="⏳" value={pending.length} label="Pending Bills" t={t} color="#F59E0B" /> |
| <StatCard icon="✅" value={billed.length} label="Settled Today" t={t} color="#039855" /> |
| <StatCard icon="💰" value={fmt(billed.reduce((a, o) => a + o.total, 0))} label="Revenue Collected" t={t} color="#875BF7" /> |
| </div> |
| |
| <div style={{ marginBottom: 20, fontSize: 15, fontWeight: 700, color: t.text }}>Pending Payment</div> |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px,1fr))", gap: 14, marginBottom: 24 }}> |
| {pending.map(o => ( |
| <Card key={o.id} t={t} style={{ border: "1.5px solid #FCD34D" }}> |
| <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}> |
| <div> |
| <div style={{ fontWeight: 800, color: t.text }}>{o.id}</div> |
| <div style={{ fontSize: 12, color: t.text2 }}>Table {o.table} · {o.waiterName}</div> |
| </div> |
| <div style={{ fontSize: 20, fontWeight: 800, color: "#875BF7" }}>{fmt(o.total)}</div> |
| </div> |
| <div style={{ display: "flex", gap: 6, marginTop: 12 }}> |
| {["Cash","Card","UPI"].map(m => ( |
| <Btn key={m} onClick={() => settle(o.id, m)} variant={m === "UPI" ? "primary" : "secondary"} size="sm" style={{ flex: 1 }}>{m}</Btn> |
| ))} |
| <Btn onClick={() => setModal({ type: "bill", order: o })} variant="ghost" size="sm">Invoice</Btn> |
| </div> |
| </Card> |
| ))} |
| {pending.length === 0 && <div style={{ color: t.text3, fontSize: 13, padding: 20 }}>No pending bills</div>} |
| </div> |
| |
| <div style={{ fontSize: 15, fontWeight: 700, color: t.text, marginBottom: 12 }}>Settled Bills</div> |
| <Card t={t} style={{ padding: 0 }}> |
| <Table t={t} cols={["Order #","Table","Amount","Method","Time","Invoice"]} |
| rows={billed.map(o => [ |
| <strong>{o.id}</strong>, `Table ${o.table}`, |
| <strong style={{ color: "#875BF7" }}>{fmt(o.total)}</strong>, |
| <Badge label={o.paymentMethod || "—"} color="green" t={t} />, |
| fmtTime(o.time), |
| <Btn onClick={() => setModal({ type: "bill", order: o })} variant="ghost" size="sm">View</Btn>, |
| ])} /> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| function MenuPage({ db, mutate, notify, t, setModal }) { |
| const [search, setSearch] = useState(""); |
| const [cat, setCat] = useState("all"); |
| const items = db.menuItems.filter(m => (cat === "all" || m.cat === cat) && (!search || m.name.toLowerCase().includes(search.toLowerCase()))); |
|
|
| const toggle = async (id) => { |
| await mutate(db => ({ ...db, menuItems: db.menuItems.map(m => m.id === id ? { ...m, status: m.status === "active" ? "inactive" : "active" } : m) })); |
| }; |
| const del = async (id) => { |
| await mutate(db => ({ ...db, menuItems: db.menuItems.filter(m => m.id !== id) })); |
| notify("Item deleted", "info"); |
| }; |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Menu Items" subtitle={`${db.menuItems.filter(m => m.status === "active").length} active items`} t={t} |
| actions={<><Btn variant="secondary" size="sm">Import CSV</Btn><Btn onClick={() => setModal({ type: "addMenu" })} variant="primary" size="sm">+ Add Item</Btn></>} /> |
|
|
| <div style={{ display: "flex", gap: 10, marginBottom: 16, alignItems: "center" }}> |
| <div style={{ display: "flex", alignItems: "center", gap: 6, background: t.surface2, border: `1px solid ${t.border}`, borderRadius: 8, padding: "0 12px", flex: 1 }}> |
| <span style={{ color: t.text3, fontSize: 13 }}>🔍</span> |
| <input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search menu items..." style={{ border: "none", background: "transparent", padding: "9px 0", fontSize: 13, color: t.text, outline: "none", width: "100%" }} /> |
| </div> |
| <select value={cat} onChange={e => setCat(e.target.value)} style={{ padding: "9px 12px", border: `1px solid ${t.border}`, borderRadius: 8, fontSize: 13, fontFamily: "inherit", background: t.card, color: t.text, cursor: "pointer", outline: "none" }}> |
| <option value="all">All Categories</option> |
| {db.categories.map(c => <option key={c.id} value={c.id}>{c.icon} {c.name}</option>)} |
| </select> |
| </div> |
|
|
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(270px,1fr))", gap: 12 }}> |
| {items.map(item => { |
| const category = db.categories.find(c => c.id === item.cat); |
| return ( |
| <Card key={item.id} t={t} style={{ opacity: item.status === "inactive" ? 0.55 : 1, padding: 0, overflow: "hidden" }}> |
| <div style={{ padding: "14px 14px 10px", borderBottom: `1px solid ${t.border}`, display: "flex", gap: 12, alignItems: "flex-start" }}> |
| <div style={{ fontSize: 32, flexShrink: 0 }}>{item.emoji}</div> |
| <div style={{ flex: 1 }}> |
| <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 3 }}> |
| <span style={{ fontWeight: 700, fontSize: 14, color: t.text }}>{item.name}</span> |
| <div style={{ width: 10, height: 10, border: `2px solid ${item.type === "veg" ? "#039855" : "#EF4444"}`, borderRadius: 2, display: "flex", alignItems: "center", justifyContent: "center" }}> |
| <div style={{ width: 5, height: 5, borderRadius: "50%", background: item.type === "veg" ? "#039855" : "#EF4444" }} /> |
| </div> |
| </div> |
| <div style={{ fontSize: 11, color: t.text2, marginBottom: 6 }}>{item.desc}</div> |
| <div style={{ display: "flex", gap: 6 }}> |
| {category && <span style={{ fontSize: 10, padding: "1px 7px", borderRadius: 99, background: "#EDE9FE", color: "#5B21B6", fontWeight: 600 }}>{category.icon} {category.name}</span>} |
| </div> |
| </div> |
| </div> |
| <div style={{ padding: "10px 14px", display: "flex", alignItems: "center", gap: 8 }}> |
| <div style={{ flex: 1 }}> |
| <div style={{ fontSize: 18, fontWeight: 800, color: "#875BF7" }}>{fmt(item.price)}</div> |
| <div style={{ fontSize: 11, color: t.text3 }}>Cost: {fmt(item.cost)} · Margin: {Math.round(((item.price - item.cost) / item.price) * 100)}%</div> |
| </div> |
| <Toggle checked={item.status === "active"} onChange={() => toggle(item.id)} /> |
| <Btn onClick={() => del(item.id)} variant="danger" size="sm">🗑</Btn> |
| </div> |
| </Card> |
| ); |
| })} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function CategoriesPage({ db, mutate, notify, t, setModal }) { |
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Menu Categories" t={t} actions={<Btn variant="primary" size="sm">+ Add Category</Btn>} /> |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(220px,1fr))", gap: 14 }}> |
| {db.categories.map(c => { |
| const count = db.menuItems.filter(m => m.cat === c.id).length; |
| return ( |
| <Card key={c.id} t={t}> |
| <div style={{ fontSize: 32, marginBottom: 10 }}>{c.icon}</div> |
| <div style={{ fontWeight: 700, fontSize: 15, color: t.text }}>{c.name}</div> |
| <div style={{ fontSize: 13, color: t.text2, marginTop: 4 }}>{count} items</div> |
| <div style={{ marginTop: 12, display: "flex", gap: 6 }}> |
| <Btn variant="secondary" size="sm" style={{ flex: 1 }}>Edit</Btn> |
| <Btn variant="danger" size="sm">🗑</Btn> |
| </div> |
| </Card> |
| ); |
| })} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function InventoryPage({ db, mutate, notify, t }) { |
| const update = async (id, delta) => { |
| await mutate(db => ({ ...db, inventory: db.inventory.map(i => i.id === id ? { ...i, stock: Math.max(0, i.stock + delta) } : i) })); |
| }; |
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Inventory" subtitle="Track stock levels and manage supplies" t={t} |
| actions={<><Btn variant="secondary" size="sm">Purchase Order</Btn><Btn variant="primary" size="sm">+ Add Item</Btn></>} /> |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14, marginBottom: 20 }}> |
| <StatCard icon="📦" value={db.inventory.length} label="Total Items" t={t} /> |
| <StatCard icon="⚠️" value={db.inventory.filter(i => i.stock <= i.reorderAt).length} label="Low Stock" t={t} color="#EF4444" /> |
| <StatCard icon="💰" value={fmt(db.inventory.reduce((a, i) => a + i.stock * i.cost, 0))} label="Inventory Value" t={t} color="#875BF7" /> |
| </div> |
| <Card t={t} style={{ padding: 0 }}> |
| <Table t={t} cols={["Item","Category","Stock","Unit","Reorder At","Value","Status","Adjust"]} |
| rows={db.inventory.map(i => [ |
| <strong>{i.name}</strong>, |
| i.category, |
| <strong style={{ color: i.stock <= i.reorderAt ? "#EF4444" : t.text }}>{i.stock}</strong>, |
| i.unit, |
| i.reorderAt, |
| fmt(i.stock * i.cost), |
| <Badge label={i.stock <= i.reorderAt ? "Low Stock" : "OK"} color={i.stock <= i.reorderAt ? "red" : "green"} t={t} />, |
| <div style={{ display: "flex", gap: 4 }}> |
| <Btn onClick={() => update(i.id, -1)} variant="secondary" size="sm">−</Btn> |
| <Btn onClick={() => update(i.id, 10)} variant="success" size="sm">+10</Btn> |
| </div>, |
| ])} /> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| function ReportsPage({ db, t }) { |
| const billed = db.orders.filter(o => o.status === "billed"); |
| const revenue = billed.reduce((a, o) => a + o.total, 0); |
| const days = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]; |
| const vals = [4200,6800,5400,7200,8900,12400,9800]; |
| const max = Math.max(...vals); |
| const payMethods = ["Cash","Card","UPI"]; |
| const payVals = [45,35,20]; |
|
|
| const topItems = db.menuItems.map(m => { |
| const sold = db.orders.flatMap(o => o.items).filter(i => i.id === m.id).reduce((a, i) => a + i.qty, 0); |
| return { ...m, sold }; |
| }).sort((a, b) => b.sold - a.sold).slice(0, 5); |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Reports & Analytics" t={t} |
| actions={<><select style={{ padding: "7px 12px", border: `1px solid ${t.border}`, borderRadius: 8, fontSize: 13, fontFamily: "inherit", background: t.card, color: t.text, outline: "none" }}><option>Today</option><option>This Week</option><option>This Month</option></select><Btn variant="primary" size="sm">Export PDF</Btn></>} /> |
|
|
| <div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 14, marginBottom: 24 }}> |
| <StatCard icon="💰" value={fmt(revenue)} label="Total Revenue" t={t} color="#875BF7" change="+8.2%" changeUp /> |
| <StatCard icon="🧾" value={db.orders.length} label="Total Orders" t={t} change="+12%" changeUp /> |
| <StatCard icon="📊" value={db.orders.length ? fmt(Math.round(revenue / db.orders.length)) : "₹0"} label="Avg Order Value" t={t} color="#3B82F6" /> |
| <StatCard icon="⭐" value={topItems[0]?.name?.split(" ")[0] || "—"} label="Top Item" t={t} color="#039855" /> |
| </div> |
|
|
| <div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16, marginBottom: 16 }}> |
| <Card t={t}> |
| <div style={{ fontWeight: 700, fontSize: 14, color: t.text, marginBottom: 16 }}>Weekly Revenue</div> |
| <div style={{ display: "flex", alignItems: "flex-end", gap: 8, height: 130 }}> |
| {days.map((d, i) => ( |
| <div key={d} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}> |
| <div style={{ fontSize: 10, color: t.text3 }}>₹{(vals[i]/1000).toFixed(1)}k</div> |
| <div style={{ width: "100%", borderRadius: "4px 4px 0 0", background: i === 5 ? "#875BF7" : "#EDE9FE", height: (vals[i] / max) * 100, minHeight: 4, transition: ".5s" }} /> |
| <div style={{ fontSize: 11, color: t.text2 }}>{d}</div> |
| </div> |
| ))} |
| </div> |
| </Card> |
| <Card t={t}> |
| <div style={{ fontWeight: 700, fontSize: 14, color: t.text, marginBottom: 16 }}>Payment Methods</div> |
| {payMethods.map((m, i) => ( |
| <div key={m} style={{ marginBottom: 14 }}> |
| <div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 5, color: t.text }}> |
| <span>{m}</span><span style={{ fontWeight: 700 }}>{payVals[i]}%</span> |
| </div> |
| <div style={{ height: 6, background: t.surface2, borderRadius: 99 }}> |
| <div style={{ height: "100%", width: `${payVals[i]}%`, background: ["#039855","#3B82F6","#875BF7"][i], borderRadius: 99, transition: ".5s" }} /> |
| </div> |
| </div> |
| ))} |
| </Card> |
| </div> |
|
|
| <Card t={t}> |
| <div style={{ fontWeight: 700, fontSize: 14, color: t.text, marginBottom: 16 }}>Top Selling Items</div> |
| <Table t={t} cols={["Item","Category","Sold","Revenue","Margin"]} |
| rows={topItems.map(item => { |
| const cat = db.categories.find(c => c.id === item.cat); |
| const rev = item.sold * item.price; |
| const margin = Math.round(((item.price - item.cost) / item.price) * 100); |
| return [ |
| <div style={{ display: "flex", gap: 8, alignItems: "center" }}><span style={{ fontSize: 18 }}>{item.emoji}</span><strong>{item.name}</strong></div>, |
| cat?.name || item.cat, |
| item.sold, |
| <strong style={{ color: "#875BF7" }}>{fmt(rev)}</strong>, |
| <Badge label={`${margin}%`} color={margin > 50 ? "green" : margin > 30 ? "orange" : "red"} t={t} />, |
| ]; |
| })} /> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| function AccountingPage({ db, t }) { |
| const revenue = db.orders.filter(o => o.status === "billed").reduce((a, o) => a + o.total, 0); |
| const expenses = db.expenses?.reduce((a, e) => a + e.amount, 0) || 0; |
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Accounting" subtitle="General ledger and financial overview" t={t} |
| actions={<Btn variant="primary" size="sm">Export P&L</Btn>} /> |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14, marginBottom: 24 }}> |
| <StatCard icon="📈" value={fmt(revenue)} label="Total Income" t={t} color="#039855" /> |
| <StatCard icon="📉" value={fmt(expenses)} label="Total Expenses" t={t} color="#EF4444" /> |
| <StatCard icon="💼" value={fmt(revenue - expenses)} label="Net Profit" t={t} color="#875BF7" /> |
| </div> |
| <Card t={t}> |
| <div style={{ fontWeight: 700, fontSize: 14, color: t.text, marginBottom: 16 }}>Ledger Entries</div> |
| <Table t={t} cols={["Date","Description","Type","Amount","Balance"]} |
| rows={[ |
| [fmtDate(Date.now()), "Sales Revenue", <Badge label="Credit" color="green" t={t} />, <span style={{ color: "#039855" }}>+{fmt(revenue)}</span>, fmt(revenue)], |
| [fmtDate(Date.now() - 86400000), "Inventory Purchase", <Badge label="Debit" color="red" t={t} />, <span style={{ color: "#EF4444" }}>−{fmt(12400)}</span>, fmt(revenue - 12400)], |
| [fmtDate(Date.now() - 172800000), "Salary Payment", <Badge label="Debit" color="red" t={t} />, <span style={{ color: "#EF4444" }}>−{fmt(expenses)}</span>, fmt(revenue - expenses - 12400)], |
| ]} /> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| function ExpensesPage({ db, mutate, notify, t }) { |
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Expenses" subtitle="Track and manage restaurant expenses" t={t} |
| actions={<Btn variant="primary" size="sm">+ Add Expense</Btn>} /> |
| <StatCard icon="💸" value={fmt(db.expenses?.reduce((a,e)=>a+e.amount,0)||0)} label="Total Expenses" t={t} color="#EF4444" /> |
| <div style={{ marginTop: 16 }}> |
| <Card t={t} style={{ padding: 0 }}> |
| <Table t={t} cols={["Date","Description","Category","Amount","Status","Receipt"]} |
| rows={(db.expenses||[]).map(e => [ |
| e.date, e.desc, e.category, |
| <strong style={{ color: "#EF4444" }}>{fmt(e.amount)}</strong>, |
| <Badge label={e.status} color={e.status === "paid" ? "green" : "orange"} t={t} />, |
| e.receipt ? "✅" : "❌", |
| ])} /> |
| </Card> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function PayrollPage({ db, t }) { |
| const staff = db.users.filter(u => u.role !== "admin"); |
| const salaries = { waiter: 18000, cashier: 22000, manager: 35000, kitchen: 20000 }; |
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Payroll" subtitle="Staff salaries and payslip management" t={t} |
| actions={<><Btn variant="secondary" size="sm">Generate Payslips</Btn><Btn variant="primary" size="sm">Process Payroll</Btn></>} /> |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14, marginBottom: 20 }}> |
| <StatCard icon="👥" value={staff.length} label="Total Staff" t={t} /> |
| <StatCard icon="💰" value={fmt(staff.reduce((a,u)=>a+(salaries[u.role]||18000),0))} label="Monthly Payroll" t={t} color="#875BF7" /> |
| <StatCard icon="📅" value="April 1" label="Next Pay Date" t={t} color="#039855" /> |
| </div> |
| <Card t={t} style={{ padding: 0 }}> |
| <Table t={t} cols={["Name","Role","Salary","Status","Payslip"]} |
| rows={staff.map(u => [ |
| <div style={{ display: "flex", gap: 8, alignItems: "center" }}> |
| <div style={{ width: 28, height: 28, borderRadius: "50%", background: u.color, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontSize: 12, fontWeight: 700 }}>{u.name[0]}</div> |
| <strong>{u.name}</strong> |
| </div>, |
| u.role, |
| <strong style={{ color: "#875BF7" }}>{fmt(salaries[u.role]||18000)}</strong>, |
| <Badge label="Active" color="green" t={t} />, |
| <Btn variant="ghost" size="sm">View</Btn>, |
| ])} /> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| function CRMPage({ db, mutate, notify, t }) { |
| const tagColors = { VIP: "purple", Regular: "blue", New: "gray" }; |
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="CRM — Customers" subtitle={`${db.customers.length} customers`} t={t} |
| actions={<Btn variant="primary" size="sm">+ Add Customer</Btn>} /> |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 14, marginBottom: 20 }}> |
| <StatCard icon="👤" value={db.customers.length} label="Total Customers" t={t} /> |
| <StatCard icon="⭐" value={db.customers.filter(c=>c.tag==="VIP").length} label="VIP Customers" t={t} color="#875BF7" /> |
| <StatCard icon="💰" value={fmt(db.customers.reduce((a,c)=>a+c.totalSpent,0))} label="Total Spent" t={t} color="#039855" /> |
| </div> |
| <Card t={t} style={{ padding: 0 }}> |
| <Table t={t} cols={["Name","Phone","Visits","Total Spent","Last Visit","Loyalty Pts","Tag"]} |
| rows={db.customers.map(c => [ |
| <strong>{c.name}</strong>, |
| c.phone, |
| c.visits, |
| <strong style={{ color: "#875BF7" }}>{fmt(c.totalSpent)}</strong>, |
| c.lastVisit, |
| <span style={{ fontWeight: 700, color: "#875BF7" }}>{c.loyaltyPts} pts</span>, |
| <Badge label={c.tag} color={tagColors[c.tag]||"gray"} t={t} />, |
| ])} /> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| function EmployeesPage({ db, t }) { |
| const staff = db.users.filter(u => u.role !== "admin"); |
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Employees" subtitle="HR management and employee records" t={t} |
| actions={<><Btn variant="secondary" size="sm">Import</Btn><Btn variant="primary" size="sm">+ Add Employee</Btn></>} /> |
| <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(240px,1fr))", gap: 14 }}> |
| {staff.map(u => ( |
| <Card key={u.id} t={t}> |
| <div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 14 }}> |
| <div style={{ width: 44, height: 44, borderRadius: "50%", background: u.color, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontSize: 18, fontWeight: 700 }}>{u.name[0]}</div> |
| <div> |
| <div style={{ fontWeight: 700, fontSize: 14, color: t.text }}>{u.name}</div> |
| <div style={{ fontSize: 12, color: t.text2 }}>@{u.id} · {u.role}</div> |
| </div> |
| </div> |
| <div style={{ fontSize: 12, color: t.text2 }}>{u.email}</div> |
| <div style={{ fontSize: 12, color: t.text2, marginTop: 3 }}>{u.phone}</div> |
| <div style={{ marginTop: 10, display: "flex", gap: 6 }}> |
| <Badge label={u.active ? "Active" : "Inactive"} color={u.active ? "green" : "red"} t={t} /> |
| <Badge label={u.role} color="purple" t={t} /> |
| </div> |
| </Card> |
| ))} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function UsersPage({ db, mutate, notify, t, setModal, currentUser }) { |
| const nonAdmin = db.users.filter(u => u.role !== "admin"); |
|
|
| const toggleActive = async (id) => { |
| await mutate(db => ({ ...db, users: db.users.map(u => u.id === id ? { ...u, active: !u.active } : u) })); |
| const u = db.users.find(x => x.id === id); |
| notify(`${u?.name} ${u?.active ? "access revoked" : "restored"}`, "info"); |
| }; |
|
|
| const copyLink = (u) => { |
| const link = `${window.location.href.split("?")[0]}?user=${u.id}`; |
| navigator.clipboard?.writeText(link).then(() => notify("Share link copied! 📋", "success")).catch(() => notify("Link: " + link, "info")); |
| }; |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Users & Staff" subtitle="Manage user accounts and access" t={t} |
| actions={<Btn onClick={() => setModal({ type: "addUser" })} variant="primary" size="sm">+ Add User</Btn>} /> |
| |
| {nonAdmin.map(u => ( |
| <Card key={u.id} t={t} style={{ marginBottom: 14 }}> |
| <div style={{ display: "flex", gap: 14, alignItems: "flex-start" }}> |
| <div style={{ width: 48, height: 48, borderRadius: "50%", background: u.color, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontSize: 20, fontWeight: 700, flexShrink: 0 }}>{u.name[0]}</div> |
| <div style={{ flex: 1 }}> |
| <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 3 }}> |
| <span style={{ fontWeight: 700, fontSize: 15, color: t.text }}>{u.name}</span> |
| <Badge label={u.active ? "Active" : "Inactive"} color={u.active ? "green" : "red"} t={t} /> |
| <Badge label={u.role} color="purple" t={t} /> |
| </div> |
| <div style={{ fontSize: 12, color: t.text2, marginBottom: 6 }}>@{u.id} · {u.email} · {u.phone}</div> |
| <div onClick={() => copyLink(u)} style={{ fontSize: 11, color: "#3B82F6", cursor: "pointer", fontFamily: "monospace", background: "#EFF6FF", display: "inline-block", padding: "3px 10px", borderRadius: 6 }}> |
| 🔗 Share login link (click to copy) |
| </div> |
| <div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 6 }}> |
| {u.permissions.map(p => { |
| const m = MODULES.find(x => x.id === p); |
| return m ? <span key={p} style={{ fontSize: 10, padding: "1px 7px", borderRadius: 99, background: "#EDE9FE", color: "#5B21B6", fontWeight: 600 }}>{m.icon} {m.label}</span> : null; |
| })} |
| </div> |
| </div> |
| <div style={{ display: "flex", gap: 6, flexShrink: 0 }}> |
| <Btn onClick={() => setModal({ type: "editPerms", userId: u.id })} variant="ghost" size="sm">🔐 Permissions</Btn> |
| <Btn onClick={() => toggleActive(u.id)} variant={u.active ? "danger" : "success"} size="sm">{u.active ? "Revoke" : "Restore"}</Btn> |
| </div> |
| </div> |
| </Card> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
| function AccessPage({ db, mutate, notify, t }) { |
| const nonAdmin = db.users.filter(u => u.role !== "admin"); |
|
|
| const toggle = async (userId, modId, val) => { |
| await mutate(db => ({ |
| ...db, |
| users: db.users.map(u => u.id !== userId ? u : { |
| ...u, |
| permissions: val ? [...new Set([...u.permissions, modId])] : u.permissions.filter(p => p !== modId) |
| }) |
| })); |
| const u = db.users.find(x => x.id === userId); |
| const m = MODULES.find(x => x.id === modId); |
| notify(`${u?.name}: ${m?.label} ${val ? "granted ✅" : "revoked ❌"}`, "info"); |
| }; |
|
|
| const grantAll = async (userId) => { |
| await mutate(db => ({ ...db, users: db.users.map(u => u.id === userId ? { ...u, permissions: MODULES.map(m => m.id) } : u) })); |
| notify("All modules granted", "success"); |
| }; |
| const revokeAll = async (userId) => { |
| await mutate(db => ({ ...db, users: db.users.map(u => u.id === userId ? { ...u, permissions: [] } : u) })); |
| notify("All access revoked", "info"); |
| }; |
|
|
| const groups = [...new Set(MODULES.map(m => m.group))]; |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Access Control" subtitle="Grant or revoke module permissions per user" t={t} /> |
| {nonAdmin.map(u => ( |
| <Card key={u.id} t={t} style={{ marginBottom: 16 }}> |
| <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16 }}> |
| <div style={{ width: 40, height: 40, borderRadius: "50%", background: u.color, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontSize: 16, fontWeight: 700 }}>{u.name[0]}</div> |
| <div style={{ flex: 1 }}> |
| <div style={{ fontWeight: 700, color: t.text }}>{u.name}</div> |
| <div style={{ fontSize: 12, color: t.text2 }}>@{u.id} · {u.role} · {u.permissions.length}/{MODULES.length} modules</div> |
| </div> |
| <Btn onClick={() => grantAll(u.id)} variant="success" size="sm">Grant All</Btn> |
| <Btn onClick={() => revokeAll(u.id)} variant="danger" size="sm">Revoke All</Btn> |
| </div> |
| {groups.map(g => { |
| const mods = MODULES.filter(m => m.group === g); |
| return ( |
| <div key={g} style={{ marginBottom: 12 }}> |
| <div style={{ fontSize: 11, fontWeight: 700, color: t.text3, textTransform: "uppercase", letterSpacing: "0.5px", marginBottom: 6 }}>{g}</div> |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}> |
| {mods.map(m => { |
| const has = u.permissions.includes(m.id); |
| return ( |
| <div key={m.id} onClick={() => toggle(u.id, m.id, !has)} |
| style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 10px", borderRadius: 99, border: `1.5px solid ${has ? "#875BF7" : t.border}`, background: has ? "#EDE9FE" : t.surface2, cursor: "pointer", fontSize: 12, fontWeight: 600, color: has ? "#5B21B6" : t.text2, transition: ".15s" }}> |
| <span style={{ fontSize: 12 }}>{m.icon}</span> {m.label} |
| {has && <span style={{ fontSize: 10, color: "#875BF7" }}>✓</span>} |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| ); |
| })} |
| </Card> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
| function SettingsPage({ db, mutate, notify, t }) { |
| const [s, setS] = useState(db.settings); |
|
|
| const save = async () => { |
| await mutate(db => ({ ...db, settings: s })); |
| notify("Settings saved ✅", "success"); |
| }; |
|
|
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title="Settings" subtitle="Restaurant configuration and preferences" t={t} |
| actions={<Btn onClick={save} variant="primary">Save Changes</Btn>} /> |
| |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, maxWidth: 800 }}> |
| <Card t={t}> |
| <div style={{ fontWeight: 700, fontSize: 14, color: t.text, marginBottom: 16 }}>Restaurant Info</div> |
| <Input label="Restaurant Name" value={s.restaurantName} onChange={v => setS(x => ({ ...x, restaurantName: v }))} /> |
| <Input label="Address" value={s.address} onChange={v => setS(x => ({ ...x, address: v }))} /> |
| <Input label="Phone" value={s.phone} onChange={v => setS(x => ({ ...x, phone: v }))} /> |
| <Input label="GST Number" value={s.gst} onChange={v => setS(x => ({ ...x, gst: v }))} /> |
| <Input label="FSSAI License" value={s.fssai} onChange={v => setS(x => ({ ...x, fssai: v }))} /> |
| </Card> |
| <Card t={t}> |
| <div style={{ fontWeight: 700, fontSize: 14, color: t.text, marginBottom: 16 }}>Tax & Charges</div> |
| <Input label="GST Rate (%)" type="number" value={String(s.gstRate)} onChange={v => setS(x => ({ ...x, gstRate: Number(v) }))} /> |
| <Input label="Service Charge (%)" type="number" value={String(s.serviceCharge)} onChange={v => setS(x => ({ ...x, serviceCharge: Number(v) }))} /> |
| <div style={{ marginBottom: 14 }}> |
| <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#374151", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.4px" }}>Theme</label> |
| <div style={{ display: "flex", gap: 8 }}> |
| {["light","dark"].map(th => ( |
| <button key={th} onClick={() => setS(x => ({ ...x, theme: th }))} |
| style={{ flex: 1, padding: "10px", borderRadius: 8, border: `2px solid ${s.theme === th ? "#875BF7" : t.border}`, background: s.theme === th ? "#EDE9FE" : t.surface2, color: s.theme === th ? "#875BF7" : t.text2, fontWeight: 600, fontSize: 13, cursor: "pointer" }}> |
| {th === "light" ? "☀️ Light" : "🌙 Dark"} |
| </button> |
| ))} |
| </div> |
| </div> |
| <Input label="Logo Emoji" value={s.logo} onChange={v => setS(x => ({ ...x, logo: v }))} /> |
| </Card> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function GenericPage({ title, icon, theme: t }) { |
| return ( |
| <div style={{ padding: 24 }}> |
| <PageHeader title={`${icon} ${title}`} subtitle="Module coming soon" t={t} /> |
| <Card t={t} style={{ textAlign: "center", padding: "60px 40px" }}> |
| <div style={{ fontSize: 56, marginBottom: 14 }}>{icon}</div> |
| <div style={{ fontSize: 18, fontWeight: 700, color: t.text }}>{title}</div> |
| <div style={{ fontSize: 13, color: t.text2, marginTop: 6 }}>This module is available and can be fully configured.</div> |
| <Btn variant="primary" style={{ marginTop: 20 }}>Configure Module</Btn> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| function ModalLayer({ modal, setModal, db, mutate, notify, theme: t, currentUser }) { |
| const close = () => setModal(null); |
|
|
| if (modal.type === "bill") { |
| const o = modal.order; |
| return ( |
| <ModalShell title={`Invoice — ${o.id}`} onClose={close} t={t} width={440}> |
| <div style={{ textAlign: "center", marginBottom: 20, padding: "0 0 16px", borderBottom: `1px dashed ${t.border}` }}> |
| <div style={{ fontSize: 28 }}>{db.settings.logo}</div> |
| <div style={{ fontWeight: 800, fontSize: 18, color: t.text }}>{db.settings.restaurantName}</div> |
| <div style={{ fontSize: 12, color: t.text2 }}>{db.settings.address}</div> |
| <div style={{ fontSize: 12, color: t.text2 }}>GST: {db.settings.gst}</div> |
| <div style={{ marginTop: 10, fontSize: 13, color: t.text2 }}>Table {o.table} · {fmtTime(o.time)} · {o.waiterName}</div> |
| <div style={{ fontWeight: 700, color: "#875BF7" }}>{o.id}</div> |
| </div> |
| {o.items.map((item, i) => ( |
| <div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 13, padding: "6px 0", color: t.text }}> |
| <span>{item.qty}× {item.name}</span><span style={{ fontWeight: 600 }}>{fmt(item.price * item.qty)}</span> |
| </div> |
| ))} |
| <div style={{ marginTop: 12, paddingTop: 12, borderTop: `1px dashed ${t.border}` }}> |
| {[["Subtotal", fmt(o.subtotal)], [`GST (${db.settings.gstRate}%)`, fmt(o.taxAmount)], [`Service (${db.settings.serviceCharge}%)`, fmt(Math.round(o.subtotal * db.settings.serviceCharge / 100))]].map(([k, v]) => ( |
| <div key={k} style={{ display: "flex", justifyContent: "space-between", fontSize: 12, color: t.text2, marginBottom: 4 }}><span>{k}</span><span>{v}</span></div> |
| ))} |
| <div style={{ display: "flex", justifyContent: "space-between", fontSize: 18, fontWeight: 800, marginTop: 10, color: t.text }}> |
| <span>TOTAL</span><span style={{ color: "#875BF7" }}>{fmt(o.total)}</span> |
| </div> |
| </div> |
| <div style={{ textAlign: "center", marginTop: 16, fontSize: 12, color: t.text3, paddingTop: 12, borderTop: `1px dashed ${t.border}` }}>Thank you for dining with us! 🙏</div> |
| <div style={{ marginTop: 16, display: "flex", gap: 8 }}> |
| <Btn onClick={close} variant="secondary" style={{ flex: 1 }}>Close</Btn> |
| <Btn onClick={() => { window.print(); }} variant="primary" style={{ flex: 1 }}>🖨️ Print</Btn> |
| </div> |
| </ModalShell> |
| ); |
| } |
|
|
| if (modal.type === "addUser") { |
| const [form, setForm] = useState({ name: "", id: "", pass: "", email: "", phone: "", role: "waiter", permissions: [...INITIAL_PERMS] }); |
| const togglePerm = (p) => setForm(f => ({ ...f, permissions: f.permissions.includes(p) ? f.permissions.filter(x => x !== p) : [...f.permissions, p] })); |
| const save = async () => { |
| if (!form.name || !form.id || !form.pass) { notify("Fill all required fields", "error"); return; } |
| if (db.users.find(u => u.id === form.id)) { notify("Login ID already exists", "error"); return; } |
| await mutate(db => ({ ...db, users: [...db.users, { ...form, color: COLORS[Math.floor(Math.random() * COLORS.length)], active: true, pin: "0000" }] })); |
| notify(`${form.name} created successfully ✅`, "success"); |
| close(); |
| }; |
| return ( |
| <ModalShell title="Add New User" onClose={close} t={t}> |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}> |
| <Input label="Full Name *" value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} placeholder="Ravi Kumar" /> |
| <Input label="Login ID *" value={form.id} onChange={v => setForm(f => ({ ...f, id: v }))} placeholder="waiter3" /> |
| <Input label="Password *" type="password" value={form.pass} onChange={v => setForm(f => ({ ...f, pass: v }))} placeholder="••••••" /> |
| <div> |
| <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#374151", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.4px" }}>Role</label> |
| <select value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))} style={{ width: "100%", padding: "10px 12px", border: "1.5px solid #D1D5DB", borderRadius: 8, fontSize: 13, fontFamily: "inherit", outline: "none" }}> |
| {["waiter","cashier","manager","kitchen"].map(r => <option key={r} value={r}>{r}</option>)} |
| </select> |
| </div> |
| <Input label="Email" value={form.email} onChange={v => setForm(f => ({ ...f, email: v }))} placeholder="email@restaurant.com" /> |
| <Input label="Phone" value={form.phone} onChange={v => setForm(f => ({ ...f, phone: v }))} placeholder="+91 98765 43210" /> |
| </div> |
| <div style={{ marginTop: 8 }}> |
| <div style={{ fontSize: 12, fontWeight: 700, color: "#374151", marginBottom: 10, textTransform: "uppercase", letterSpacing: "0.4px" }}>Module Permissions</div> |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 6, maxHeight: 200, overflowY: "auto" }}> |
| {MODULES.map(m => { |
| const has = form.permissions.includes(m.id); |
| return <div key={m.id} onClick={() => togglePerm(m.id)} style={{ padding: "4px 10px", borderRadius: 99, border: `1.5px solid ${has ? "#875BF7" : "#E5E7EB"}`, background: has ? "#EDE9FE" : "#F9FAFB", color: has ? "#5B21B6" : "#6B7280", fontSize: 12, fontWeight: 600, cursor: "pointer" }}>{m.icon} {m.label}</div>; |
| })} |
| </div> |
| </div> |
| <div style={{ display: "flex", gap: 8, marginTop: 16 }}> |
| <Btn onClick={close} variant="secondary" style={{ flex: 1 }}>Cancel</Btn> |
| <Btn onClick={save} variant="primary" style={{ flex: 1 }}>Create User</Btn> |
| </div> |
| </ModalShell> |
| ); |
| } |
|
|
| if (modal.type === "editPerms") { |
| const user = db.users.find(u => u.id === modal.userId); |
| if (!user) return null; |
| const [perms, setPerms] = useState([...user.permissions]); |
| const togglePerm = (p) => setPerms(ps => ps.includes(p) ? ps.filter(x => x !== p) : [...ps, p]); |
| const save = async () => { |
| await mutate(db => ({ ...db, users: db.users.map(u => u.id === modal.userId ? { ...u, permissions: perms } : u) })); |
| notify(`Permissions updated for ${user.name} ✅`, "success"); |
| close(); |
| }; |
| const groups = [...new Set(MODULES.map(m => m.group))]; |
| return ( |
| <ModalShell title={`Permissions — ${user.name}`} onClose={close} t={t} width={640}> |
| <div style={{ display: "flex", gap: 8, marginBottom: 14 }}> |
| <Btn onClick={() => setPerms(MODULES.map(m => m.id))} variant="success" size="sm">Grant All</Btn> |
| <Btn onClick={() => setPerms([])} variant="danger" size="sm">Revoke All</Btn> |
| <span style={{ marginLeft: "auto", fontSize: 13, color: t.text2 }}>{perms.length}/{MODULES.length} modules</span> |
| </div> |
| <div style={{ maxHeight: 360, overflowY: "auto" }}> |
| {groups.map(g => ( |
| <div key={g} style={{ marginBottom: 12 }}> |
| <div style={{ fontSize: 11, fontWeight: 700, color: t.text3, textTransform: "uppercase", letterSpacing: "0.5px", marginBottom: 6 }}>{g}</div> |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}> |
| {MODULES.filter(m => m.group === g).map(m => { |
| const has = perms.includes(m.id); |
| return <div key={m.id} onClick={() => togglePerm(m.id)} style={{ padding: "4px 10px", borderRadius: 99, border: `1.5px solid ${has ? "#875BF7" : "#E5E7EB"}`, background: has ? "#EDE9FE" : "#F9FAFB", color: has ? "#5B21B6" : "#6B7280", fontSize: 12, fontWeight: 600, cursor: "pointer" }}>{m.icon} {m.label} {has ? "✓" : ""}</div>; |
| })} |
| </div> |
| </div> |
| ))} |
| </div> |
| <div style={{ marginTop: 14, padding: 12, background: "#EFF6FF", borderRadius: 8 }}> |
| <div style={{ fontSize: 12, fontWeight: 700, color: "#1E40AF", marginBottom: 4 }}>Shareable Login Link</div> |
| <div style={{ fontFamily: "monospace", fontSize: 11, color: "#3B82F6", cursor: "pointer", wordBreak: "break-all" }} onClick={() => { navigator.clipboard?.writeText(`${window.location.href.split("?")[0]}?user=${user.id}`); notify("Link copied! 📋", "success"); }}> |
| {window.location.href.split("?")[0]}?user={user.id} (click to copy) |
| </div> |
| </div> |
| <div style={{ display: "flex", gap: 8, marginTop: 14 }}> |
| <Btn onClick={close} variant="secondary" style={{ flex: 1 }}>Cancel</Btn> |
| <Btn onClick={save} variant="primary" style={{ flex: 1 }}>Save Permissions</Btn> |
| </div> |
| </ModalShell> |
| ); |
| } |
|
|
| if (modal.type === "addMenu") { |
| const [form, setForm] = useState({ name: "", cat: db.categories[0]?.id || "", price: "", cost: "", type: "veg", emoji: "🍽", desc: "", status: "active", tax: 5 }); |
| const save = async () => { |
| if (!form.name || !form.price) { notify("Enter name and price", "error"); return; } |
| await mutate(db => ({ ...db, menuItems: [...db.menuItems, { ...form, id: Date.now(), price: Number(form.price), cost: Number(form.cost), tax: Number(form.tax), stock: 999 }] })); |
| notify(`${form.name} added to menu ✅`, "success"); |
| close(); |
| }; |
| return ( |
| <ModalShell title="Add Menu Item" onClose={close} t={t}> |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}> |
| <Input label="Item Name *" value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} placeholder="Chicken Biryani" /> |
| <div> |
| <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#374151", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.4px" }}>Category</label> |
| <select value={form.cat} onChange={e => setForm(f => ({ ...f, cat: e.target.value }))} style={{ width: "100%", padding: "10px 12px", border: "1.5px solid #D1D5DB", borderRadius: 8, fontSize: 13, fontFamily: "inherit", outline: "none" }}> |
| {db.categories.map(c => <option key={c.id} value={c.id}>{c.icon} {c.name}</option>)} |
| </select> |
| </div> |
| <Input label="Price (₹) *" type="number" value={form.price} onChange={v => setForm(f => ({ ...f, price: v }))} placeholder="320" /> |
| <Input label="Cost (₹)" type="number" value={form.cost} onChange={v => setForm(f => ({ ...f, cost: v }))} placeholder="130" /> |
| <Input label="Emoji" value={form.emoji} onChange={v => setForm(f => ({ ...f, emoji: v }))} placeholder="🍚" /> |
| <div> |
| <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#374151", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.4px" }}>Type</label> |
| <select value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))} style={{ width: "100%", padding: "10px 12px", border: "1.5px solid #D1D5DB", borderRadius: 8, fontSize: 13, fontFamily: "inherit", outline: "none" }}> |
| <option value="veg">🟢 Vegetarian</option> |
| <option value="nonveg">🔴 Non-Vegetarian</option> |
| </select> |
| </div> |
| </div> |
| <div style={{ marginTop: 4 }}> |
| <label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#374151", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.4px" }}>Description</label> |
| <textarea value={form.desc} onChange={e => setForm(f => ({ ...f, desc: e.target.value }))} placeholder="Brief description of the dish..." style={{ width: "100%", padding: "10px 12px", border: "1.5px solid #D1D5DB", borderRadius: 8, fontSize: 13, fontFamily: "inherit", resize: "none", height: 70, outline: "none" }} /> |
| </div> |
| <div style={{ display: "flex", gap: 8, marginTop: 12 }}> |
| <Btn onClick={close} variant="secondary" style={{ flex: 1 }}>Cancel</Btn> |
| <Btn onClick={save} variant="primary" style={{ flex: 1 }}>Add Item</Btn> |
| </div> |
| </ModalShell> |
| ); |
| } |
|
|
| return null; |
| } |
|
|
| function ModalShell({ title, onClose, children, t, width = 540 }) { |
| return ( |
| <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", backdropFilter: "blur(4px)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }} onClick={onClose}> |
| <div style={{ background: t?.card || "#fff", border: `1px solid ${t?.cardBorder || "#E5E7EB"}`, borderRadius: 16, width, maxWidth: "100%", maxHeight: "88vh", overflow: "auto", boxShadow: "0 24px 60px rgba(0,0,0,0.18)", animation: "fadeIn 0.2s ease" }} onClick={e => e.stopPropagation()}> |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: `1px solid ${t?.border || "#E5E7EB"}` }}> |
| <h3 style={{ fontSize: 15, fontWeight: 800, color: t?.text || "#111" }}>{title}</h3> |
| <button onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", fontSize: 18, color: t?.text2 || "#888", lineHeight: 1 }}>✕</button> |
| </div> |
| <div style={{ padding: 20 }}>{children}</div> |
| </div> |
| </div> |
| ); |
| } |
|
|