first commit

This commit is contained in:
hardik 2026-08-12 11:53:13 +05:30
commit a03a620acc
71 changed files with 35293 additions and 0 deletions

14
.agents/rules/graphify.md Normal file
View File

@ -0,0 +1,14 @@
---
trigger: always_on
description: Consult the graphify knowledge graph at graphify-out/ for codebase and architecture questions.
---
## graphify
This project has a graphify knowledge graph at graphify-out/.
Rules:
- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query "<question>"` (CLI) or `query_graph` (MCP). Use `graphify path "<A>" "<B>"` / `shortest_path` for relationships and `graphify explain "<concept>"` / `get_node` for focused concepts. These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output.
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)

View File

@ -0,0 +1,10 @@
---
name: graphify
description: Turn any folder of files into a navigable knowledge graph
---
# Workflow: graphify
Follow the graphify skill to run the full pipeline.
If no path argument is given, use `.` (current directory).

7
.env Normal file
View File

@ -0,0 +1,7 @@
# RestroAI Frontend Environment Variables
# The HTTP URL of the remote backend server
VITE_API_URL=https://restro-backend.navigolabs.com
# The WebSocket URL of the remote backend server
VITE_WS_URL=wss://restro-backend.navigolabs.com

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
# RestroAI Frontend Environment Variables
# The HTTP URL of the remote backend server
VITE_API_URL=https://api.yourdomain.com
# The WebSocket URL of the remote backend server
VITE_WS_URL=wss://api.yourdomain.com

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
.oxlintrc.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
README.md Normal file
View File

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

View File

@ -0,0 +1,407 @@
{
"communities": {
"0": [
"src_app",
"src_app_app",
"src_app_customerview",
"src_app_gettablenumfromurl",
"src_components_admin_staffloginmodal",
"src_components_admin_staffloginmodal_staffloginmodal",
"src_components_admin_staffloginmodal_staffloginmodalprops",
"src_components_customer_billpaymentview",
"src_components_customer_billpaymentview_billpaymentview",
"src_components_customer_billpaymentview_billpaymentviewprops",
"src_components_customer_customizationmodal_customizationmodal",
"src_components_customer_orderstatusview",
"src_components_customer_orderstatusview_order_steps",
"src_components_customer_orderstatusview_orderstatusview",
"src_components_customer_orderstatusview_orderstatusviewprops",
"src_components_customer_tablelandingview",
"src_components_customer_tablelandingview_tablelandingview",
"src_components_customer_tablelandingview_tablelandingviewprops",
"src_main",
"src_services_api",
"src_services_api_apirequest",
"src_services_api_clearcustomersession",
"src_services_api_cleartokens",
"src_services_api_getaccesstoken",
"src_services_api_getbackendmenuid",
"src_services_api_getcustomersessionid",
"src_services_api_getcustomertoken",
"src_services_api_getfrontendmenuitem",
"src_services_api_getrefreshtoken",
"src_services_api_handlemockrequest",
"src_services_api_isdemomode",
"src_services_api_mockdb",
"src_services_api_notifywslisteners",
"src_services_api_restrowebsocket",
"src_services_api_restrowebsocket_close",
"src_services_api_restrowebsocket_connect",
"src_services_api_restrowebsocket_constructor",
"src_services_api_setcustomersession",
"src_services_api_setdemomodechangecallback",
"src_services_api_settokens",
"src_services_api_subscribetowsevents",
"src_services_api_triggerdemomode",
"src_services_api_wslisteners",
"src_theme_theme",
"src_theme_theme_theme"
],
"1": [
"ref_react",
"src_components_admin_inventoryview",
"src_components_analytics_analyticsdashboard",
"src_components_common_navigation",
"src_components_common_navigation_navigation",
"src_components_common_navigation_navigationprops",
"src_components_common_statusbadge",
"src_components_common_statusbadge_statusbadge",
"src_components_customer_cartreviewview",
"src_components_customer_cartreviewview_cartreviewview",
"src_components_customer_cartreviewview_cartreviewviewprops",
"src_components_customer_customizationmodal",
"src_components_customer_customizationmodal_customizationmodalprops",
"src_components_customer_menubrowseview",
"src_components_customer_menubrowseview_dietary_filters",
"src_components_customer_menubrowseview_menubrowseview",
"src_components_customer_menubrowseview_menubrowseviewprops",
"src_components_customer_menubrowseview_spice_icons",
"src_components_table_tablelandingview",
"src_components_table_tablelandingview_tablelandingview",
"src_data_menudata",
"src_data_menudata_categories",
"src_data_menudata_categorytype",
"src_data_menudata_menu_items",
"src_data_menudata_menuitem"
],
"2": [
"emotion_react",
"emotion_styled",
"mui_icons_material",
"mui_material",
"package",
"package_dependencies",
"package_dependencies_emotion_react",
"package_dependencies_emotion_styled",
"package_dependencies_mui_icons_material",
"package_dependencies_mui_material",
"package_dependencies_react",
"package_dependencies_react_dom",
"package_name",
"package_packagemanager",
"package_private",
"package_scripts",
"package_scripts_build",
"package_scripts_dev",
"package_scripts_lint",
"package_scripts_preview",
"package_type",
"package_version",
"react",
"react_dom"
],
"3": [
"ref_dom",
"ref_src",
"ref_vite_client",
"tsconfig_app",
"tsconfig_app_compileroptions",
"tsconfig_app_compileroptions_allowarbitraryextensions",
"tsconfig_app_compileroptions_allowimportingtsextensions",
"tsconfig_app_compileroptions_erasablesyntaxonly",
"tsconfig_app_compileroptions_jsx",
"tsconfig_app_compileroptions_lib",
"tsconfig_app_compileroptions_module",
"tsconfig_app_compileroptions_moduledetection",
"tsconfig_app_compileroptions_moduleresolution",
"tsconfig_app_compileroptions_noemit",
"tsconfig_app_compileroptions_nofallthroughcasesinswitch",
"tsconfig_app_compileroptions_nounusedlocals",
"tsconfig_app_compileroptions_nounusedparameters",
"tsconfig_app_compileroptions_skiplibcheck",
"tsconfig_app_compileroptions_target",
"tsconfig_app_compileroptions_tsbuildinfofile",
"tsconfig_app_compileroptions_types",
"tsconfig_app_compileroptions_verbatimmodulesyntax",
"tsconfig_app_include",
"tsconfig_app_json_ref_es2023"
],
"4": [
"oxlint",
"oxlintrc",
"oxlintrc_plugins",
"oxlintrc_rules",
"oxlintrc_rules_react_only_export_components",
"oxlintrc_rules_react_rules_of_hooks",
"oxlintrc_schema",
"package_devdependencies",
"package_devdependencies_oxlint",
"package_devdependencies_types_node",
"package_devdependencies_types_react",
"package_devdependencies_types_react_dom",
"package_devdependencies_typescript",
"package_devdependencies_vite",
"package_devdependencies_vitejs_plugin_react",
"ref_oxc",
"ref_warn",
"types_node",
"types_react",
"types_react_dom",
"typescript",
"vite",
"vitejs_plugin_react"
],
"5": [
"ref_node",
"ref_vite_config_ts",
"tsconfig_node",
"tsconfig_node_compileroptions",
"tsconfig_node_compileroptions_allowimportingtsextensions",
"tsconfig_node_compileroptions_erasablesyntaxonly",
"tsconfig_node_compileroptions_lib",
"tsconfig_node_compileroptions_module",
"tsconfig_node_compileroptions_moduledetection",
"tsconfig_node_compileroptions_noemit",
"tsconfig_node_compileroptions_nofallthroughcasesinswitch",
"tsconfig_node_compileroptions_nounusedlocals",
"tsconfig_node_compileroptions_nounusedparameters",
"tsconfig_node_compileroptions_skiplibcheck",
"tsconfig_node_compileroptions_target",
"tsconfig_node_compileroptions_tsbuildinfofile",
"tsconfig_node_compileroptions_types",
"tsconfig_node_compileroptions_verbatimmodulesyntax",
"tsconfig_node_include",
"tsconfig_node_json_ref_es2023"
],
"6": [
"src_components_customer_voiceassistantmodal",
"src_components_customer_voiceassistantmodal_detecteditem",
"src_components_customer_voiceassistantmodal_parsetranscripttoitems",
"src_components_customer_voiceassistantmodal_quick_commands",
"src_components_customer_voiceassistantmodal_voiceassistantmodal",
"src_components_customer_voiceassistantmodal_voiceassistantmodalprops",
"src_hooks_usespeechrecognition",
"src_hooks_usespeechrecognition_ispeechrecognition",
"src_hooks_usespeechrecognition_ispeechrecognition_abort",
"src_hooks_usespeechrecognition_ispeechrecognition_start",
"src_hooks_usespeechrecognition_ispeechrecognition_stop",
"src_hooks_usespeechrecognition_speechrecognitionerrorevent",
"src_hooks_usespeechrecognition_speechrecognitionevent",
"src_hooks_usespeechrecognition_usespeechrecognition",
"src_hooks_usespeechrecognition_usespeechrecognitionreturn",
"src_hooks_usespeechrecognition_voicestate",
"src_hooks_usespeechrecognition_window",
"src_hooks_usespeechsynthesis",
"src_hooks_usespeechsynthesis_usespeechsynthesis",
"src_hooks_usespeechsynthesis_usespeechsynthesisreturn"
],
"7": [
"src_components_admin_admindesktopshell",
"src_components_admin_admindesktopshell_admindesktopshell",
"src_components_admin_admindesktopshell_tabtype",
"src_components_admin_billingmanagerview",
"src_components_admin_billingmanagerview_billingmanagerview",
"src_components_admin_feedbackinboxview",
"src_components_admin_feedbackinboxview_feedbackinboxview",
"src_components_admin_inventoryview_inventoryview",
"src_components_admin_reservationsview",
"src_components_admin_reservationsview_reservationsview",
"src_components_admin_staffshiftsview",
"src_components_admin_staffshiftsview_staffshiftsview",
"src_components_admin_suppliersview",
"src_components_admin_suppliersview_suppliersview",
"src_components_admin_tablemanagementview",
"src_components_admin_tablemanagementview_tablemanagementview",
"src_components_analytics_analyticsdashboard_analyticsdashboard",
"src_components_kds_kdskanban_kdskanban",
"src_services_api_api"
],
"8": [
"src_components_admin_admindesktopshell_admindesktopshellprops",
"src_components_common_statusbadge_statusbadgeprops",
"src_components_kds_kdskanban",
"src_components_kds_kdskanban_kdskanbanprops",
"src_components_kds_kdsticketcard",
"src_components_kds_kdsticketcard_kdsticketcard",
"src_components_kds_kdsticketcard_kdsticketcardprops",
"src_components_voice_voiceorderingview",
"src_components_voice_voiceorderingview_voiceorderingview",
"src_components_voice_voiceorderingview_voiceorderingviewprops",
"src_types_index",
"src_types_index_analyticsmetric",
"src_types_index_dishperformance",
"src_types_index_kdsorder",
"src_types_index_orderitem",
"src_types_index_orderstatus",
"src_types_index_ordertype",
"src_types_index_voicecommandsuggestion"
],
"9": [
"tsconfig",
"tsconfig_files",
"tsconfig_references"
],
"10": [
"vite_config"
]
},
"cohesion": {
"0": 0.08080808080808081,
"1": 0.16333333333333333,
"2": 0.08333333333333333,
"3": 0.08333333333333333,
"4": 0.08695652173913043,
"5": 0.1,
"6": 0.15789473684210525,
"7": 0.1695906432748538,
"8": 0.20261437908496732,
"9": 0.6666666666666666,
"10": 1.0
},
"gods": [
{
"id": "ref_react",
"label": "react",
"degree": 28
},
{
"id": "tsconfig_app_compileroptions",
"label": "compilerOptions",
"degree": 18
},
{
"id": "tsconfig_node_compileroptions",
"label": "compilerOptions",
"degree": 15
},
{
"id": "src_services_api_api",
"label": "api",
"degree": 13
},
{
"id": "src_data_menudata_menuitem",
"label": "MenuItem",
"degree": 10
},
{
"id": "src_types_index_orderstatus",
"label": "OrderStatus",
"degree": 10
},
{
"id": "src_data_menudata_menu_items",
"label": "MENU_ITEMS",
"degree": 9
},
{
"id": "src_app_app",
"label": "App()",
"degree": 8
},
{
"id": "src_types_index_kdsorder",
"label": "KDSOrder",
"degree": 8
},
{
"id": "src_components_common_statusbadge_statusbadge",
"label": "StatusBadge()",
"degree": 7
}
],
"surprises": [
{
"source": "plugins",
"target": "typescript",
"source_files": [
".oxlintrc.json",
"package.json"
],
"confidence": "EXTRACTED",
"relation": "extends",
"why": "connects across different repos/directories"
},
{
"source": "DetectedItem",
"target": "MenuItem",
"source_files": [
"src/components/customer/VoiceAssistantModal.tsx",
"src/data/menuData.ts"
],
"confidence": "EXTRACTED",
"relation": "references",
"why": "bridges separate communities; peripheral node `DetectedItem` unexpectedly reaches hub `MenuItem`"
},
{
"source": "CustomizationModalProps",
"target": "MenuItem",
"source_files": [
"src/components/customer/CustomizationModal.tsx",
"src/data/menuData.ts"
],
"confidence": "EXTRACTED",
"relation": "references",
"why": "peripheral node `CustomizationModalProps` unexpectedly reaches hub `MenuItem`"
},
{
"source": "MenuBrowseViewProps",
"target": "MenuItem",
"source_files": [
"src/components/customer/MenuBrowseView.tsx",
"src/data/menuData.ts"
],
"confidence": "EXTRACTED",
"relation": "references",
"why": "peripheral node `MenuBrowseViewProps` unexpectedly reaches hub `MenuItem`"
},
{
"source": "getFrontendMenuItem()",
"target": "MENU_ITEMS",
"source_files": [
"src/services/api.ts",
"src/data/menuData.ts"
],
"confidence": "EXTRACTED",
"relation": "references",
"why": "bridges separate communities"
}
],
"questions": [
{
"type": "bridge_node",
"question": "Why does `react` connect `react` to `App.tsx`, `devDependencies`, `VoiceAssistantModal.tsx`, `AdminDesktopShell.tsx`, `index.ts`?",
"why": "High betweenness centrality (0.333) - this node is a cross-community bridge."
},
{
"type": "bridge_node",
"question": "Why does `plugins` connect `devDependencies` to `react`?",
"why": "High betweenness centrality (0.252) - this node is a cross-community bridge."
},
{
"type": "bridge_node",
"question": "Why does `devDependencies` connect `devDependencies` to `package.json`?",
"why": "High betweenness centrality (0.217) - this node is a cross-community bridge."
},
{
"type": "isolated_nodes",
"question": "What connects `$schema`, `oxc`, `react/rules-of-hooks` to the rest of the system?",
"why": "85 weakly-connected nodes found - possible documentation gaps or missing edges."
},
{
"type": "low_cohesion",
"question": "Should `App.tsx` be split into smaller, more focused modules?",
"why": "Cohesion score 0.08080808080808081 - nodes in this community are weakly interconnected."
},
{
"type": "low_cohesion",
"question": "Should `package.json` be split into smaller, more focused modules?",
"why": "Cohesion score 0.08333333333333333 - nodes in this community are weakly interconnected."
},
{
"type": "low_cohesion",
"question": "Should `compilerOptions` be split into smaller, more focused modules?",
"why": "Cohesion score 0.08333333333333333 - nodes in this community are weakly interconnected."
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,80 @@
{
"files": {
"code": [
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\.oxlintrc.json",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\package.json",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\App.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\admin\\AdminDesktopShell.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\admin\\BillingManagerView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\admin\\FeedbackInboxView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\admin\\InventoryView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\admin\\ReservationsView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\admin\\StaffLoginModal.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\admin\\StaffShiftsView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\admin\\SuppliersView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\admin\\TableManagementView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\analytics\\AnalyticsDashboard.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\common\\Navigation.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\common\\StatusBadge.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\customer\\BillPaymentView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\customer\\CartReviewView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\customer\\CustomizationModal.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\customer\\MenuBrowseView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\customer\\OrderStatusView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\customer\\TableLandingView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\customer\\VoiceAssistantModal.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\kds\\KDSKanban.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\kds\\KDSTicketCard.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\table\\TableLandingView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\components\\voice\\VoiceOrderingView.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\data\\menuData.ts",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\hooks\\useSpeechRecognition.ts",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\hooks\\useSpeechSynthesis.ts",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\main.tsx",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\services\\api.ts",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\theme\\theme.ts",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\types\\index.ts",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\tsconfig.app.json",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\tsconfig.json",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\tsconfig.node.json",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\vite.config.ts"
],
"document": [
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\.agents\\rules\\graphify.md",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\.agents\\workflows\\graphify.md",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\README.md",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\index.html"
],
"paper": [],
"image": [
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\public\\favicon.svg",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\public\\icons.svg",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\assets\\hero.png",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\assets\\react.svg",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\assets\\vite.svg"
],
"video": []
},
"total_files": 46,
"total_words": 29773,
"needs_graph": false,
"warning": "Corpus is ~29,773 words - fits in a single context window. You may not need a graph.",
"skipped_sensitive": [
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\.env"
],
"unclassified": [
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\.env.example",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\.gitignore",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\App.css",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\src\\index.css"
],
"walk_errors": [],
"ignored": [],
"pruned_noise_dirs": [
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\dist\\",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\graphify-out\\",
"C:\\Users\\hp\\Desktop\\projects\\restroAI\\node_modules\\"
],
"graphifyignore_patterns": 20,
"scan_root": "C:\\Users\\hp\\Desktop\\projects\\restroAI"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
{"0": "App.tsx", "1": "react", "2": "package.json", "3": "compilerOptions", "4": "devDependencies", "5": "compilerOptions", "6": "VoiceAssistantModal.tsx", "7": "AdminDesktopShell.tsx", "8": "index.ts", "9": "tsconfig.json", "10": "vite.config.ts"}

View File

@ -0,0 +1 @@
{"0": "2fd27a4e049e8550", "1": "f766b666900956c7", "2": "be894a9bb6829423", "3": "5e1b3e45a5cffb50", "4": "8f1d803994b9793e", "5": "fe9accd04c14e54c", "6": "d1b0c48243d20e7d", "7": "8b8a887a71cbef13", "8": "15691021ccd947f3", "9": "7256026fe64f6f6a", "10": "b5cc399b9462978b"}

View File

@ -0,0 +1 @@
C:\Users\hp\AppData\Local\Programs\Python\Python312\python.exe

View File

@ -0,0 +1 @@
C:\Users\hp\Desktop\projects\restroAI

View File

@ -0,0 +1,7 @@
{
"nodes": [],
"edges": [],
"hyperedges": [],
"input_tokens": 0,
"output_tokens": 0
}

View File

@ -0,0 +1,109 @@
# Graph Report - C:\Users\hp\Desktop\projects\restroAI (2026-08-05)
## Corpus Check
- cluster-only mode — file stats not available
## Summary
- 222 nodes · 385 edges · 11 communities (10 shown, 1 thin omitted)
- Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- App.tsx
- react
- package.json
- compilerOptions
- devDependencies
- compilerOptions
- VoiceAssistantModal.tsx
- AdminDesktopShell.tsx
- index.ts
- tsconfig.json
## God Nodes (most connected - your core abstractions)
1. `react` - 28 edges
2. `compilerOptions` - 18 edges
3. `compilerOptions` - 15 edges
4. `api` - 13 edges
5. `MenuItem` - 10 edges
6. `OrderStatus` - 10 edges
7. `MENU_ITEMS` - 9 edges
8. `App()` - 8 edges
9. `KDSOrder` - 8 edges
10. `StatusBadge()` - 7 edges
## Surprising Connections (you probably didn't know these)
- `plugins` --extends--> `typescript` [EXTRACTED]
.oxlintrc.json → package.json
- `DetectedItem` --references--> `MenuItem` [EXTRACTED]
src/components/customer/VoiceAssistantModal.tsx → src/data/menuData.ts
- `CustomizationModalProps` --references--> `MenuItem` [EXTRACTED]
src/components/customer/CustomizationModal.tsx → src/data/menuData.ts
- `MenuBrowseViewProps` --references--> `MenuItem` [EXTRACTED]
src/components/customer/MenuBrowseView.tsx → src/data/menuData.ts
- `getFrontendMenuItem()` --references--> `MENU_ITEMS` [EXTRACTED]
src/services/api.ts → src/data/menuData.ts
## Import Cycles
- None detected.
## Communities (11 total, 1 thin omitted)
### Community 0 - "App.tsx"
Cohesion: 0.08
Nodes (30): App(), CustomerView, getTableNumFromUrl(), StaffLoginModal(), StaffLoginModalProps, BillPaymentView(), BillPaymentViewProps, CustomizationModal() (+22 more)
### Community 1 - "react"
Cohesion: 0.16
Nodes (14): react, NavigationProps, StatusBadge(), CartReviewView(), CartReviewViewProps, CustomizationModalProps, DIETARY_FILTERS, MenuBrowseView() (+6 more)
### Community 2 - "package.json"
Cohesion: 0.08
Nodes (23): @emotion/react, @emotion/styled, @mui/icons-material, @mui/material, dependencies, @emotion/react, @emotion/styled, @mui/icons-material (+15 more)
### Community 3 - "compilerOptions"
Cohesion: 0.08
Nodes (23): DOM, src, vite/client, compilerOptions, allowArbitraryExtensions, allowImportingTsExtensions, erasableSyntaxOnly, jsx (+15 more)
### Community 4 - "devDependencies"
Cohesion: 0.09
Nodes (22): oxlint, plugins, rules, react/only-export-components, react/rules-of-hooks, $schema, devDependencies, oxlint (+14 more)
### Community 5 - "compilerOptions"
Cohesion: 0.10
Nodes (19): node, vite.config.ts, compilerOptions, allowImportingTsExtensions, erasableSyntaxOnly, lib, module, moduleDetection (+11 more)
### Community 6 - "VoiceAssistantModal.tsx"
Cohesion: 0.16
Nodes (14): DetectedItem, parseTranscriptToItems(), QUICK_COMMANDS, VoiceAssistantModal(), VoiceAssistantModalProps, ISpeechRecognition, SpeechRecognitionErrorEvent, SpeechRecognitionEvent (+6 more)
### Community 7 - "AdminDesktopShell.tsx"
Cohesion: 0.17
Nodes (12): AdminDesktopShell(), TabType, BillingManagerView(), FeedbackInboxView(), InventoryView(), ReservationsView(), StaffShiftsView(), SuppliersView() (+4 more)
### Community 8 - "index.ts"
Cohesion: 0.20
Nodes (14): AdminDesktopShellProps, StatusBadgeProps, KDSKanbanProps, KDSTicketCard(), KDSTicketCardProps, VoiceOrderingView(), VoiceOrderingViewProps, AnalyticsMetric (+6 more)
## Knowledge Gaps
- **85 isolated node(s):** `$schema`, `oxc`, `react/rules-of-hooks`, `warn`, `name` (+80 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **1 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `react` connect `react` to `App.tsx`, `devDependencies`, `VoiceAssistantModal.tsx`, `AdminDesktopShell.tsx`, `index.ts`?**
_High betweenness centrality (0.333) - this node is a cross-community bridge._
- **Why does `plugins` connect `devDependencies` to `react`?**
_High betweenness centrality (0.252) - this node is a cross-community bridge._
- **Why does `devDependencies` connect `devDependencies` to `package.json`?**
_High betweenness centrality (0.217) - this node is a cross-community bridge._
- **What connects `$schema`, `oxc`, `react/rules-of-hooks` to the rest of the system?**
_85 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `App.tsx` be split into smaller, more focused modules?**
_Cohesion score 0.08080808080808081 - nodes in this community are weakly interconnected._
- **Should `package.json` be split into smaller, more focused modules?**
_Cohesion score 0.08333333333333333 - nodes in this community are weakly interconnected._
- **Should `compilerOptions` be split into smaller, more focused modules?**
_Cohesion score 0.08333333333333333 - nodes in this community are weakly interconnected._

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_tsconfig_json", "label": "tsconfig.json", "file_type": "code", "source_file": "tsconfig.json", "source_location": "L1"}, {"id": "$graphify-root$_tsconfig_files", "label": "files", "file_type": "code", "source_file": "tsconfig.json", "source_location": "L2"}, {"id": "$graphify-root$_tsconfig_references", "label": "references", "file_type": "code", "source_file": "tsconfig.json", "source_location": "L3"}], "edges": [{"source": "$graphify-root$_tsconfig_json", "target": "$graphify-root$_tsconfig_files", "relation": "contains", "confidence": "EXTRACTED", "source_file": "tsconfig.json", "source_location": "L2", "weight": 1.0}, {"source": "$graphify-root$_tsconfig_json", "target": "$graphify-root$_tsconfig_references", "relation": "contains", "confidence": "EXTRACTED", "source_file": "tsconfig.json", "source_location": "L3", "weight": 1.0}]}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_oxlintrc_json", "label": ".oxlintrc.json", "file_type": "code", "source_file": ".oxlintrc.json", "source_location": "L1"}, {"id": "$graphify-root$_oxlintrc_schema", "label": "$schema", "file_type": "code", "source_file": ".oxlintrc.json", "source_location": "L2"}, {"id": "$graphify-root$_oxlintrc_plugins", "label": "plugins", "file_type": "code", "source_file": ".oxlintrc.json", "source_location": "L3"}, {"id": "ref_react", "label": "react", "file_type": "concept", "source_file": ".oxlintrc.json", "source_location": "L3"}, {"id": "ref_typescript", "label": "typescript", "file_type": "concept", "source_file": ".oxlintrc.json", "source_location": "L3"}, {"id": "ref_oxc", "label": "oxc", "file_type": "concept", "source_file": ".oxlintrc.json", "source_location": "L3"}, {"id": "$graphify-root$_oxlintrc_rules", "label": "rules", "file_type": "code", "source_file": ".oxlintrc.json", "source_location": "L4"}, {"id": "$graphify-root$_oxlintrc_rules_react_rules_of_hooks", "label": "react/rules-of-hooks", "file_type": "code", "source_file": ".oxlintrc.json", "source_location": "L5"}, {"id": "$graphify-root$_oxlintrc_rules_react_only_export_components", "label": "react/only-export-components", "file_type": "code", "source_file": ".oxlintrc.json", "source_location": "L6"}, {"id": "ref_warn", "label": "warn", "file_type": "concept", "source_file": ".oxlintrc.json", "source_location": "L6"}], "edges": [{"source": "$graphify-root$_oxlintrc_json", "target": "$graphify-root$_oxlintrc_schema", "relation": "contains", "confidence": "EXTRACTED", "source_file": ".oxlintrc.json", "source_location": "L2", "weight": 1.0}, {"source": "$graphify-root$_oxlintrc_json", "target": "$graphify-root$_oxlintrc_plugins", "relation": "contains", "confidence": "EXTRACTED", "source_file": ".oxlintrc.json", "source_location": "L3", "weight": 1.0}, {"source": "$graphify-root$_oxlintrc_plugins", "target": "ref_react", "relation": "extends", "confidence": "EXTRACTED", "source_file": ".oxlintrc.json", "source_location": "L3", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_oxlintrc_plugins", "target": "ref_typescript", "relation": "extends", "confidence": "EXTRACTED", "source_file": ".oxlintrc.json", "source_location": "L3", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_oxlintrc_plugins", "target": "ref_oxc", "relation": "extends", "confidence": "EXTRACTED", "source_file": ".oxlintrc.json", "source_location": "L3", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_oxlintrc_json", "target": "$graphify-root$_oxlintrc_rules", "relation": "contains", "confidence": "EXTRACTED", "source_file": ".oxlintrc.json", "source_location": "L4", "weight": 1.0}, {"source": "$graphify-root$_oxlintrc_rules", "target": "$graphify-root$_oxlintrc_rules_react_rules_of_hooks", "relation": "contains", "confidence": "EXTRACTED", "source_file": ".oxlintrc.json", "source_location": "L5", "weight": 1.0}, {"source": "$graphify-root$_oxlintrc_rules", "target": "$graphify-root$_oxlintrc_rules_react_only_export_components", "relation": "contains", "confidence": "EXTRACTED", "source_file": ".oxlintrc.json", "source_location": "L6", "weight": 1.0}, {"source": "$graphify-root$_oxlintrc_rules_react_only_export_components", "target": "ref_warn", "relation": "extends", "confidence": "EXTRACTED", "source_file": ".oxlintrc.json", "source_location": "L6", "weight": 1.0, "context": "import"}]}

File diff suppressed because one or more lines are too long

1
graphify-out/cache/stat-index.json vendored Normal file

File diff suppressed because one or more lines are too long

320
graphify-out/graph.html Normal file

File diff suppressed because one or more lines are too long

7028
graphify-out/graph.json Normal file

File diff suppressed because it is too large Load Diff

187
graphify-out/manifest.json Normal file
View File

@ -0,0 +1,187 @@
{
".oxlintrc.json": {
"mtime": 1785233280.3463209,
"ast_hash": "029f4905006c1fffd96bc863e2d98d3e",
"semantic_hash": "029f4905006c1fffd96bc863e2d98d3e"
},
"package.json": {
"mtime": 1785396043.7175367,
"ast_hash": "4655a1612c925f79016d7e2f9187ba73",
"semantic_hash": "4655a1612c925f79016d7e2f9187ba73"
},
"src/App.tsx": {
"mtime": 1785905557.3172138,
"ast_hash": "b59e28160f20dd3a81ea9b0db11afe54",
"semantic_hash": "b59e28160f20dd3a81ea9b0db11afe54"
},
"src/components/admin/AdminDesktopShell.tsx": {
"mtime": 1785905097.248853,
"ast_hash": "afff87f6af48754405d30704280185df",
"semantic_hash": "afff87f6af48754405d30704280185df"
},
"src/components/admin/BillingManagerView.tsx": {
"mtime": 1785905514.858435,
"ast_hash": "f437ceb784222a87398bf2e1c6a8a216",
"semantic_hash": "f437ceb784222a87398bf2e1c6a8a216"
},
"src/components/admin/FeedbackInboxView.tsx": {
"mtime": 1785905479.3076365,
"ast_hash": "7a8469ab9ecf7bdb8ffe7f0750c25a52",
"semantic_hash": "7a8469ab9ecf7bdb8ffe7f0750c25a52"
},
"src/components/admin/InventoryView.tsx": {
"mtime": 1785905582.5370235,
"ast_hash": "abfccd3a550964c60fd67e5c0c0c5aac",
"semantic_hash": "abfccd3a550964c60fd67e5c0c0c5aac"
},
"src/components/admin/ReservationsView.tsx": {
"mtime": 1785905528.0872736,
"ast_hash": "860b3c3951fbfe786027586b5c735a6a",
"semantic_hash": "860b3c3951fbfe786027586b5c735a6a"
},
"src/components/admin/StaffLoginModal.tsx": {
"mtime": 1785905533.9719107,
"ast_hash": "2a7b212d658fc57140796bc7d36c2534",
"semantic_hash": "2a7b212d658fc57140796bc7d36c2534"
},
"src/components/admin/StaffShiftsView.tsx": {
"mtime": 1785905521.3331137,
"ast_hash": "c768b5b542c87471c231c8b98644fa43",
"semantic_hash": "c768b5b542c87471c231c8b98644fa43"
},
"src/components/admin/SuppliersView.tsx": {
"mtime": 1785905498.7847517,
"ast_hash": "e589c84af3729b58f5ed5af6f23aad13",
"semantic_hash": "e589c84af3729b58f5ed5af6f23aad13"
},
"src/components/admin/TableManagementView.tsx": {
"mtime": 1785905381.1579387,
"ast_hash": "064aed47b09064c875f2164337472608",
"semantic_hash": "064aed47b09064c875f2164337472608"
},
"src/components/analytics/AnalyticsDashboard.tsx": {
"mtime": 1785905548.1189818,
"ast_hash": "8f11f39a224311e286e450f718159e2a",
"semantic_hash": "8f11f39a224311e286e450f718159e2a"
},
"src/components/common/Navigation.tsx": {
"mtime": 1785233556.4295392,
"ast_hash": "628b6a71dfcd00bf6e69f24d0347ece2",
"semantic_hash": "628b6a71dfcd00bf6e69f24d0347ece2"
},
"src/components/common/StatusBadge.tsx": {
"mtime": 1785233494.191133,
"ast_hash": "205207743512af9e406b5227049b64fc",
"semantic_hash": "205207743512af9e406b5227049b64fc"
},
"src/components/customer/BillPaymentView.tsx": {
"mtime": 1785905194.0057652,
"ast_hash": "7ca6bf89fa190fd0e07593af3f7005cf",
"semantic_hash": "7ca6bf89fa190fd0e07593af3f7005cf"
},
"src/components/customer/CartReviewView.tsx": {
"mtime": 1785905199.5156598,
"ast_hash": "96f8f643e1bda9391a19644ad78c762e",
"semantic_hash": "96f8f643e1bda9391a19644ad78c762e"
},
"src/components/customer/CustomizationModal.tsx": {
"mtime": 1785239166.0497863,
"ast_hash": "877361d754afa0a4906d8d07127d4db9",
"semantic_hash": "877361d754afa0a4906d8d07127d4db9"
},
"src/components/customer/MenuBrowseView.tsx": {
"mtime": 1785905157.8688335,
"ast_hash": "76824fe03461a9b6d174a7122c7e462c",
"semantic_hash": "76824fe03461a9b6d174a7122c7e462c"
},
"src/components/customer/OrderStatusView.tsx": {
"mtime": 1785905172.1933515,
"ast_hash": "f1a447a5d4cb1ab9e432577d5e0cf36c",
"semantic_hash": "f1a447a5d4cb1ab9e432577d5e0cf36c"
},
"src/components/customer/TableLandingView.tsx": {
"mtime": 1785905152.257808,
"ast_hash": "25694968d103c84c34d1050f942e3c85",
"semantic_hash": "25694968d103c84c34d1050f942e3c85"
},
"src/components/customer/VoiceAssistantModal.tsx": {
"mtime": 1785239308.1253068,
"ast_hash": "4b176d62d71a7fb9bcb49e2f0ce86896",
"semantic_hash": "4b176d62d71a7fb9bcb49e2f0ce86896"
},
"src/components/kds/KDSKanban.tsx": {
"mtime": 1785233506.0593884,
"ast_hash": "42cc5e869a2b732da3164dcd0c9d833d",
"semantic_hash": "42cc5e869a2b732da3164dcd0c9d833d"
},
"src/components/kds/KDSTicketCard.tsx": {
"mtime": 1785233547.0403948,
"ast_hash": "3af4261d1af75a0936901b6ebc60d151",
"semantic_hash": "3af4261d1af75a0936901b6ebc60d151"
},
"src/components/table/TableLandingView.tsx": {
"mtime": 1785233475.4122329,
"ast_hash": "1a190d943d11c35de8443fa70108a774",
"semantic_hash": "1a190d943d11c35de8443fa70108a774"
},
"src/components/voice/VoiceOrderingView.tsx": {
"mtime": 1785239270.3471916,
"ast_hash": "00464f0c42167a0df33cf8efcae9088d",
"semantic_hash": "00464f0c42167a0df33cf8efcae9088d"
},
"src/data/menuData.ts": {
"mtime": 1785238770.1435912,
"ast_hash": "e95b94d964f714c9c11cc9d752d0cd38",
"semantic_hash": "e95b94d964f714c9c11cc9d752d0cd38"
},
"src/hooks/useSpeechRecognition.ts": {
"mtime": 1785238790.0349185,
"ast_hash": "95485ffe24c46698ccd14faf1e9dda5f",
"semantic_hash": "95485ffe24c46698ccd14faf1e9dda5f"
},
"src/hooks/useSpeechSynthesis.ts": {
"mtime": 1785238801.6592543,
"ast_hash": "b963f9a7fcb1d1c53c2d8b61786f9118",
"semantic_hash": "b963f9a7fcb1d1c53c2d8b61786f9118"
},
"src/main.tsx": {
"mtime": 1785233281.7759771,
"ast_hash": "787e5a07efeb2bf6209d5937195f2f68",
"semantic_hash": "787e5a07efeb2bf6209d5937195f2f68"
},
"src/services/api.ts": {
"mtime": 1785908359.8571103,
"ast_hash": "3477c88ca1ba0114944daada5a7d1e7b",
"semantic_hash": "3477c88ca1ba0114944daada5a7d1e7b"
},
"src/theme/theme.ts": {
"mtime": 1785233448.0913806,
"ast_hash": "0f1068cf16ec48a6b8679aabe997988d",
"semantic_hash": "0f1068cf16ec48a6b8679aabe997988d"
},
"src/types/index.ts": {
"mtime": 1785233301.05939,
"ast_hash": "7bb44e94e1c329983c155971065d4f3e",
"semantic_hash": "7bb44e94e1c329983c155971065d4f3e"
},
"tsconfig.app.json": {
"mtime": 1785233280.5814598,
"ast_hash": "5f2dafd737dea8a4ddcbb10f5113ac5a",
"semantic_hash": "5f2dafd737dea8a4ddcbb10f5113ac5a"
},
"tsconfig.json": {
"mtime": 1785233280.63329,
"ast_hash": "a3d39fa780ebff65444de257f291ce6c",
"semantic_hash": "a3d39fa780ebff65444de257f291ce6c"
},
"tsconfig.node.json": {
"mtime": 1785233280.683418,
"ast_hash": "f9cb81ddcea69ffdab672fc8c48dca66",
"semantic_hash": "f9cb81ddcea69ffdab672fc8c48dca66"
},
"vite.config.ts": {
"mtime": 1785233281.6674225,
"ast_hash": "88439104b3c207f17d33ef3160e3b080",
"semantic_hash": "88439104b3c207f17d33ef3160e3b080"
}
}

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RestroAI Operations Platform - Kitchen & Analytics Suite</title>
<!-- Google Fonts: Inter, Plus Jakarta Sans & JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700;800&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2195
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "restroai",
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "yarn@1.22.22",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
src/App.css Normal file
View File

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

333
src/App.tsx Normal file
View File

@ -0,0 +1,333 @@
import React, { useState, useEffect } from 'react';
import { ThemeProvider, CssBaseline, Box, Alert } from '@mui/material';
import { theme } from './theme/theme';
import { TableLandingView } from './components/customer/TableLandingView';
import { MenuBrowseView } from './components/customer/MenuBrowseView';
import { CustomizationModal } from './components/customer/CustomizationModal';
import { CartReviewView } from './components/customer/CartReviewView';
import { OrderStatusView } from './components/customer/OrderStatusView';
import { BillPaymentView } from './components/customer/BillPaymentView';
import { VoiceAssistantModal } from './components/customer/VoiceAssistantModal';
import { StaffLoginModal } from './components/admin/StaffLoginModal';
import { AdminDesktopShell } from './components/admin/AdminDesktopShell';
import type { MenuItem } from './data/menuData';
import type { KDSOrder, OrderStatus } from './types';
import { api, getBackendMenuId, getFrontendMenuItem, RestroWebSocket, subscribeToWsEvents, isDemoMode, setDemoModeChangeCallback } from './services/api';
type CustomerView = 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice';
const getTableNumFromUrl = (): number => {
const params = new URLSearchParams(window.location.search);
const tableParam = params.get('table');
if (tableParam) {
const parsed = parseInt(tableParam);
if (!isNaN(parsed)) return parsed;
}
const path = window.location.pathname;
const match = path.match(/\/scan\/(\d+)/);
if (match) {
return parseInt(match[1]);
}
return 12;
};
export const App: React.FC = () => {
const [appMode, setAppMode] = useState<'customer' | 'staff'>('customer');
const [customerView, setCustomerView] = useState<CustomerView>('landing');
const [staffRole, setStaffRole] = useState<'kitchen' | 'manager'>('kitchen');
const [staffLoginOpen, setStaffLoginOpen] = useState<boolean>(false);
const [voiceModalOpen, setVoiceModalOpen] = useState<boolean>(false);
const [demoActive, setDemoActive] = useState<boolean>(isDemoMode);
const [cart, setCart] = useState<{ [itemId: string]: number }>({});
const [customizingItem, setCustomizingItem] = useState<MenuItem | null>(null);
const [orders, setOrders] = useState<KDSOrder[]>([]);
// Monitor demo mode status
useEffect(() => {
setDemoActive(isDemoMode);
setDemoModeChangeCallback((demo) => {
setDemoActive(demo);
});
}, []);
// Fetch KDS board when staff logged in
const loadKDS = async () => {
try {
// Backend returns KdsBoardResponse: { restaurant_id, orders: KdsBoardOrder[] }
const data = await api.getKDSBoard(1); // 1 = restaurant ID
const ordersArray = Array.isArray(data) ? data : (data?.orders || []);
// Map API Order model to KDSOrder type if format differs
const mappedOrders: KDSOrder[] = ordersArray.map((ord: any) => {
// Map KDS status to Order status
let overallStatus: OrderStatus = 'pending';
if (ord.status === 'served') overallStatus = 'served';
else if (ord.status === 'ready') overallStatus = 'ready';
else if (ord.status === 'in_preparation' || ord.status === 'preparing') overallStatus = 'preparing';
return {
id: String(ord.id || ord.order_id),
ticketNumber: ord.ticketNumber || String(ord.id || ord.order_id).substring(0, 4),
tableNumber: ord.tableNumber || (ord.table_number ? String(ord.table_number) : 'Takeaway'),
orderType: ord.orderType || (ord.channel === 'touch' ? 'Dine-In' : 'Dine-In'),
status: overallStatus,
createdAt: ord.createdAt || ord.placed_at || new Date().toISOString(),
timeElapsedMinutes: ord.timeElapsedMinutes || 0,
priority: ord.priority || 'normal',
serverName: ord.serverName || 'System',
totalAmount: ord.totalAmount || Number(ord.subtotal || 0),
items: ord.items?.map((it: any) => {
const original = getFrontendMenuItem(it.menu_item_id);
return {
id: String(it.id),
name: it.name || original?.name || `Dish ${it.menu_item_id}`,
quantity: it.quantity,
price: it.price || (it.unit_price ? Number(it.unit_price) : (original?.price || 150)),
completed: it.kds_status === 'ready' || it.kds_status === 'served',
dietary: 'veg'
};
}) || []
};
});
setOrders(mappedOrders);
} catch (err) {
console.error('Failed to reload KDS board', err);
}
};
useEffect(() => {
if (appMode === 'staff') {
loadKDS();
// Setup WebSocket connection
const ws = new RestroWebSocket('kds', '1');
ws.connect();
// Subscribe to updates
const unsubscribe = subscribeToWsEvents((event) => {
if (event.type === 'kds.item_updated') {
loadKDS();
}
});
return () => {
ws.close();
unsubscribe();
};
}
}, [appMode]);
const handleUpdateCart = (itemId: string, quantity: number) => {
setCart((prev) => {
const copy = { ...prev };
if (quantity <= 0) {
delete copy[itemId];
} else {
copy[itemId] = quantity;
}
return copy;
});
};
const handleCustomizationConfirm = (item: MenuItem, quantity: number, _notes: string) => {
handleUpdateCart(item.id, (cart[item.id] || 0) + quantity);
};
const handleStartOrdering = async (language: string) => {
try {
const tableId = getTableNumFromUrl();
await api.startSession(tableId, language === 'english' ? 'en' : 'hi');
setCustomerView('menu');
} catch (err) {
console.warn('Could not initialize session, entering offline menu browse', err);
setCustomerView('menu');
}
};
const handlePlaceOrder = async (_orderNotes: string) => {
try {
const items = Object.entries(cart).map(([id, qty]) => ({
menu_item_id: getBackendMenuId(id),
quantity: qty,
customization_notes: []
}));
await api.placeOrder(items);
setCart({});
setCustomerView('status');
} catch (err) {
alert('Failed to place order: ' + err);
}
};
const handleStatusChange = async (orderId: string, newStatus: OrderStatus) => {
try {
const statusMap: Record<OrderStatus, string> = {
pending: 'queued',
preparing: 'in_prep',
ready: 'ready',
served: 'served'
};
const kdsStatus = statusMap[newStatus];
const order = orders.find((o) => o.id === orderId);
if (order) {
for (const item of order.items) {
await api.updateOrderItemStatus(item.id, kdsStatus);
}
}
loadKDS();
} catch (err) {
console.error('Failed to change status', err);
}
};
const handleToggleItem = async (orderId: string, itemId: string) => {
try {
const order = orders.find((o) => o.id === orderId);
const item = order?.items.find((i) => i.id === itemId);
if (item) {
const nextStatus = item.completed ? 'in_prep' : 'ready';
await api.updateOrderItemStatus(itemId, nextStatus);
loadKDS();
}
} catch (err) {
console.error('Failed to toggle item', err);
}
};
const handleAddSampleOrder = async () => {
// Add a sample order via API or locally
try {
const items = [
{ menu_item_id: 9, quantity: 2 }, // Butter chicken
{ menu_item_id: 11, quantity: 4 } // Garlic naan
];
await api.placeOrder(items);
loadKDS();
} catch (e) {
console.error('Failed to add sample order', e);
}
};
const handleAddOrderFromVoice = async (_transcript: string) => {
try {
const items = [
{ menu_item_id: 9, quantity: 2, customization_notes: ['Via Voice assistant'] },
{ menu_item_id: 11, quantity: 3 }
];
await api.placeOrder(items);
loadKDS();
} catch (e) {
console.error('Failed to place voice order', e);
}
};
const handleStaffLoginSuccess = (role: 'kitchen' | 'manager') => {
setStaffRole(role);
setAppMode('staff');
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{demoActive && (
<Alert
severity="warning"
variant="filled"
sx={{
borderRadius: 0,
justifyContent: 'center',
fontWeight: 800,
py: 0.5,
fontSize: '0.8rem',
position: 'sticky',
top: 0,
zIndex: 2000,
background: 'linear-gradient(90deg, #d32f2f 0%, #ef5350 100%)',
}}
>
Running in Demo Mode (Mock Backend). Start restroai-backend locally to connect to live DB.
</Alert>
)}
{appMode === 'staff' ? (
<AdminDesktopShell
role={staffRole}
orders={orders}
onStatusChange={handleStatusChange}
onToggleItem={handleToggleItem}
onAddSampleOrder={handleAddSampleOrder}
onAddOrderFromVoice={handleAddOrderFromVoice}
onLogout={() => setAppMode('customer')}
/>
) : (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2' }}>
{customerView === 'landing' && (
<TableLandingView
onStartOrdering={handleStartOrdering}
onOpenStaffLogin={() => setStaffLoginOpen(true)}
onOpenVoice={() => setVoiceModalOpen(true)}
/>
)}
{customerView === 'menu' && (
<MenuBrowseView
cart={cart}
onUpdateCart={handleUpdateCart}
onOpenCustomization={(item) => setCustomizingItem(item)}
onOpenVoice={() => setVoiceModalOpen(true)}
onNavigate={(v) => setCustomerView(v as CustomerView)}
/>
)}
{customerView === 'cart' && (
<CartReviewView
cart={cart}
onUpdateCart={handleUpdateCart}
onPlaceOrder={handlePlaceOrder}
onNavigate={(v) => setCustomerView(v as CustomerView)}
/>
)}
{customerView === 'status' && (
<OrderStatusView onNavigate={(v) => setCustomerView(v as CustomerView)} />
)}
{customerView === 'bill' && (
<BillPaymentView onNavigate={(v) => setCustomerView(v as CustomerView)} />
)}
{/* Item Customization Dialog */}
<CustomizationModal
open={!!customizingItem}
item={customizingItem}
onClose={() => setCustomizingItem(null)}
onConfirm={handleCustomizationConfirm}
/>
{/* AI Voice Assistant Modal (bottom sheet) */}
<VoiceAssistantModal
open={voiceModalOpen}
onClose={() => setVoiceModalOpen(false)}
onAddToCart={handleUpdateCart}
/>
</Box>
)}
{/* Staff Login Modal */}
<StaffLoginModal
open={staffLoginOpen}
onClose={() => setStaffLoginOpen(false)}
onLoginSuccess={handleStaffLoginSuccess}
/>
</ThemeProvider>
);
};
export default App;

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,284 @@
import React, { useState } from 'react';
import {
Box,
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
AppBar,
Toolbar,
Chip,
Button,
Avatar,
Divider,
} from '@mui/material';
import SoupKitchenIcon from '@mui/icons-material/SoupKitchen';
import BarChartIcon from '@mui/icons-material/BarChart';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import LogoutIcon from '@mui/icons-material/Logout';
import LocalDiningIcon from '@mui/icons-material/LocalDining';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import TableBarIcon from '@mui/icons-material/TableBar';
import ReceiptIcon from '@mui/icons-material/Receipt';
import BookOnlineIcon from '@mui/icons-material/BookOnline';
import InventoryIcon from '@mui/icons-material/Inventory';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import BadgeIcon from '@mui/icons-material/Badge';
import StarHalfIcon from '@mui/icons-material/StarHalf';
import { KDSKanban } from '../kds/KDSKanban';
import { AnalyticsDashboard } from '../analytics/AnalyticsDashboard';
import { VoiceOrderingView } from '../voice/VoiceOrderingView';
import { TableManagementView } from './TableManagementView';
import { BillingManagerView } from './BillingManagerView';
import { ReservationsView } from './ReservationsView';
import { InventoryView } from './InventoryView';
import { SuppliersView } from './SuppliersView';
import { StaffShiftsView } from './StaffShiftsView';
import { FeedbackInboxView } from './FeedbackInboxView';
import type { KDSOrder, OrderStatus } from '../../types';
interface AdminDesktopShellProps {
role: 'kitchen' | 'manager';
orders: KDSOrder[];
onStatusChange: (id: string, newStatus: OrderStatus) => void;
onToggleItem: (orderId: string, itemId: string) => void;
onAddSampleOrder: () => void;
onAddOrderFromVoice: (transcript: string) => void;
onLogout: () => void;
}
type TabType =
| 'kds'
| 'tables'
| 'billing'
| 'reservations'
| 'inventory'
| 'suppliers'
| 'staff'
| 'feedback'
| 'analytics'
| 'voice';
export const AdminDesktopShell: React.FC<AdminDesktopShellProps> = ({
role,
orders,
onStatusChange,
onToggleItem,
onAddSampleOrder,
onAddOrderFromVoice,
onLogout,
}) => {
const [activeTab, setActiveTab] = useState<TabType>(
role === 'manager' ? 'analytics' : 'kds'
);
const pendingCount = orders.filter((o) => o.status === 'pending').length;
const drawerWidth = 260;
// Sidebar list configurations
const menuItems = [
{ id: 'kds', label: 'KDS Kanban Board', icon: <SoupKitchenIcon />, roles: ['kitchen', 'manager'] },
{ id: 'tables', label: 'Tables & Floor', icon: <TableBarIcon />, roles: ['manager'] },
{ id: 'billing', label: 'Billing Desk', icon: <ReceiptIcon />, roles: ['manager'] },
{ id: 'reservations', label: 'Reservations', icon: <BookOnlineIcon />, roles: ['manager'] },
{ id: 'inventory', label: 'Kitchen Stock & Recipes', icon: <InventoryIcon />, roles: ['kitchen', 'manager'] },
{ id: 'suppliers', label: 'Suppliers & POs', icon: <LocalShippingIcon />, roles: ['manager'] },
{ id: 'staff', label: 'Staff & Shifts', icon: <BadgeIcon />, roles: ['manager'] },
{ id: 'feedback', label: 'Customer Feedback', icon: <StarHalfIcon />, roles: ['manager'] },
{ id: 'analytics', label: 'Business Intelligence', icon: <BarChartIcon />, roles: ['manager'] },
{ id: 'voice', label: 'AI Voice Terminal', icon: <RecordVoiceOverIcon />, roles: ['kitchen', 'manager'] },
];
const filteredMenuItems = menuItems.filter((item) => item.roles.includes(role));
const getPageTitle = () => {
switch (activeTab) {
case 'kds': return 'Kitchen Display System (KDS) Live Kanban';
case 'tables': return 'Table Floor & Session Planner';
case 'billing': return 'Cashier Billing Desk & Payments';
case 'reservations': return 'Diner Reservations Book';
case 'inventory': return 'Kitchen Raw Stocks & Recipes Linking';
case 'suppliers': return 'Merchant Suppliers & Purchase Orders';
case 'staff': return 'Employee Attendance & Shift Roster';
case 'feedback': return 'Customer Feedback Rating Reviews';
case 'analytics': return 'Executive Business Analytics & Trends';
case 'voice': return 'Staff AI Voice Assistant Terminal';
default: return 'Management Portal';
}
};
return (
<Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: '#f4f5f7' }}>
{/* Desktop Sidebar Navigation */}
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
bgcolor: '#1a1c1c',
color: '#ffffff',
borderRight: '1px solid #2f3131',
},
}}
>
<Box sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '8px',
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
}}
>
<LocalDiningIcon />
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ffb5a0', lineHeight: 1.1 }}>
RestroAI
</Typography>
<Typography variant="caption" sx={{ color: '#c6c6cd', fontSize: '0.65rem', fontWeight: 700 }}>
DESKTOP STAFF PORTAL
</Typography>
</Box>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
<Box sx={{ p: 2 }}>
<Chip
avatar={<Avatar sx={{ bgcolor: '#ac2d00' }}>{role === 'manager' ? 'M' : 'K'}</Avatar>}
label={role === 'manager' ? 'Role: Restaurant Manager' : 'Role: Kitchen Head Chef'}
sx={{
bgcolor: 'rgba(255,255,255,0.08)',
color: '#ffffff',
fontWeight: 700,
width: '100%',
justifyContent: 'flex-start',
}}
/>
</Box>
<List sx={{ px: 1, overflowY: 'auto', flexGrow: 1 }}>
{filteredMenuItems.map((item) => (
<ListItem disablePadding sx={{ mb: 0.5 }} key={item.id}>
<ListItemButton
selected={activeTab === item.id}
onClick={() => setActiveTab(item.id as TabType)}
sx={{
borderRadius: '8px',
'&.Mui-selected': { bgcolor: '#ac2d00', color: '#ffffff' },
'&.Mui-selected:hover': { bgcolor: '#872100' },
}}
>
<ListItemIcon sx={{ color: activeTab === item.id ? '#ffffff' : '#c6c6cd', minWidth: 38 }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={
<Typography sx={{ fontWeight: activeTab === item.id ? 800 : 500, fontSize: '0.9rem' }}>
{item.label}
</Typography>
}
/>
{item.id === 'kds' && pendingCount > 0 && (
<Chip label={pendingCount} size="small" color="error" sx={{ fontWeight: 800, height: 20 }} />
)}
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ mt: 'auto', p: 2 }}>
<Button
fullWidth
variant="outlined"
onClick={onLogout}
startIcon={<LogoutIcon />}
sx={{
color: '#ffb5a0',
borderColor: 'rgba(255, 181, 160, 0.4)',
borderRadius: '8px',
fontWeight: 700,
'&:hover': {
borderColor: '#ffb5a0',
bgcolor: 'rgba(255, 181, 160, 0.08)',
},
}}
>
Switch to Diner Mode
</Button>
</Box>
</Drawer>
{/* Main Content Area */}
<Box component="main" sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<AppBar position="static" elevation={0} sx={{ bgcolor: '#ffffff', color: '#1a1c1c', borderBottom: '1px solid #e2e2e2' }}>
<Toolbar sx={{ justifyContent: 'space-between' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00' }}>
{getPageTitle()}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Chip
label="System Status: LIVE ONLINE"
color="success"
size="small"
sx={{ fontWeight: 800 }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AccountCircleIcon sx={{ color: '#ac2d00' }} />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Logged in as {role === 'manager' ? 'Admin Manager' : 'Chef Rahul S.'}
</Typography>
</Box>
</Box>
</Toolbar>
</AppBar>
<Box sx={{ p: 3, flexGrow: 1, overflowY: 'auto' }}>
{activeTab === 'kds' && (
<KDSKanban
orders={orders}
onStatusChange={onStatusChange}
onToggleItem={onToggleItem}
onAddSampleOrder={onAddSampleOrder}
/>
)}
{activeTab === 'tables' && <TableManagementView />}
{activeTab === 'billing' && <BillingManagerView />}
{activeTab === 'reservations' && <ReservationsView />}
{activeTab === 'inventory' && <InventoryView />}
{activeTab === 'suppliers' && <SuppliersView />}
{activeTab === 'staff' && <StaffShiftsView />}
{activeTab === 'feedback' && <FeedbackInboxView />}
{activeTab === 'analytics' && <AnalyticsDashboard />}
{activeTab === 'voice' && (
<VoiceOrderingView onAddOrderFromVoice={onAddOrderFromVoice} />
)}
</Box>
</Box>
</Box>
);
};

View File

@ -0,0 +1,310 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardContent,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
Alert,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
} from '@mui/material';
import ReceiptIcon from '@mui/icons-material/Receipt';
import PaymentIcon from '@mui/icons-material/Payment';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import RefreshIcon from '@mui/icons-material/Refresh';
import { api } from '../../services/api';
export const BillingManagerView: React.FC = () => {
const [orders, setOrders] = useState<any[]>([]);
const [bills, setBills] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
// Dialog states
const [paymentOpen, setPaymentOpen] = useState<boolean>(false);
const [activeBill, setActiveBill] = useState<any | null>(null);
const [paymentMethod, setPaymentMethod] = useState<string>('cash');
const [paymentAmount, setPaymentAmount] = useState<string>('');
const loadBillingData = async () => {
setError(null);
try {
// Load KDS board to find active orders
const ordersData = await api.getKDSBoard(1);
const ordersArray = Array.isArray(ordersData) ? ordersData : (ordersData?.orders || []);
setOrders(ordersArray);
} catch (err: any) {
setError(err.message || 'Failed to load billing status');
}
};
useEffect(() => {
loadBillingData();
}, []);
const handleGenerateBill = async (orderId: string) => {
setError(null);
try {
const bill = await api.generateBill(orderId);
setBills((prev) => {
// Avoid duplicate entries
if (prev.some((b) => b.id === bill.id)) return prev;
return [bill, ...prev];
});
alert(`Bill successfully generated! Total: ₹${bill.grandTotal}`);
} catch (err: any) {
setError(err.message || 'Failed to generate bill');
}
};
const handleRecordPayment = async () => {
if (!activeBill) return;
setError(null);
try {
const amount = parseFloat(paymentAmount) || activeBill.grandTotal;
await api.recordPayment(activeBill.id, paymentMethod, amount);
// Update local bills list status
setBills((prev) =>
prev.map((b) => (b.id === activeBill.id ? { ...b, status: 'paid', paymentMethod } : b))
);
setPaymentOpen(false);
setActiveBill(null);
alert('Payment recorded successfully!');
} catch (err: any) {
setError(err.message || 'Failed to record payment');
}
};
const handleIssueCreditNote = async (billId: string) => {
if (!window.confirm('Are you sure you want to issue a Credit Note? This will create a reversal entry.')) {
return;
}
setError(null);
try {
await api.issueCreditNote(billId);
setBills((prev) =>
prev.map((b) => (b.id === billId ? { ...b, status: 'void/credited' } : b))
);
alert('Credit Note / Reversal registered successfully.');
} catch (err: any) {
setError(err.message || 'Failed to issue credit note');
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Cashier & Billing Desk
</Typography>
<Typography variant="body2" color="text.secondary">
Process guest checks, record cash/digital payments, and manage invoices.
</Typography>
</Box>
<Button
variant="outlined"
onClick={loadBillingData}
startIcon={<RefreshIcon />}
sx={{ borderColor: '#ac2d00', color: '#ac2d00', '&:hover': { borderColor: '#872100', bgcolor: 'rgba(172,45,0,0.04)' } }}
>
Refresh Billing Data
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
<Grid container spacing={3}>
{/* Left pane: Active Tables / Active Orders to check out */}
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Active Diner Orders (Generate Bills)
</Typography>
{orders.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1.5px dashed #e2e2e2', borderRadius: '16px' }}>
<Typography variant="body2" color="text.secondary">
No active orders at tables right now.
</Typography>
</Paper>
) : (
<Grid container spacing={2}>
{orders.map((order) => (
<Grid size={{ xs: 12 }} key={order.id}>
<Card elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '12px' }}>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography sx={{ fontWeight: 900 }}>
Table {order.tableNumber || 'Takeaway'}
</Typography>
<Chip label={`Ticket #${order.ticketNumber}`} size="small" variant="outlined" sx={{ fontWeight: 700 }} />
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Items: {order.items?.map((it: any) => `${it.quantity}x ${it.name}`).join(', ')}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Subtotal: {order.totalAmount}
</Typography>
</CardContent>
<Box sx={{ p: 2, pt: 0, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
size="small"
startIcon={<ReceiptIcon />}
onClick={() => handleGenerateBill(order.id)}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Generate Bill
</Button>
</Box>
</Card>
</Grid>
))}
</Grid>
)}
</Grid>
{/* Right pane: Bills Ledger */}
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Checkout Bills Ledger
</Typography>
{bills.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1.5px dashed #e2e2e2', borderRadius: '16px' }}>
<Typography variant="body2" color="text.secondary">
No bills generated in this session yet.
</Typography>
</Paper>
) : (
<TableContainer component={Paper} elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px' }}>
<Table size="small">
<TableHead sx={{ bgcolor: '#fbfbfb' }}>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>Bill ID</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Total</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Status</TableCell>
<TableCell sx={{ fontWeight: 800 }} align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{bills.map((bill) => (
<TableRow key={bill.id}>
<TableCell sx={{ fontWeight: 600 }}>{bill.id.substring(0, 12)}...</TableCell>
<TableCell sx={{ fontWeight: 700 }}>{bill.grandTotal}</TableCell>
<TableCell>
<Chip
label={bill.status.toUpperCase()}
size="small"
color={bill.status === 'paid' ? 'success' : bill.status.startsWith('void') ? 'default' : 'warning'}
sx={{ fontWeight: 700, fontSize: '0.65rem' }}
/>
</TableCell>
<TableCell align="right" sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{bill.status === 'unpaid' && (
<Button
variant="contained"
size="small"
color="success"
startIcon={<PaymentIcon />}
onClick={() => {
setActiveBill(bill);
setPaymentAmount(String(bill.grandTotal));
setPaymentOpen(true);
}}
sx={{ fontWeight: 700 }}
>
Pay
</Button>
)}
{bill.status === 'paid' && (
<Button
variant="outlined"
size="small"
color="error"
startIcon={<LocalOfferIcon />}
onClick={() => handleIssueCreditNote(bill.id)}
sx={{ fontWeight: 700 }}
>
Refund
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Grid>
</Grid>
{/* Pay Bill Dialog */}
<Dialog open={paymentOpen} onClose={() => setPaymentOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800 }}>Record Customer Payment</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
Provide the payment method and final received amount to close this invoice.
</Typography>
<TextField
label="Total Bill Amount"
value={`${activeBill?.grandTotal}`}
disabled
fullWidth
size="small"
/>
<TextField
label="Amount Paid"
type="number"
value={paymentAmount}
onChange={(e) => setPaymentAmount(e.target.value)}
fullWidth
size="small"
/>
<FormControl fullWidth size="small">
<InputLabel>Payment Method</InputLabel>
<Select
value={paymentMethod}
label="Payment Method"
onChange={(e) => setPaymentMethod(e.target.value)}
>
<MenuItem value="cash">💵 Cash Payment</MenuItem>
<MenuItem value="upi">📱 UPI / QR Transfer</MenuItem>
<MenuItem value="card">💳 Card Terminal</MenuItem>
<MenuItem value="wallet">💼 Mobile Wallet</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setPaymentOpen(false)} color="inherit">Cancel</Button>
<Button
variant="contained"
onClick={handleRecordPayment}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Confirm & Save
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@ -0,0 +1,153 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Card,
CircularProgress,
Alert,
Rating,
List,
ListItem,
ListItemText,
Divider,
FormControl,
InputLabel,
Select,
MenuItem,
Button,
Grid,
} from '@mui/material';
import StarIcon from '@mui/icons-material/Star';
import RefreshIcon from '@mui/icons-material/Refresh';
import { api } from '../../services/api';
export const FeedbackInboxView: React.FC = () => {
const [feedbackList, setFeedbackList] = useState<any[]>([]);
const [minRating, setMinRating] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const fetchFeedback = async () => {
setLoading(true);
setError(null);
try {
const data = await api.getFeedback(minRating);
setFeedbackList(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || 'Failed to fetch customer feedback');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFeedback();
}, [minRating]);
const averageRating = feedbackList.length > 0
? (feedbackList.reduce((acc, f) => acc + f.star_rating, 0) / feedbackList.length).toFixed(1)
: '0.0';
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Customer Feedback Inbox
</Typography>
<Typography variant="body2" color="text.secondary">
Read reviews, check service satisfaction, and aggregate dining experience scores.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Filter by Rating</InputLabel>
<Select
value={minRating}
label="Filter by Rating"
onChange={(e) => setMinRating(Number(e.target.value))}
>
<MenuItem value={0}>All Stars</MenuItem>
<MenuItem value={4}>4 Stars & Above</MenuItem>
<MenuItem value={5}>5 Stars Only</MenuItem>
</Select>
</FormControl>
<Button
variant="outlined"
onClick={fetchFeedback}
startIcon={<RefreshIcon />}
sx={{ borderColor: '#ac2d00', color: '#ac2d00', '&:hover': { borderColor: '#872100', bgcolor: 'rgba(172,45,0,0.04)' } }}
>
Refresh
</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
) : (
<Grid container spacing={3}>
{/* Summary Box */}
<Grid size={{ xs: 12, md: 4 }}>
<Card elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px', bgcolor: '#fff9f7', p: 3, textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
Aggregated Score
</Typography>
<Typography variant="h2" sx={{ fontWeight: 900, mb: 1 }}>
{averageRating}
</Typography>
<Rating
value={parseFloat(averageRating)}
precision={0.1}
readOnly
emptyIcon={<StarIcon style={{ opacity: 0.55 }} fontSize="inherit" />}
sx={{ mb: 1.5 }}
/>
<Typography variant="body2" color="text.secondary">
Based on {feedbackList.length} reviews
</Typography>
</Card>
</Grid>
{/* Feedbacks List */}
<Grid size={{ xs: 12, md: 8 }}>
<Card elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px', p: 2 }}>
{feedbackList.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: 'center', fontStyle: 'italic' }}>
No feedback reviews matching your filter criteria.
</Typography>
) : (
<List sx={{ width: '100%' }}>
{feedbackList.map((fb, idx) => (
<React.Fragment key={fb.id || idx}>
<ListItem sx={{ alignItems: 'flex-start', px: 1 }}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
<Rating value={fb.star_rating} readOnly size="small" />
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
{fb.submitted_at ? new Date(fb.submitted_at).toLocaleString() : (fb.date || 'N/A')}
</Typography>
</Box>
}
secondary={
<Typography variant="body2" color="text.primary" sx={{ fontWeight: 550, fontStyle: fb.comment_text ? 'normal' : 'italic' }}>
{fb.comment_text || '(No comment left by guest)'}
</Typography>
}
/>
</ListItem>
{idx < feedbackList.length - 1 && <Divider component="li" />}
</React.Fragment>
))}
</List>
)}
</Card>
</Grid>
</Grid>
)}
</Box>
);
};

View File

@ -0,0 +1,439 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
CircularProgress,
Alert,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Chip,
Select,
MenuItem,
InputLabel,
FormControl,
List,
ListItem,
ListItemText,
Divider,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
import SaveIcon from '@mui/icons-material/Save';
import { api } from '../../services/api';
import { MENU_ITEMS } from '../../data/menuData';
export const InventoryView: React.FC = () => {
const [ingredients, setIngredients] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Ingredient Form Dialog States
const [openIngForm, setOpenIngForm] = useState<boolean>(false);
const [editingIng, setEditingIng] = useState<any | null>(null);
const [ingName, setIngName] = useState<string>('');
const [ingStock, setIngStock] = useState<number>(0);
const [ingUnit, setIngUnit] = useState<string>('kg');
const [ingMinStock, setIngMinStock] = useState<number>(2);
// Recipe Builder States
const [selectedMenuId, setSelectedMenuId] = useState<number>(5); // Default to item 5
// Backend schema: { ingredient_id, quantity_required }
const [recipeIngredients, setRecipeIngredients] = useState<{ ingredient_id: number; quantity_required: number }[]>([]);
const [recipeLoading, setRecipeLoading] = useState<boolean>(false);
// Adding single ingredient to recipe
const [addIngId, setAddIngId] = useState<string>('');
const [addIngQty, setAddIngQty] = useState<number>(0.1);
const fetchIngredients = async () => {
setLoading(true);
setError(null);
try {
const data = await api.getIngredients();
setIngredients(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || 'Failed to fetch ingredients');
} finally {
setLoading(false);
}
};
const fetchRecipe = async (menuId: number) => {
setRecipeLoading(true);
try {
const data = await api.getRecipe(menuId);
// Backend returns list[RecipeIngredientRead] - an array directly
setRecipeIngredients(Array.isArray(data) ? data : (data?.ingredients || []));
} catch (err) {
console.error('Failed to load recipe', err);
} finally {
setRecipeLoading(false);
}
};
useEffect(() => {
fetchIngredients();
}, []);
useEffect(() => {
fetchRecipe(selectedMenuId);
}, [selectedMenuId]);
const handleSaveIngredient = async () => {
setError(null);
try {
if (editingIng) {
// Edit existing — backend only allows: name, unit, reorder_threshold, unit_cost
const updated = await api.updateIngredient(editingIng.id, {
name: ingName,
reorder_threshold: ingMinStock,
});
setIngredients((prev) =>
prev.map((i) => (i.id === editingIng.id ? updated : i))
);
} else {
// Create new — backend schema: name, unit, current_stock, reorder_threshold, unit_cost
const created = await api.createIngredient(ingName, ingStock, ingUnit, ingMinStock);
setIngredients((prev) => [...prev, created]);
}
setOpenIngForm(false);
setEditingIng(null);
setIngName('');
setIngStock(0);
setIngMinStock(2);
} catch (err: any) {
setError(err.message || 'Failed to save ingredient');
}
};
const handleDeleteIngredient = async (id: string) => {
if (!window.confirm('Delete this ingredient?')) return;
try {
await api.deleteIngredient(id);
setIngredients((prev) => prev.filter((i) => i.id !== id));
} catch (err: any) {
alert(err.message || 'Failed to delete ingredient');
}
};
const handleSaveRecipe = async () => {
try {
await api.saveRecipe(selectedMenuId, recipeIngredients);
alert('Recipe updated successfully!');
} catch (err: any) {
alert(err.message || 'Failed to save recipe');
}
};
const handleAddRecipeIngredient = () => {
if (!addIngId || addIngQty <= 0) return;
if (recipeIngredients.some(r => r.ingredient_id === Number(addIngId))) {
alert('Ingredient already added to recipe!');
return;
}
setRecipeIngredients(prev => [...prev, { ingredient_id: Number(addIngId), quantity_required: addIngQty }]);
setAddIngId('');
};
const handleRemoveRecipeIngredient = (ingId: number) => {
setRecipeIngredients(prev => prev.filter(r => r.ingredient_id !== ingId));
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Kitchen Stock & Recipes
</Typography>
<Typography variant="body2" color="text.secondary">
Keep track of ingredient raw stocks, identify low stock warnings, and link menu items to recipes.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
onClick={() => {
setEditingIng(null);
setIngName('');
setIngStock(0);
setIngUnit('kg');
setIngMinStock(2);
setOpenIngForm(true);
}}
startIcon={<AddIcon />}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Add Raw Stock
</Button>
<Button
variant="outlined"
onClick={fetchIngredients}
startIcon={<RefreshIcon />}
sx={{ borderColor: '#ac2d00', color: '#ac2d00', '&:hover': { borderColor: '#872100', bgcolor: 'rgba(172,45,0,0.04)' } }}
>
Refresh Stock
</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
<Grid container spacing={3}>
{/* Ingredients Grid */}
<Grid size={{ xs: 12, md: 7 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Ingredient Stocks List
</Typography>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : (
<TableContainer component={Paper} elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px' }}>
<Table size="small">
<TableHead sx={{ bgcolor: '#fbfbfb' }}>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>Ingredient</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Stock Qty</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Alert Level</TableCell>
<TableCell sx={{ fontWeight: 800 }} align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ingredients.map((ing) => {
// Backend fields: current_stock, reorder_threshold
const currentStock = Number(ing.current_stock ?? ing.stock ?? 0);
const threshold = Number(ing.reorder_threshold ?? ing.minStock ?? 0);
const isLow = currentStock <= threshold;
return (
<TableRow key={ing.id} sx={{ bgcolor: isLow ? 'rgba(237, 108, 2, 0.05)' : 'inherit' }}>
<TableCell sx={{ fontWeight: 700 }}>{ing.name}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{currentStock} {ing.unit}</TableCell>
<TableCell>
{isLow ? (
<Chip label="LOW STOCK" color="warning" size="small" sx={{ fontWeight: 850, fontSize: '0.6rem' }} />
) : (
<Chip label="GOOD" color="success" size="small" sx={{ fontWeight: 850, fontSize: '0.6rem' }} />
)}
</TableCell>
<TableCell align="right" sx={{ display: 'flex', gap: 0.5, justifyContent: 'flex-end' }}>
<IconButton
size="small"
onClick={() => {
setEditingIng(ing);
setIngName(ing.name);
setIngStock(currentStock);
setIngUnit(ing.unit);
setIngMinStock(threshold);
setOpenIngForm(true);
}}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDeleteIngredient(ing.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</Grid>
{/* Recipes Linker Panel */}
<Grid size={{ xs: 12, md: 5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Dish Recipes Linking
</Typography>
<Card elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px', p: 2 }}>
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
<InputLabel>Select Menu Dish</InputLabel>
<Select
value={selectedMenuId}
label="Select Menu Dish"
onChange={(e) => setSelectedMenuId(Number(e.target.value))}
>
{MENU_ITEMS.map((item) => (
<MenuItem key={item.id} value={parseInt(item.id.replace('m', ''))}>
{item.name} ({item.price})
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>
Recipe Ingredients needed:
</Typography>
{recipeLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}><CircularProgress size={30} /></Box>
) : (
<Box sx={{ mb: 3 }}>
{recipeIngredients.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, fontStyle: 'italic' }}>
No ingredients linked to this dish recipe yet. Link below.
</Typography>
) : (
<List disablePadding>
{recipeIngredients.map((item) => {
// Backend fields: ingredient_id, quantity_required
const detail = ingredients.find((i: any) => i.id === item.ingredient_id);
return (
<ListItem
key={item.ingredient_id}
secondaryAction={
<IconButton size="small" color="error" onClick={() => handleRemoveRecipeIngredient(item.ingredient_id)}>
<DeleteIcon fontSize="small" />
</IconButton>
}
sx={{ px: 0, py: 0.5 }}
>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{detail ? detail.name : `Ingredient #${item.ingredient_id}`}
</Typography>
}
secondary={`Required: ${item.quantity_required} ${detail ? detail.unit : 'kg'}`}
/>
</ListItem>
);
})}
</List>
)}
<Divider sx={{ my: 2 }} />
<Typography variant="caption" sx={{ fontWeight: 800, color: 'text.secondary', display: 'block', mb: 1 }}>
LINK ANOTHER STOCK COMPONENT:
</Typography>
<Grid container spacing={1} sx={{ alignItems: 'center' }}>
<Grid size={{ xs: 6 }}>
<FormControl fullWidth size="small">
<Select
value={addIngId}
displayEmpty
onChange={(e) => setAddIngId(e.target.value)}
>
<MenuItem value="">Choose stock...</MenuItem>
{ingredients.map((ing) => (
<MenuItem key={ing.id} value={ing.id}>{ing.name}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 3 }}>
<TextField
type="number"
size="small"
placeholder="Qty"
value={addIngQty}
onChange={(e) => setAddIngQty(parseFloat(e.target.value) || 0)}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
size="small"
onClick={handleAddRecipeIngredient}
sx={{ height: 38, borderColor: '#ac2d00', color: '#ac2d00', fontWeight: 700 }}
>
Add
</Button>
</Grid>
</Grid>
</Box>
)}
<Button
fullWidth
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSaveRecipe}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Save Recipe Link
</Button>
</Card>
</Grid>
</Grid>
{/* Ingredient Form Dialog */}
<Dialog open={openIngForm} onClose={() => setOpenIngForm(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800 }}>
{editingIng ? 'Modify Raw Ingredient Stock' : 'Add Raw Stock Ingredient'}
</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Ingredient Name"
value={ingName}
onChange={(e) => setIngName(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Available Stock"
type="number"
value={ingStock}
onChange={(e) => setIngStock(parseFloat(e.target.value) || 0)}
fullWidth
size="small"
/>
<FormControl fullWidth size="small">
<InputLabel>Measurement Unit</InputLabel>
<Select
value={ingUnit}
label="Measurement Unit"
onChange={(e) => setIngUnit(e.target.value)}
>
<MenuItem value="kg">kg (Kilogram)</MenuItem>
<MenuItem value="g">g (Gram)</MenuItem>
<MenuItem value="litre">litre (Litre)</MenuItem>
<MenuItem value="units">units (Items count)</MenuItem>
</Select>
</FormControl>
<TextField
label="Minimum Alert Limit"
type="number"
value={ingMinStock}
onChange={(e) => setIngMinStock(parseFloat(e.target.value) || 0)}
fullWidth
size="small"
/>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpenIngForm(false)} color="inherit">Cancel</Button>
<Button
variant="contained"
onClick={handleSaveIngredient}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Save Stock
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@ -0,0 +1,338 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Alert,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Snackbar,
} from '@mui/material';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { api } from '../../services/api';
export const ReservationsView: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<string>(new Date().toISOString().split('T')[0]);
const [reservations, setReservations] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Form dialog states
const [openForm, setOpenForm] = useState<boolean>(false);
const [resName, setResName] = useState<string>('');
const [resPhone, setResPhone] = useState<string>(''); // Required by backend
const [resSize, setResSize] = useState<number>(2);
const [resTable, setResTable] = useState<number>(3);
const [resTime, setResTime] = useState<string>('19:00');
const [resDate, setResDate] = useState<string>(new Date().toISOString().split('T')[0]);
// Warning toast states
const [toastOpen, setToastOpen] = useState<boolean>(false);
const [toastMessage, setToastMessage] = useState<string>('');
const fetchReservations = async () => {
setLoading(true);
setError(null);
try {
const data = await api.getReservations(selectedDate);
setReservations(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || 'Failed to fetch reservations');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchReservations();
}, [selectedDate]);
const handleCreateReservation = async () => {
setError(null);
try {
// Backend schema: { table_id, customer_name, customer_phone, party_size, reservation_time (ISO datetime) }
const reservationTime = new Date(`${resDate}T${resTime}:00`).toISOString();
const res = await api.createReservation({
table_id: resTable,
customer_name: resName,
customer_phone: resPhone || '0000000000',
party_size: resSize,
reservation_time: reservationTime,
});
setOpenForm(false);
// Backend returns { reservation, warnings: ConflictWarning[] }
if (res.warnings && res.warnings.length > 0) {
setToastMessage(`⚠️ Warning: ${res.warnings[0].message || res.warnings[0]}`);
setToastOpen(true);
} else {
setToastMessage('🎉 Reservation booked successfully!');
setToastOpen(true);
}
// Clear form inputs
setResName('');
setResPhone('');
setResSize(2);
setResTable(3);
setResTime('19:00');
// Refresh list
fetchReservations();
} catch (err: any) {
setError(err.message || 'Failed to create reservation');
}
};
const handleUpdateStatus = async (id: string, status: 'confirmed' | 'seated' | 'cancelled' | 'no_show') => {
try {
await api.updateReservationStatus(id, status);
setReservations((prev) =>
prev.map((r) => (r.id === id ? { ...r, status } : r))
);
} catch (err: any) {
alert(err.message || 'Failed to update reservation status');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed':
return 'info';
case 'seated':
return 'success';
case 'cancelled':
return 'error';
case 'no_show':
return 'default';
default:
return 'default';
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Table Reservation Book
</Typography>
<Typography variant="body2" color="text.secondary">
Manage advance table bookings and track check-ins.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<TextField
type="date"
size="small"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
slotProps={{
input: {
startAdornment: <CalendarMonthIcon sx={{ mr: 1, color: '#ac2d00' }} />
}
}}
/>
<Button
variant="contained"
onClick={() => setOpenForm(true)}
startIcon={<AddIcon />}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
New Booking
</Button>
<Button
variant="outlined"
onClick={fetchReservations}
startIcon={<RefreshIcon />}
sx={{ borderColor: '#ac2d00', color: '#ac2d00', '&:hover': { borderColor: '#872100', bgcolor: 'rgba(172,45,0,0.04)' } }}
>
Refresh
</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress color="primary" />
</Box>
) : (
<TableContainer component={Paper} elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px' }}>
<Table>
<TableHead sx={{ bgcolor: '#fbfbfb' }}>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>Diner Name</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Guests</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Table</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Time</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Status</TableCell>
<TableCell sx={{ fontWeight: 800 }} align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{reservations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 6, color: 'text.secondary' }}>
No bookings found for {selectedDate}.
</TableCell>
</TableRow>
) : (
reservations.map((res) => (
<TableRow key={res.id}>
<TableCell sx={{ fontWeight: 700 }}>{res.customer_name || res.name}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{res.party_size || res.size} Pax</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Table {res.table_id || res.tableId}</TableCell>
<TableCell sx={{ fontWeight: 600 }}> {res.reservation_time ? new Date(res.reservation_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : res.time}</TableCell>
<TableCell>
<Chip
label={res.status.toUpperCase()}
size="small"
color={getStatusColor(res.status) as any}
sx={{ fontWeight: 800, fontSize: '0.65rem' }}
/>
</TableCell>
<TableCell align="right" sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{res.status === 'confirmed' && (
<>
<Button
variant="contained"
size="small"
color="success"
onClick={() => handleUpdateStatus(res.id, 'seated')}
sx={{ fontWeight: 700 }}
>
Seat
</Button>
<Button
variant="outlined"
size="small"
color="error"
onClick={() => handleUpdateStatus(res.id, 'cancelled')}
sx={{ fontWeight: 700 }}
>
Cancel
</Button>
</>
)}
{res.status === 'seated' && (
<CheckCircleIcon color="success" />
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
{/* Booking Form Dialog */}
<Dialog open={openForm} onClose={() => setOpenForm(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800 }}>Create New Booking</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Customer Name"
value={resName}
onChange={(e) => setResName(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Customer Phone"
value={resPhone}
onChange={(e) => setResPhone(e.target.value)}
fullWidth
size="small"
placeholder="e.g. 9876543210"
/>
<TextField
label="Number of Guests"
type="number"
value={resSize}
onChange={(e) => setResSize(parseInt(e.target.value) || 2)}
fullWidth
size="small"
/>
<FormControl fullWidth size="small">
<InputLabel>Assign Table</InputLabel>
<Select
value={resTable}
label="Assign Table"
onChange={(e) => setResTable(Number(e.target.value))}
>
{[1, 2, 3, 4, 5, 8, 12].map((num) => (
<MenuItem key={num} value={num}>Table {num}</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Reservation Time"
type="time"
value={resTime}
onChange={(e) => setResTime(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Reservation Date"
type="date"
value={resDate}
onChange={(e) => setResDate(e.target.value)}
fullWidth
size="small"
/>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpenForm(false)} color="inherit">Cancel</Button>
<Button
variant="contained"
onClick={handleCreateReservation}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Create Reservation
</Button>
</DialogActions>
</Dialog>
{/* Conflicted Warning Toast */}
<Snackbar
open={toastOpen}
autoHideDuration={6000}
onClose={() => setToastOpen(false)}
message={toastMessage}
action={
<Button color="inherit" size="small" onClick={() => setToastOpen(false)}>
Dismiss
</Button>
}
/>
</Box>
);
};

View File

@ -0,0 +1,166 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
TextField,
Button,
Box,
Alert,
Chip,
CircularProgress,
} from '@mui/material';
import LockIcon from '@mui/icons-material/Lock';
import BadgeIcon from '@mui/icons-material/Badge';
import { api } from '../../services/api';
interface StaffLoginModalProps {
open: boolean;
onClose: () => void;
onLoginSuccess: (role: 'kitchen' | 'manager') => void;
}
export const StaffLoginModal: React.FC<StaffLoginModalProps> = ({
open,
onClose,
onLoginSuccess,
}) => {
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
setError('Please provide both username and password.');
return;
}
setLoading(true);
setError('');
try {
await api.login(username, password);
// Determine role based on JWT claims or username
const role = username.includes('manager') ? 'manager' : 'kitchen';
onLoginSuccess(role);
onClose();
} catch (err: any) {
setError(err.message || 'Login failed. Please check your credentials.');
} finally {
setLoading(false);
}
};
const fillCredentials = (user: string, pass: string) => {
setUsername(user);
setPassword(pass);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle sx={{ textAlign: 'center', pt: 3 }}>
<Box
sx={{
width: 48,
height: 48,
borderRadius: '50%',
bgcolor: '#ffdbd1',
color: '#ac2d00',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
mb: 1,
}}
>
<LockIcon />
</Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Staff & Admin Portal Login
</Typography>
<Typography variant="caption" sx={{ color: '#5b4139' }}>
Authorized Restaurant Personnel Only
</Typography>
</DialogTitle>
<DialogContent dividers>
<form onSubmit={handleLogin}>
{error && (
<Alert severity="error" sx={{ mb: 2, fontWeight: 600 }}>
{error}
</Alert>
)}
<Box sx={{ mb: 2.5 }}>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#546067', mb: 1, display: 'block' }}>
QUICK AUTOFILL DEMO LOGIN:
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
icon={<BadgeIcon fontSize="small" />}
label="Manager Account"
clickable
onClick={() => fillCredentials('manager@test.com', 'managerpass')}
sx={{ fontWeight: 700, flex: 1 }}
/>
<Chip
icon={<BadgeIcon fontSize="small" />}
label="Chef / Waiter Account"
clickable
onClick={() => fillCredentials('waiter@test.com', 'waiterpass')}
sx={{ fontWeight: 700, flex: 1 }}
/>
</Box>
</Box>
<TextField
label="Email Address / Username"
type="email"
placeholder="manager@test.com"
fullWidth
value={username}
onChange={(e) => setUsername(e.target.value)}
sx={{ mb: 2 }}
disabled={loading}
/>
<TextField
label="Password"
type="password"
placeholder="••••••••"
fullWidth
value={password}
onChange={(e) => setPassword(e.target.value)}
sx={{ mb: 2.5 }}
disabled={loading}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={loading}
sx={{
py: 1.5,
borderRadius: '9999px',
fontWeight: 800,
bgcolor: '#ac2d00',
'&:hover': { bgcolor: '#872100' },
}}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Access Portal'}
</Button>
</form>
</DialogContent>
<DialogActions sx={{ p: 2, justifyContent: 'center' }}>
<Button size="small" onClick={onClose} color="inherit" disabled={loading}>
Cancel & Return to Dining
</Button>
</DialogActions>
</Dialog>
);
};

View File

@ -0,0 +1,333 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Alert,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
import LogoutIcon from '@mui/icons-material/Logout';
import LoginIcon from '@mui/icons-material/Login';
import { api } from '../../services/api';
export const StaffShiftsView: React.FC = () => {
const [staff, setStaff] = useState<any[]>([]);
const [shifts, setShifts] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Staff Form
const [openStaffForm, setOpenStaffForm] = useState<boolean>(false);
const [staffName, setStaffName] = useState<string>('');
const [staffEmail, setStaffEmail] = useState<string>('');
const [staffPassword, setStaffPassword] = useState<string>('');
const [staffRoleId, setStaffRoleId] = useState<number>(2); // default waiter role_id
const [staffRole, setStaffRole] = useState<string>('waiter'); // display only
const [roles, setRoles] = useState<any[]>([]); // RoleRead[] from backend
const loadShiftsAndStaff = async () => {
setLoading(true);
setError(null);
try {
const staffData = await api.getStaff();
setStaff(Array.isArray(staffData) ? staffData : []);
const shiftsData = await api.getShifts();
setShifts(Array.isArray(shiftsData) ? shiftsData : []);
// Load roles for the form dropdown
try {
const rolesData = await api.getRoles();
setRoles(Array.isArray(rolesData) ? rolesData : []);
} catch {
// Roles not critical, ignore failure
}
} catch (err: any) {
setError(err.message || 'Failed to load shifts/staff data');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadShiftsAndStaff();
}, []);
const handleCreateStaff = async () => {
setError(null);
if (!staffPassword || staffPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
try {
// Backend schema: { full_name, email, password, role_id (int), phone? }
const roleId = roles.find(r => r.name === staffRole)?.id || staffRoleId;
const res = await api.createStaff({
full_name: staffName,
email: staffEmail,
password: staffPassword,
role_id: roleId,
});
setStaff(prev => [...prev, res]);
setOpenStaffForm(false);
setStaffName('');
setStaffEmail('');
setStaffPassword('');
setStaffRole('waiter');
} catch (err: any) {
setError(err.message || 'Failed to create staff member');
}
};
const handleClockIn = async (shiftId: string) => {
try {
// Backend returns ShiftRead directly (not { shift })
const res = await api.clockIn(shiftId);
setShifts(prev => prev.map(s => s.id === shiftId ? (res.shift || res) : s));
alert('Clock-in registered successfully!');
} catch (err: any) {
alert(err.message || 'Failed to clock in');
}
};
const handleClockOut = async (shiftId: string) => {
try {
// Backend returns ShiftRead directly (not { shift })
const res = await api.clockOut(shiftId);
setShifts(prev => prev.map(s => s.id === shiftId ? (res.shift || res) : s));
alert('Clock-out registered successfully!');
} catch (err: any) {
alert(err.message || 'Failed to clock out');
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Staff Directory & Shifts
</Typography>
<Typography variant="body2" color="text.secondary">
Manage employee rosters, register work shifts, and track daily clock-in/clock-out attendance.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
onClick={() => setOpenStaffForm(true)}
startIcon={<AddIcon />}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Add Team Member
</Button>
<Button
variant="outlined"
onClick={loadShiftsAndStaff}
startIcon={<RefreshIcon />}
sx={{ borderColor: '#ac2d00', color: '#ac2d00', '&:hover': { borderColor: '#872100', bgcolor: 'rgba(172,45,0,0.04)' } }}
>
Refresh
</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
) : (
<Grid container spacing={3}>
{/* Left panel: Staff Roster */}
<Grid size={{ xs: 12, md: 5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Employees Directory
</Typography>
<TableContainer component={Paper} elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px' }}>
<Table size="small">
<TableHead sx={{ bgcolor: '#fbfbfb' }}>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>Name</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Role</TableCell>
</TableRow>
</TableHead>
<TableBody>
{staff.map((member) => (
<TableRow key={member.id}>
<TableCell sx={{ fontWeight: 700 }}>
{member.full_name || member.name}
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{member.email}
</Typography>
</TableCell>
<TableCell>
<Chip
label={(member.role?.name || member.role || 'staff').toUpperCase()}
size="small"
color={(member.role?.name || member.role) === 'manager' ? 'primary' : 'secondary'}
sx={{ fontWeight: 850, fontSize: '0.6rem' }}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
{/* Right panel: Shifts List */}
<Grid size={{ xs: 12, md: 7 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Attendance Roster
</Typography>
{shifts.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1.5px dashed #e2e2e2', borderRadius: '16px' }}>
<Typography variant="body2" color="text.secondary">
No shifts scheduled or logged for today.
</Typography>
</Paper>
) : (
<TableContainer component={Paper} elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px' }}>
<Table size="small">
<TableHead sx={{ bgcolor: '#fbfbfb' }}>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>Staff Name</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Clock In</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Clock Out</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Status</TableCell>
<TableCell sx={{ fontWeight: 800 }} align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{shifts.map((shift) => (
<TableRow key={shift.id}>
<TableCell sx={{ fontWeight: 700 }}>
{shift.staffName || `Staff #${shift.staff_id || shift.id}`}
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary', fontSize: '0.65rem' }}>
{shift.shift_start ? new Date(shift.shift_start).toLocaleDateString() : shift.date}
</Typography>
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>
{shift.attendance?.time_in ? new Date(shift.attendance.time_in).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : (shift.clockIn || '---')}
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>
{shift.attendance?.time_out ? new Date(shift.attendance.time_out).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : (shift.clockOut || '---')}
</TableCell>
<TableCell>
<Chip
label={(shift.status || 'scheduled').toUpperCase()}
size="small"
color={shift.status === 'in_progress' || shift.status === 'active' ? 'success' : 'default'}
sx={{ fontWeight: 800, fontSize: '0.6rem' }}
/>
</TableCell>
<TableCell align="right">
{(!shift.attendance?.time_in && !shift.clockIn) && (
<Button
variant="contained"
size="small"
startIcon={<LoginIcon />}
onClick={() => handleClockIn(shift.id)}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Clock In
</Button>
)}
{(shift.attendance?.time_in || shift.clockIn) && (!shift.attendance?.time_out && !shift.clockOut) && (
<Button
variant="outlined"
size="small"
color="error"
startIcon={<LogoutIcon />}
onClick={() => handleClockOut(shift.id)}
sx={{ fontWeight: 700 }}
>
Clock Out
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Grid>
</Grid>
)}
{/* Staff Dialog */}
<Dialog open={openStaffForm} onClose={() => setOpenStaffForm(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800 }}>Add Employee</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Full Name"
value={staffName}
onChange={(e) => setStaffName(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Email Address"
value={staffEmail}
onChange={(e) => setStaffEmail(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Password (min 8 chars)"
type="password"
value={staffPassword}
onChange={(e) => setStaffPassword(e.target.value)}
fullWidth
size="small"
helperText="Will be used for staff portal login"
/>
<FormControl fullWidth size="small">
<InputLabel>Portal Role</InputLabel>
<Select
value={staffRole}
label="Portal Role"
onChange={(e) => setStaffRole(e.target.value)}
>
<MenuItem value="waiter">Waiter (Floor Staff)</MenuItem>
<MenuItem value="chef">Chef (Kitchen Staff)</MenuItem>
<MenuItem value="cashier">Cashier (Billing Staff)</MenuItem>
<MenuItem value="manager">Manager (Admin Access)</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpenStaffForm(false)} color="inherit">Cancel</Button>
<Button
variant="contained"
onClick={handleCreateStaff}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Add Member
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@ -0,0 +1,513 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardContent,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Alert,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Chip,
List,
ListItem,
ListItemText,
Divider,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
import SendIcon from '@mui/icons-material/Send';
import Inventory2Icon from '@mui/icons-material/Inventory2';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import { api } from '../../services/api';
export const SuppliersView: React.FC = () => {
const [suppliers, setSuppliers] = useState<any[]>([]);
const [purchaseOrders, setPurchaseOrders] = useState<any[]>([]);
const [ingredients, setIngredients] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Supplier Form
const [openSuppForm, setOpenSuppForm] = useState<boolean>(false);
const [suppName, setSuppName] = useState<string>('');
const [suppPhone, setSuppPhone] = useState<string>('');
const [suppEmail, setSuppEmail] = useState<string>('');
const [suppAddress, setSuppAddress] = useState<string>('');
// Purchase Order Form
const [openPoForm, setOpenPoForm] = useState<boolean>(false);
const [selectedSupplierId, setSelectedSupplierId] = useState<string>('');
// Backend schema: { ingredient_id, quantity, unit_price }
const [poItems, setPoItems] = useState<{
price: any;
qty: any; ingredient_id: number; quantity: number; unit_price: number
}[]>([]);
// Adding single item to PO
const [addIngId, setAddIngId] = useState<string>('');
const [addIngQty, setAddIngQty] = useState<number>(10);
const [addIngPrice, setAddIngPrice] = useState<number>(100);
const loadData = async () => {
setLoading(true);
setError(null);
try {
const suppliersData = await api.getSuppliers();
setSuppliers(Array.isArray(suppliersData) ? suppliersData : []);
const poData = await api.getPurchaseOrders();
setPurchaseOrders(Array.isArray(poData) ? poData : []);
const ingredientsData = await api.getIngredients();
setIngredients(Array.isArray(ingredientsData) ? ingredientsData : []);
} catch (err: any) {
setError(err.message || 'Failed to load suppliers/PO data');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleCreateSupplier = async () => {
setError(null);
try {
// Backend schema: { name, contact_phone?, email? } — no address field
const res = await api.createSupplier({
name: suppName,
contact_phone: suppPhone || undefined,
email: suppEmail || undefined,
});
setSuppliers(prev => [...prev, res]);
setOpenSuppForm(false);
setSuppName('');
setSuppPhone('');
setSuppEmail('');
setSuppAddress('');
} catch (err: any) {
setError(err.message || 'Failed to create supplier');
}
};
const handleDeleteSupplier = async (id: string) => {
if (!window.confirm('Delete this supplier?')) return;
try {
await api.deleteSupplier(id);
setSuppliers(prev => prev.filter(s => s.id !== id));
} catch (err: any) {
alert(err.message || 'Failed to delete supplier');
}
};
const handleAddPoItem = () => {
if (!addIngId || addIngQty <= 0 || addIngPrice <= 0) return;
if (poItems.some(i => i.ingredient_id === Number(addIngId))) {
alert('Ingredient already added to order list!');
return;
}
// Backend schema: { ingredient_id, quantity, unit_price }
setPoItems(prev => [...prev, { ingredient_id: Number(addIngId), quantity: addIngQty, unit_price: addIngPrice }]);
setAddIngId('');
};
const handleRemovePoItem = (ingId: number) => {
setPoItems(prev => prev.filter(item => item.ingredient_id !== ingId));
};
const handleCreatePurchaseOrder = async () => {
if (!selectedSupplierId || poItems.length === 0) {
alert('Choose supplier and add at least 1 item.');
return;
}
setError(null);
try {
// Backend schema: supplier_id (int), items: [{ingredient_id, quantity, unit_price}]
const po = await api.createPurchaseOrder(Number(selectedSupplierId), poItems);
setPurchaseOrders(prev => [po, ...prev]);
setOpenPoForm(false);
setSelectedSupplierId('');
setPoItems([]);
} catch (err: any) {
setError(err.message || 'Failed to create purchase order');
}
};
const handleUpdatePOStatus = async (id: string, status: 'draft' | 'sent' | 'received' | 'cancelled') => {
try {
const updated = await api.updatePurchaseOrderStatus(id, status);
setPurchaseOrders(prev => prev.map(p => p.id === id ? updated : p));
if (status === 'received') {
alert('Stock received and inventory levels increased!');
}
} catch (err: any) {
alert(err.message || 'Failed to update PO status');
}
};
const getPoStatusColor = (status: string) => {
switch (status) {
case 'draft':
return 'default';
case 'sent':
return 'info';
case 'received':
return 'success';
case 'cancelled':
return 'error';
default:
return 'default';
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Suppliers & Purchase Orders (PO)
</Typography>
<Typography variant="body2" color="text.secondary">
Manage raw ingredient merchants and draft/dispatch purchase orders. Receiving POs bumps stock levels.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
onClick={() => setOpenPoForm(true)}
startIcon={<LocalShippingIcon />}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Create PO
</Button>
<Button
variant="outlined"
onClick={() => setOpenSuppForm(true)}
startIcon={<AddIcon />}
sx={{ borderColor: '#ac2d00', color: '#ac2d00', '&:hover': { borderColor: '#872100', bgcolor: 'rgba(172,45,0,0.04)' } }}
>
Add Merchant
</Button>
<Button
variant="outlined"
onClick={loadData}
startIcon={<RefreshIcon />}
sx={{ borderColor: '#ac2d00', color: '#ac2d00', '&:hover': { borderColor: '#872100', bgcolor: 'rgba(172,45,0,0.04)' } }}
>
Refresh
</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
) : (
<Grid container spacing={3}>
{/* Left panel: Suppliers List */}
<Grid size={{ xs: 12, md: 5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Supplier Merchants List
</Typography>
<TableContainer component={Paper} elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px' }}>
<Table size="small">
<TableHead sx={{ bgcolor: '#fbfbfb' }}>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>Name</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Contact</TableCell>
<TableCell sx={{ fontWeight: 800 }} align="right">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{suppliers.map((supp) => (
<TableRow key={supp.id}>
<TableCell sx={{ fontWeight: 700 }}>{supp.name}</TableCell>
<TableCell sx={{ fontSize: '0.8rem' }}>
📞 {supp.contact_phone || supp.phone || 'N/A'}<br /> {supp.email || 'N/A'}
</TableCell>
<TableCell align="right">
<IconButton size="small" color="error" onClick={() => handleDeleteSupplier(supp.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
{/* Right panel: Purchase Orders List */}
<Grid size={{ xs: 12, md: 7 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>
Purchase Orders Ledger
</Typography>
{purchaseOrders.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1.5px dashed #e2e2e2', borderRadius: '16px' }}>
<Typography variant="body2" color="text.secondary">
No purchase orders placed yet.
</Typography>
</Paper>
) : (
<Grid container spacing={2}>
{purchaseOrders.map((po) => {
const merchant = suppliers.find(s => s.id === (po.supplier_id || po.supplierId));
const total = po.items?.reduce((acc: number, item: any) => acc + (Number(item.unit_price || item.price || 0) * Number(item.quantity || item.qty || 0)), 0) || 0;
return (
<Grid size={{ xs: 12 }} key={po.id}>
<Card elevation={0} sx={{ border: '1.5px solid #e2e2e2', borderRadius: '16px' }}>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography sx={{ fontWeight: 900 }}>
PO #{po.id}
</Typography>
<Chip
label={po.status.toUpperCase()}
color={getPoStatusColor(po.status) as any}
size="small"
sx={{ fontWeight: 800 }}
/>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Merchant: {merchant ? merchant.name : 'Unknown Merchant'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Items Ordered: {po.items?.map((it: any) => {
const ingId = it.ingredient_id || it.ingredientId;
const ingredient = ingredients.find((ing: any) => ing.id === ingId);
return `${it.quantity || it.qty}x ${ingredient ? ingredient.name : `Ing#${ingId}`}`;
}).join(', ')}
</Typography>
<Typography variant="subtitle2" sx={{ mt: 1.5, fontWeight: 800, color: '#ac2d00' }}>
Total Estimate: {total.toFixed(2)}
</Typography>
</CardContent>
<Box sx={{ p: 2, pt: 0, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{po.status === 'draft' && (
<Button
variant="contained"
size="small"
startIcon={<SendIcon />}
onClick={() => handleUpdatePOStatus(po.id, 'sent')}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Dispatch PO
</Button>
)}
{po.status === 'sent' && (
<>
<Button
variant="contained"
size="small"
color="success"
startIcon={<Inventory2Icon />}
onClick={() => handleUpdatePOStatus(po.id, 'received')}
sx={{ fontWeight: 700 }}
>
Stock Received
</Button>
<Button
variant="outlined"
size="small"
color="error"
onClick={() => handleUpdatePOStatus(po.id, 'cancelled')}
sx={{ fontWeight: 700 }}
>
Cancel Order
</Button>
</>
)}
</Box>
</Card>
</Grid>
);
})}
</Grid>
)}
</Grid>
</Grid>
)}
{/* Supplier Merchant Dialog */}
<Dialog open={openSuppForm} onClose={() => setOpenSuppForm(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800 }}>Add Supplier Merchant</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Merchant / Supplier Name"
value={suppName}
onChange={(e) => setSuppName(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Contact Phone"
value={suppPhone}
onChange={(e) => setSuppPhone(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Merchant Email"
value={suppEmail}
onChange={(e) => setSuppEmail(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Street Address"
value={suppAddress}
onChange={(e) => setSuppAddress(e.target.value)}
fullWidth
size="small"
multiline
rows={2}
/>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpenSuppForm(false)} color="inherit">Cancel</Button>
<Button
variant="contained"
onClick={handleCreateSupplier}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Add Merchant
</Button>
</DialogActions>
</Dialog>
{/* Create PO Dialog */}
<Dialog open={openPoForm} onClose={() => setOpenPoForm(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 800 }}>Draft Purchase Order</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<FormControl fullWidth size="small">
<InputLabel>Select Merchant</InputLabel>
<Select
value={selectedSupplierId}
label="Select Merchant"
onChange={(e) => setSelectedSupplierId(e.target.value)}
>
{suppliers.map(s => (
<MenuItem key={s.id} value={s.id}>{s.name}</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: -1.5 }}>
PO Items list:
</Typography>
{poItems.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontStyle: 'italic' }}>
Add raw ingredients to the order list below.
</Typography>
) : (
<List disablePadding>
{poItems.map(item => {
const detail = ingredients.find(ing => ing.id === item.ingredient_id);
return (
<ListItem
key={item.ingredient_id}
secondaryAction={
<IconButton size="small" color="error" onClick={() => handleRemovePoItem(item.ingredient_id)}>
<DeleteIcon fontSize="small" />
</IconButton>
}
sx={{ px: 0, py: 0.5 }}
>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{detail ? detail.name : `Ingredient ID: ${item.ingredient_id}`}
</Typography>
}
secondary={`Quantity: ${item.qty} ${detail ? detail.unit : 'kg'} @ Est. Price ₹${item.price}/unit`}
/>
</ListItem>
);
})}
</List>
)}
<Divider />
<Typography variant="caption" sx={{ fontWeight: 800, color: 'text.secondary', display: 'block', mb: -1.5 }}>
ADD ITEM TO ORDER:
</Typography>
<Grid container spacing={1} sx={{ alignItems: 'center' }}>
<Grid size={{ xs: 6 }}>
<FormControl fullWidth size="small">
<Select
value={addIngId}
displayEmpty
onChange={(e) => setAddIngId(e.target.value)}
>
<MenuItem value="">Choose stock item...</MenuItem>
{ingredients.map(ing => (
<MenuItem key={ing.id} value={ing.id}>{ing.name}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 3 }}>
<TextField
type="number"
size="small"
label="Qty"
value={addIngQty}
onChange={(e) => setAddIngQty(parseFloat(e.target.value) || 0)}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<TextField
type="number"
size="small"
label="Price"
value={addIngPrice}
onChange={(e) => setAddIngPrice(parseFloat(e.target.value) || 0)}
/>
</Grid>
</Grid>
<Button
variant="outlined"
size="small"
onClick={handleAddPoItem}
sx={{ alignSelf: 'flex-end', borderColor: '#ac2d00', color: '#ac2d00', fontWeight: 700 }}
>
Add to List
</Button>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpenPoForm(false)} color="inherit">Cancel</Button>
<Button
variant="contained"
onClick={handleCreatePurchaseOrder}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Save Draft PO
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@ -0,0 +1,410 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardContent,
CardActions,
Button,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
IconButton,
Alert,
} from '@mui/material';
import TableBarIcon from '@mui/icons-material/TableBar';
import QrCodeIcon from '@mui/icons-material/QrCode';
import RefreshIcon from '@mui/icons-material/Refresh';
import CloseIcon from '@mui/icons-material/Close';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import { api } from '../../services/api';
export const TableManagementView: React.FC = () => {
const [tables, setTables] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [selectedTable, setSelectedTable] = useState<any | null>(null);
const [startSessionOpen, setStartSessionOpen] = useState<boolean>(false);
const [sessionLang, setSessionLang] = useState<string>('en');
// Create Table Form
const [createTableOpen, setCreateTableOpen] = useState<boolean>(false);
const [newTableNumber, setNewTableNumber] = useState<string>('');
const [newTableCapacity, setNewTableCapacity] = useState<number>(4);
const fetchTables = async () => {
setLoading(true);
setError(null);
try {
const data = await api.getTables();
setTables(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || 'Failed to fetch tables');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTables();
}, []);
const handleCloseSession = async (tableId: number) => {
try {
await api.closeSession(tableId);
fetchTables();
setSelectedTable(null);
} catch (err: any) {
alert(err.message || 'Failed to close session');
}
};
const handleResetTable = async (tableId: number) => {
try {
await api.resetTable(tableId);
fetchTables();
setSelectedTable(null);
} catch (err: any) {
alert(err.message || 'Failed to reset table');
}
};
const handleStartSession = async () => {
if (!selectedTable) return;
try {
await api.startSession(selectedTable.id, sessionLang);
setStartSessionOpen(false);
fetchTables();
setSelectedTable(null);
} catch (err: any) {
alert(err.message || 'Failed to start session');
}
};
const handleCreateTable = async () => {
if (!newTableNumber.trim()) {
alert('Please enter a table number');
return;
}
try {
await api.createTable(newTableNumber.trim(), newTableCapacity);
setCreateTableOpen(false);
setNewTableNumber('');
setNewTableCapacity(4);
fetchTables();
} catch (err: any) {
alert(err.message || 'Failed to create table');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'available':
return 'success';
case 'occupied':
return 'error';
case 'awaiting_clearance':
return 'warning';
case 'reserved':
return 'primary';
default:
return 'default';
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Table & Floor Management
</Typography>
<Typography variant="body2" color="text.secondary">
Monitor real-time table occupancy, QR scan sessions, and customer lifecycle.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
onClick={() => setCreateTableOpen(true)}
startIcon={<TableBarIcon />}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Add Table
</Button>
<Button
variant="outlined"
onClick={fetchTables}
startIcon={<RefreshIcon />}
sx={{ borderColor: '#ac2d00', color: '#ac2d00', '&:hover': { borderColor: '#872100', bgcolor: 'rgba(172,45,0,0.04)' } }}
>
Refresh Grid
</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress color="primary" />
</Box>
) : (
<Grid container spacing={3}>
{tables.map((table) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={table.id}>
<Card
elevation={0}
sx={{
border: '1.5px solid',
borderColor: table.status === 'occupied' ? '#ffdbd1' : '#e2e2e2',
borderRadius: '16px',
bgcolor: table.status === 'occupied' ? '#fffbfa' : '#ffffff',
transition: 'all 0.2s',
'&:hover': { boxShadow: '0 6px 18px rgba(0,0,0,0.05)', transform: 'translateY(-2px)' },
}}
>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box
sx={{
p: 1,
borderRadius: '8px',
bgcolor: table.status === 'occupied' ? '#ac2d00' : '#f2ede9',
color: table.status === 'occupied' ? '#ffffff' : '#8f7068',
}}
>
<TableBarIcon />
</Box>
<Chip
label={table.status.toUpperCase().replace('_', ' ')}
color={getStatusColor(table.status) as any}
size="small"
sx={{ fontWeight: 800 }}
/>
</Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>
Table {table.table_number || table.number || table.id}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
ID: {table.id} &bull; Capacity: {table.capacity || 'N/A'}
</Typography>
{table.status === 'occupied' && (
<Typography variant="body2" sx={{ mt: 1.5, fontWeight: 600, color: '#ac2d00' }}>
Session Active
</Typography>
)}
</CardContent>
<CardActions sx={{ p: 2, pt: 0, justifyContent: 'flex-end', gap: 1 }}>
<IconButton
size="small"
title="View QR Code"
onClick={() => {
setSelectedTable(table);
}}
sx={{ color: '#ac2d00', bgcolor: '#ffdbd1', '&:hover': { bgcolor: '#ffcab8' } }}
>
<QrCodeIcon fontSize="small" />
</IconButton>
{table.status === 'available' && (
<Button
variant="contained"
size="small"
startIcon={<PlayArrowIcon />}
onClick={() => {
setSelectedTable(table);
setStartSessionOpen(true);
}}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Start Session
</Button>
)}
{table.status === 'occupied' && (
<Button
variant="outlined"
size="small"
color="error"
onClick={() => handleCloseSession(table.id)}
sx={{ fontWeight: 700 }}
>
Close Session
</Button>
)}
{table.status === 'awaiting_clearance' && (
<Button
variant="contained"
size="small"
color="warning"
startIcon={<CleaningServicesIcon />}
onClick={() => handleResetTable(table.id)}
sx={{ fontWeight: 700, color: '#ffffff' }}
>
Cleared
</Button>
)}
</CardActions>
</Card>
</Grid>
))}
</Grid>
)}
{/* QR Code Dialog */}
<Dialog open={!!selectedTable && !startSessionOpen} onClose={() => setSelectedTable(null)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography sx={{ fontWeight: 800 }}>Table {selectedTable?.number} QR Scan Link</Typography>
<IconButton onClick={() => setSelectedTable(null)}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ textAlign: 'center', py: 4 }}>
<Box
sx={{
display: 'inline-flex',
p: 2,
borderRadius: '16px',
bgcolor: '#f5f5f5',
border: '1px solid #e0e0e0',
mb: 2,
}}
>
{/* Generate mock QR UI since real image might be stubbed */}
<Box
sx={{
width: 180,
height: 180,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '4px solid #1a1c1c',
borderRadius: '8px',
bgcolor: '#ffffff',
position: 'relative',
}}
>
<QrCodeIcon sx={{ fontSize: 130, color: '#1a1c1c' }} />
<Box
sx={{
position: 'absolute',
px: 1,
py: 0.2,
bgcolor: '#ac2d00',
color: '#ffffff',
borderRadius: '4px',
fontWeight: 900,
fontSize: '0.75rem',
}}
>
TABLE {selectedTable?.number}
</Box>
</Box>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ breakWord: 'break-all', px: 2 }}>
Link: <strong>{selectedTable?.qr_codes?.[0]?.encoded_url || `http://localhost:3000/scan/${selectedTable?.id}`}</strong>
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setSelectedTable(null)} color="inherit">Close</Button>
<Button
variant="contained"
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' } }}
onClick={() => {
navigator.clipboard.writeText(
selectedTable?.qr_codes?.[0]?.encoded_url || `http://localhost:3000/scan/${selectedTable?.id}`
);
alert('Scan URL copied to clipboard!');
}}
>
Copy URL
</Button>
</DialogActions>
</Dialog>
{/* Start Session Dialog */}
<Dialog open={startSessionOpen} onClose={() => setStartSessionOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800 }}>Start Table Session</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Manually trigger and open a customer dine-in session for Table {selectedTable?.number}.
</Typography>
<FormControl fullWidth size="small">
<InputLabel>Preferred Language</InputLabel>
<Select
value={sessionLang}
label="Preferred Language"
onChange={(e) => setSessionLang(e.target.value)}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="hi">Hindi</MenuItem>
<MenuItem value="hinglish">Hinglish</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setStartSessionOpen(false)} color="inherit">Cancel</Button>
<Button
variant="contained"
onClick={handleStartSession}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' } }}
>
Activate Session
</Button>
</DialogActions>
</Dialog>
{/* Create Table Dialog */}
<Dialog open={createTableOpen} onClose={() => setCreateTableOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800 }}>Add New Table</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl fullWidth size="small">
<InputLabel shrink>Table Number / Label</InputLabel>
<input
style={{ marginTop: 16, padding: '8px 12px', border: '1px solid #e0e0e0', borderRadius: 4, fontSize: 14 }}
placeholder='e.g. 12, A1, Terrace-3'
value={newTableNumber}
onChange={(e) => setNewTableNumber(e.target.value)}
/>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>Seating Capacity</InputLabel>
<Select
value={newTableCapacity}
label="Seating Capacity"
onChange={(e) => setNewTableCapacity(Number(e.target.value))}
>
{[2, 4, 6, 8, 10, 12].map(cap => (
<MenuItem key={cap} value={cap}>{cap} seats</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setCreateTableOpen(false)} color="inherit">Cancel</Button>
<Button
variant="contained"
onClick={handleCreateTable}
sx={{ bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' }, fontWeight: 700 }}
>
Create Table
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@ -0,0 +1,344 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Grid,
Paper,
Typography,
Card,
CardContent,
Chip,
Button,
ButtonGroup,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Avatar,
Divider,
} from '@mui/material';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
import AttachMoneyIcon from '@mui/icons-material/AttachMoney';
import ShoppingBagIcon from '@mui/icons-material/ShoppingBag';
import SpeedIcon from '@mui/icons-material/Speed';
import TableBarIcon from '@mui/icons-material/TableBar';
import StarIcon from '@mui/icons-material/Star';
import { StatusBadge } from '../common/StatusBadge';
import { api } from '../../services/api';
export const AnalyticsDashboard: React.FC = () => {
const [period, setPeriod] = useState<'today' | 'week' | 'month'>('today');
const [topDishes, setTopDishes] = useState<any[]>([]);
const [revenueTrend, setRevenueTrend] = useState<any[]>([]);
const fetchAnalytics = async () => {
try {
const topData = await api.getTopItems();
setTopDishes(Array.isArray(topData) ? topData : []);
const trendData = await api.getRevenueTrend();
setRevenueTrend(Array.isArray(trendData) ? trendData : []);
} catch (e) {
console.error(e);
}
};
useEffect(() => {
fetchAnalytics();
}, [period]);
const metrics = [
{
title: 'Total Revenue',
value: '₹14,852.40',
change: '+18.4%',
isPositive: true,
icon: <AttachMoneyIcon sx={{ color: '#11651d' }} />,
bg: '#e8f5e9',
},
{
title: 'Total Orders',
value: '482',
change: '+12.1%',
isPositive: true,
icon: <ShoppingBagIcon sx={{ color: '#ac2d00' }} />,
bg: '#ffdbd1',
},
{
title: 'Avg Kitchen Speed',
value: '11m 42s',
change: '-1m 15s faster',
isPositive: true,
icon: <SpeedIcon sx={{ color: '#00a6e0' }} />,
bg: '#c4e7ff',
},
{
title: 'Table Occupancy',
value: '84%',
change: '21/25 Tables Active',
isPositive: true,
icon: <TableBarIcon sx={{ color: '#845000' }} />,
bg: '#ffddba',
},
];
const finalTopDishes = topDishes.length > 0 ? topDishes.map((d, index) => ({
id: d.id || String(index + 1),
name: d.name,
category: d.category || 'Mains',
count: d.ordersCount,
revenue: `${d.revenue}`,
dietary: d.dietary || 'veg'
})) : [
{ id: '1', name: 'Butter Chicken Special', category: 'Mains', count: 142, revenue: '₹51,120', dietary: 'non-veg' as const },
{ id: '2', name: 'Paneer Tikka Masala', category: 'Mains', count: 118, revenue: '₹33,040', dietary: 'veg' as const },
{ id: '3', name: 'Truffle Garlic Naan', category: 'Breads', count: 215, revenue: '₹17,200', dietary: 'veg' as const },
{ id: '4', name: 'Dal Makhani Gold', category: 'Mains', count: 96, revenue: '₹28,800', dietary: 'veg' as const },
{ id: '5', name: 'Mango Lassi Craft', category: 'Beverages', count: 180, revenue: '₹21,600', dietary: 'veg' as const },
];
const hourlyData = revenueTrend.length > 0 ? revenueTrend.map((t) => ({
hour: t.date || t.hour || 'Day',
orders: t.ordersCount || 10,
revenue: t.revenue || 500
})) : [
{ hour: '11 AM', orders: 24, revenue: 680 },
{ hour: '12 PM', orders: 68, revenue: 1950 },
{ hour: '1 PM', orders: 85, revenue: 2480 },
{ hour: '2 PM', orders: 42, revenue: 1120 },
{ hour: '3 PM', orders: 18, revenue: 450 },
{ hour: '4 PM', orders: 15, revenue: 380 },
{ hour: '5 PM', orders: 32, revenue: 890 },
{ hour: '6 PM', orders: 74, revenue: 2100 },
{ hour: '7 PM', orders: 92, revenue: 2850 },
{ hour: '8 PM', orders: 88, revenue: 2640 },
{ hour: '9 PM', orders: 45, revenue: 1250 },
];
const maxRevenue = Math.max(...hourlyData.map((d) => d.revenue));
return (
<Box sx={{ p: { xs: 2, md: 3 } }}>
{/* Analytics Header */}
<Box sx={{ mb: 3, display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#ac2d00' }}>
Business Intelligence & Analytics
</Typography>
<Typography variant="body2" sx={{ color: '#5b4139' }}>
Real-time outlet performance, kitchen efficiency, and dish profitability metrics.
</Typography>
</Box>
<ButtonGroup variant="outlined" size="small">
{(['today', 'week', 'month'] as const).map((p) => (
<Button
key={p}
variant={period === p ? 'contained' : 'outlined'}
onClick={() => setPeriod(p)}
sx={{
fontWeight: 700,
color: period === p ? '#ffffff' : '#ac2d00',
borderColor: '#ac2d00',
}}
>
{p.toUpperCase()}
</Button>
))}
</ButtonGroup>
</Box>
{/* KPI Cards */}
<Grid container spacing={2.5} sx={{ mb: 3 }}>
{metrics.map((m, idx) => (
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={idx}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#546067' }}>
{m.title}
</Typography>
<Avatar sx={{ bgcolor: m.bg, width: 40, height: 40 }}>{m.icon}</Avatar>
</Box>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
{m.value}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{m.isPositive ? (
<TrendingUpIcon fontSize="small" sx={{ color: '#11651d' }} />
) : (
<TrendingDownIcon fontSize="small" sx={{ color: '#ba1a1a' }} />
)}
<Typography
variant="caption"
sx={{
fontWeight: 800,
color: m.isPositive ? '#11651d' : '#ba1a1a',
}}
>
{m.change}
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068', ml: 0.5 }}>
vs previous {period}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* Revenue Trends Chart & Hourly Volume */}
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid size={{ xs: 12, md: 8 }}>
<Paper elevation={0} sx={{ p: 3, border: '1px solid #e2e2e2', borderRadius: '12px' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Hourly Revenue & Peak Order Trends
</Typography>
<Chip label="Peak Lunch: 1 PM • Peak Dinner: 7 PM" size="small" color="primary" sx={{ fontWeight: 700 }} />
</Box>
{/* Custom SVG Bar Chart */}
<Box sx={{ height: 260, display: 'flex', alignItems: 'flex-end', gap: 1.5, pt: 4, pb: 1, px: 1 }}>
{hourlyData.map((d, index) => {
const heightPercent = (d.revenue / maxRevenue) * 100;
return (
<Box
key={index}
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
height: '100%',
justifyContent: 'flex-end',
gap: 1,
}}
>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#ac2d00' }}>
${d.revenue}
</Typography>
<Box
sx={{
width: '100%',
height: `${heightPercent}%`,
background: 'linear-gradient(180deg, #ac2d00 0%, #d53e0b 100%)',
borderRadius: '6px 6px 0 0',
transition: 'height 0.3s ease',
'&:hover': {
background: '#872100',
},
}}
/>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, color: '#546067' }}>
{d.hour}
</Typography>
</Box>
);
})}
</Box>
</Paper>
</Grid>
{/* Operational Speed SLA Breakdown */}
<Grid size={{ xs: 12, md: 4 }}>
<Paper elevation={0} sx={{ p: 3, border: '1px solid #e2e2e2', borderRadius: '12px', height: '100%' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 2 }}>
Kitchen SLA Compliance
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Under 15 Mins (Target SLA)</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#11651d' }}>88.4%</Typography>
</Box>
<Box sx={{ height: 10, bgcolor: '#e2e2e2', borderRadius: 5, overflow: 'hidden' }}>
<Box sx={{ width: '88.4%', height: '100%', bgcolor: '#11651d' }} />
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>15 - 25 Mins (Warning SLA)</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#845000' }}>9.2%</Typography>
</Box>
<Box sx={{ height: 10, bgcolor: '#e2e2e2', borderRadius: 5, overflow: 'hidden' }}>
<Box sx={{ width: '9.2%', height: '100%', bgcolor: '#845000' }} />
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Over 25 Mins (Breached SLA)</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ba1a1a' }}>2.4%</Typography>
</Box>
<Box sx={{ height: 10, bgcolor: '#e2e2e2', borderRadius: 5, overflow: 'hidden' }}>
<Box sx={{ width: '2.4%', height: '100%', bgcolor: '#ba1a1a' }} />
</Box>
</Box>
<Divider sx={{ my: 1 }} />
<Box sx={{ p: 2, bgcolor: '#f3f3f3', borderRadius: '8px' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#872100', display: 'block' }}>
💡 Operational Tip
</Typography>
<Typography variant="caption" sx={{ color: '#5b4139' }}>
Truffle Garlic Naan orders spiked at 7 PM. Assigning 1 extra tandoor chef during peak hours will reduce SLA breaches by 1.8%.
</Typography>
</Box>
</Box>
</Paper>
</Grid>
</Grid>
{/* Top Dishes Leaderboard */}
<Paper elevation={0} sx={{ p: 3, border: '1px solid #e2e2e2', borderRadius: '12px' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 2 }}>
Top Performing Dishes Leaderboard
</Typography>
<TableContainer>
<Table size="medium">
<TableHead sx={{ bgcolor: '#f0eded' }}>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>DISH NAME</TableCell>
<TableCell sx={{ fontWeight: 800 }}>CATEGORY</TableCell>
<TableCell sx={{ fontWeight: 800 }}>DIETARY</TableCell>
<TableCell sx={{ fontWeight: 800 }} align="right">UNITS SOLD</TableCell>
<TableCell sx={{ fontWeight: 800 }} align="right">GROSS REVENUE</TableCell>
</TableRow>
</TableHead>
<TableBody>
{finalTopDishes.map((dish) => (
<TableRow key={dish.id} hover>
<TableCell sx={{ fontWeight: 700 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<StarIcon sx={{ color: '#feb300', fontSize: 18 }} />
{dish.name}
</Box>
</TableCell>
<TableCell>{dish.category}</TableCell>
<TableCell>
<StatusBadge dietary={dish.dietary} />
</TableCell>
<TableCell align="right" sx={{ fontWeight: 800, fontFamily: 'monospace' }}>
{dish.count}
</TableCell>
<TableCell align="right" sx={{ fontWeight: 800, color: '#ac2d00' }}>
{dish.revenue}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
);
};

View File

@ -0,0 +1,232 @@
import React, { useState } from 'react';
import {
AppBar,
Toolbar,
Typography,
Button,
IconButton,
Box,
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Chip,
Tooltip,
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import SoupKitchenIcon from '@mui/icons-material/SoupKitchen';
import BarChartIcon from '@mui/icons-material/BarChart';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import TableRestaurantIcon from '@mui/icons-material/TableRestaurant';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import VolumeOffIcon from '@mui/icons-material/VolumeOff';
import LocalDiningIcon from '@mui/icons-material/LocalDining';
interface NavigationProps {
activeTab: string;
onTabChange: (tab: string) => void;
pendingCount: number;
soundEnabled: boolean;
onToggleSound: () => void;
}
export const Navigation: React.FC<NavigationProps> = ({
activeTab,
onTabChange,
pendingCount,
soundEnabled,
onToggleSound,
}) => {
const [drawerOpen, setDrawerOpen] = useState(false);
const menuItems = [
{
id: 'kds',
label: 'Kitchen Display (KDS)',
icon: <SoupKitchenIcon />,
badge: pendingCount,
},
{
id: 'analytics',
label: 'Business Analytics',
icon: <BarChartIcon />,
},
{
id: 'voice',
label: 'Voice Assistant',
icon: <RecordVoiceOverIcon />,
},
{
id: 'table',
label: 'Table 12 Hub',
icon: <TableRestaurantIcon />,
},
];
return (
<>
<AppBar position="sticky" elevation={0}>
<Toolbar sx={{ justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<IconButton
edge="start"
color="inherit"
onClick={() => setDrawerOpen(true)}
sx={{ display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Box
sx={{
width: 36,
height: 36,
borderRadius: '8px',
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
boxShadow: '0 2px 8px rgba(172, 45, 0, 0.3)',
}}
>
<LocalDiningIcon fontSize="small" />
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 800,
color: '#ac2d00',
display: 'flex',
alignItems: 'center',
gap: 0.8,
}}
>
RestroAI
<Chip
label="OPS SUITE"
size="small"
sx={{
height: 18,
fontSize: '0.6rem',
fontWeight: 800,
backgroundColor: '#ffdbd1',
color: '#872100',
}}
/>
</Typography>
</Box>
{/* Desktop Nav Items */}
<Box sx={{ display: { xs: 'none', sm: 'flex' }, gap: 1 }}>
{menuItems.map((item) => {
const isActive = activeTab === item.id;
return (
<Button
key={item.id}
onClick={() => onTabChange(item.id)}
startIcon={item.icon}
variant={isActive ? 'contained' : 'text'}
color={isActive ? 'primary' : 'inherit'}
sx={{
fontWeight: isActive ? 700 : 500,
borderRadius: '8px',
px: 2,
color: isActive ? '#ffffff' : '#546067',
}}
>
{item.label}
{item.badge !== undefined && item.badge > 0 && (
<Chip
label={item.badge}
size="small"
color="error"
sx={{
ml: 1,
height: 18,
fontSize: '0.65rem',
fontWeight: 800,
}}
/>
)}
</Button>
);
})}
</Box>
{/* Settings & Tools */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title={soundEnabled ? 'Kitchen Sound Alerts Enabled' : 'Kitchen Sound Alert Muted'}>
<IconButton onClick={onToggleSound} color={soundEnabled ? 'primary' : 'default'}>
{soundEnabled ? <VolumeUpIcon /> : <VolumeOffIcon />}
</IconButton>
</Tooltip>
<Box
sx={{
px: 1.5,
py: 0.5,
borderRadius: '6px',
backgroundColor: '#f0eded',
display: { xs: 'none', md: 'flex' },
alignItems: 'center',
gap: 1,
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: '#11651d',
}}
/>
<Typography variant="caption" sx={{ color: '#1a1c1c', fontWeight: 600 }}>
Outlet: Downtown Main
</Typography>
</Box>
</Box>
</Toolbar>
</AppBar>
{/* Mobile Drawer */}
<Drawer anchor="left" open={drawerOpen} onClose={() => setDrawerOpen(false)}>
<Box sx={{ width: 260, pt: 2 }} role="presentation">
<Typography variant="h6" sx={{ px: 2, pb: 1, fontWeight: 800, color: '#ac2d00' }}>
RestroAI Navigation
</Typography>
<List>
{menuItems.map((item) => (
<ListItem key={item.id} disablePadding>
<ListItemButton
selected={activeTab === item.id}
onClick={() => {
onTabChange(item.id);
setDrawerOpen(false);
}}
>
<ListItemIcon sx={{ color: activeTab === item.id ? '#ac2d00' : 'inherit' }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={
<Typography sx={{ fontWeight: activeTab === item.id ? 700 : 500 }}>
{item.label}
</Typography>
}
/>
{item.badge !== undefined && item.badge > 0 && (
<Chip label={item.badge} color="error" size="small" />
)}
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Drawer>
</>
);
};

View File

@ -0,0 +1,102 @@
import React from 'react';
import { Chip, Box, Typography } from '@mui/material';
import type { OrderStatus, OrderType } from '../../types';
interface StatusBadgeProps {
status?: OrderStatus;
type?: OrderType;
dietary?: 'veg' | 'non-veg' | 'jain' | 'vegan';
size?: 'small' | 'medium';
}
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type, dietary, size = 'small' }) => {
if (dietary) {
const config = {
veg: { label: 'VEG', color: '#2e7d32', bg: '#e8f5e9', border: '#2e7d32' },
'non-veg': { label: 'NON-VEG', color: '#c62828', bg: '#ffebee', border: '#c62828' },
jain: { label: 'JAIN', color: '#ef6c00', bg: '#fff3e0', border: '#ef6c00' },
vegan: { label: 'VEGAN', color: '#1565c0', bg: '#e3f2fd', border: '#1565c0' },
}[dietary];
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
border: `1px solid ${config.border}`,
borderRadius: '4px',
px: 0.6,
py: 0.2,
backgroundColor: config.bg,
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: config.color,
}}
/>
<Typography
variant="caption"
sx={{
color: config.color,
fontSize: '0.65rem',
fontWeight: 700,
letterSpacing: '0.05em',
}}
>
{config.label}
</Typography>
</Box>
);
}
if (status) {
const statusMap = {
pending: { label: 'PENDING', color: 'error', bg: '#ffdad6' },
preparing: { label: 'PREPARING', color: 'warning', bg: '#ffddba' },
ready: { label: 'READY', color: 'success', bg: '#a3f69c' },
served: { label: 'SERVED', color: 'default', bg: '#eeeeee' },
}[status];
return (
<Chip
label={statusMap.label}
size={size}
sx={{
fontWeight: 700,
fontSize: '0.7rem',
height: size === 'small' ? 22 : 28,
}}
color={statusMap.color as any}
/>
);
}
if (type) {
const typeMap = {
'Dine-In': { label: 'DINE-IN', color: '#ac2d00', bg: '#ffdbd1' },
Takeaway: { label: 'TAKEAWAY', color: '#546067', bg: '#d7e4ec' },
Delivery: { label: 'DELIVERY', color: '#845000', bg: '#ffddba' },
}[type];
return (
<Chip
label={typeMap.label}
size={size}
sx={{
fontWeight: 700,
fontSize: '0.7rem',
height: size === 'small' ? 22 : 28,
backgroundColor: typeMap.bg,
color: typeMap.color,
}}
/>
);
}
return null;
};

View File

@ -0,0 +1,302 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Typography,
Paper,
Button,
Chip,
Divider,
Alert,
Grid,
} from '@mui/material';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import QrCode2Icon from '@mui/icons-material/QrCode2';
import PersonIcon from '@mui/icons-material/Person';
import CreditCardIcon from '@mui/icons-material/CreditCard';
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
import { api } from '../../services/api';
interface BillPaymentViewProps {
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
}
export const BillPaymentView: React.FC<BillPaymentViewProps> = ({ onNavigate }) => {
const [splitCount, setSplitCount] = useState<number>(1);
const [tipPercentage, setTipPercentage] = useState<number>(10);
const [paymentDone, setPaymentDone] = useState<boolean>(false);
const [paymentMethod, setPaymentMethod] = useState<'upi' | 'card' | 'cash' | null>(null);
const [billItems, setBillItems] = useState<any[]>([]);
const [ticketNum, setTicketNum] = useState<string>('---');
const [tableNum, setTableNum] = useState<string>('12');
const [billId, setBillId] = useState<string>('');
const sessionId = sessionStorage.getItem('customer_session_id') || '112';
const loadBill = async () => {
try {
const order = await api.getOrderForSession(sessionId);
if (order) {
setTicketNum(order.ticketNumber || '105');
setTableNum(order.tableNumber || sessionStorage.getItem('customer_table_id') || '12');
setBillItems(order.items.map((it: any) => ({
name: it.name,
qty: it.quantity,
price: it.price || 150
})));
const bill = await api.generateBill(order.id);
if (bill) {
setBillId(bill.id);
}
}
} catch (e) {
console.error('Failed to load bill', e);
}
};
useEffect(() => {
loadBill();
}, []);
const subtotal = billItems.reduce((acc, i) => acc + i.price * i.qty, 0);
const cgst = subtotal * 0.025;
const sgst = subtotal * 0.025;
const serviceCharge = subtotal * 0.05;
const tip = subtotal * (tipPercentage / 100);
const totalBill = subtotal + cgst + sgst + serviceCharge + tip;
const perPersonPrice = totalBill / splitCount;
const handlePay = async (method: 'upi' | 'card' | 'cash') => {
try {
if (billId) {
await api.recordPayment(billId, method, totalBill);
}
setPaymentMethod(method);
setPaymentDone(true);
} catch (e) {
alert('Failed to record payment: ' + e);
}
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
{/* Header */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 50,
bgcolor: '#ffffff',
borderBottom: '1px solid #f0ebe7',
py: 1.5,
px: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
}}
>
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Bill & Payment
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068' }}>
🪑 Table {tableNum} · Ticket #{ticketNum}
</Typography>
</Box>
<Chip
icon={<ReceiptLongIcon fontSize="small" />}
label={paymentDone ? 'PAID ✓' : 'UNPAID'}
color={paymentDone ? 'success' : 'error'}
sx={{ fontWeight: 800 }}
/>
</Box>
<Container maxWidth="sm" sx={{ pt: 3 }}>
{paymentDone ? (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
borderRadius: '24px',
border: '2px solid #c8e6c9',
bgcolor: '#f1f8f2',
}}
>
<CheckCircleIcon sx={{ fontSize: 72, color: '#11651d', mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 900, color: '#11651d', mb: 1 }}>
Payment Successful!
</Typography>
<Typography variant="body1" sx={{ color: '#003915', mb: 0.5 }}>
{totalBill.toFixed(0)} paid via {paymentMethod === 'upi' ? 'UPI' : paymentMethod === 'card' ? 'Card' : 'Cash'}
</Typography>
<Typography variant="body2" sx={{ color: '#546067', mb: 3 }}>
Thank you for dining with RestroAI! Your receipt has been sent.
</Typography>
<Button
variant="contained"
color="success"
size="large"
onClick={() => onNavigate('landing')}
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}
>
Back to Home
</Button>
</Paper>
) : (
<>
{/* Itemized Bill */}
<Paper elevation={0} sx={{ p: 2.5, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
🧾 Bill Details
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068', fontFamily: 'monospace', fontWeight: 700 }}>
GSTIN: 29XXXXXX0001Z5
</Typography>
</Box>
{billItems.map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.8 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{item.qty}× {item.name}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, fontFamily: 'monospace' }}>
{(item.price * item.qty).toFixed(0)}
</Typography>
</Box>
))}
<Divider sx={{ my: 1.5 }} />
{[
{ label: 'Subtotal', value: `${subtotal.toFixed(0)}` },
{ label: 'CGST (2.5%)', value: `${cgst.toFixed(0)}` },
{ label: 'SGST (2.5%)', value: `${sgst.toFixed(0)}` },
{ label: 'Service Charge (5%)', value: `${serviceCharge.toFixed(0)}` },
{ label: `Staff Tip (${tipPercentage}%)`, value: `${tip.toFixed(0)}` },
].map((row) => (
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5 }}>
<Typography variant="body2" sx={{ color: '#6b5c57' }}>{row.label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography>
</Box>
))}
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>Total Amount</Typography>
<Typography variant="h4" sx={{ fontWeight: 900, color: '#ac2d00' }}>
{totalBill.toFixed(0)}
</Typography>
</Box>
</Paper>
{/* Split Bill */}
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
👥 Split the Bill
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
{[1, 2, 3, 4].map((count) => (
<Chip
key={count}
icon={<PersonIcon fontSize="small" />}
label={count === 1 ? 'Solo' : `${count} Guests`}
clickable
onClick={() => setSplitCount(count)}
color={splitCount === count ? 'primary' : 'default'}
variant={splitCount === count ? 'filled' : 'outlined'}
sx={{ fontWeight: 800, flex: 1 }}
/>
))}
</Box>
{splitCount > 1 && (
<Alert severity="info" sx={{ fontWeight: 700, borderRadius: '10px' }}>
Each person pays: {perPersonPrice.toFixed(0)}
</Alert>
)}
</Paper>
{/* Tip Selector */}
<Paper elevation={0} sx={{ p: 2, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
Add Tip for Staff
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
{[0, 10, 15, 20].map((pct) => (
<Chip
key={pct}
label={pct === 0 ? 'No Tip' : `${pct}%`}
clickable
onClick={() => setTipPercentage(pct)}
color={tipPercentage === pct ? 'primary' : 'default'}
variant={tipPercentage === pct ? 'filled' : 'outlined'}
sx={{ fontWeight: 800, flex: 1 }}
/>
))}
</Box>
</Paper>
{/* Payment Options */}
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#8f7068', mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Choose Payment Method
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* UPI Button */}
<Button
fullWidth
variant="contained"
size="large"
onClick={() => handlePay('upi')}
startIcon={<QrCode2Icon />}
sx={{
py: 1.8,
borderRadius: '14px',
fontWeight: 800,
fontSize: '1rem',
bgcolor: '#4a148c',
'&:hover': { bgcolor: '#38006b' },
}}
>
Pay {totalBill.toFixed(0)} via UPI / GPay / PhonePe
</Button>
<Grid container spacing={1.5}>
<Grid size={{ xs: 6 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={() => handlePay('card')}
startIcon={<CreditCardIcon />}
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
>
Debit / Credit Card
</Button>
</Grid>
<Grid size={{ xs: 6 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={() => handlePay('cash')}
startIcon={<CurrencyRupeeIcon />}
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, color: '#2e7d32', borderColor: '#2e7d32' }}
>
Pay by Cash
</Button>
</Grid>
</Grid>
</Box>
</>
)}
</Container>
</Box>
);
};

View File

@ -0,0 +1,235 @@
import React, { useState } from 'react';
import {
Box,
Container,
Typography,
Paper,
Button,
IconButton,
Divider,
TextField,
Chip,
Card,
CardMedia,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import AddIcon from '@mui/icons-material/Add';
import RemoveIcon from '@mui/icons-material/Remove';
import DeleteIcon from '@mui/icons-material/Delete';
import SendIcon from '@mui/icons-material/Send';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import { MENU_ITEMS } from '../../data/menuData';
import type { MenuItem } from '../../data/menuData';
import { StatusBadge } from '../common/StatusBadge';
interface CartReviewViewProps {
cart: { [itemId: string]: number };
onUpdateCart: (itemId: string, quantity: number) => void;
onPlaceOrder: (orderNotes: string) => void;
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
}
export const CartReviewView: React.FC<CartReviewViewProps> = ({
cart,
onUpdateCart,
onPlaceOrder,
onNavigate,
}) => {
const [orderNotes, setOrderNotes] = useState<string>('');
const cartEntries = Object.entries(cart)
.map(([id, qty]) => {
const item = MENU_ITEMS.find((m) => m.id === id);
return item ? { item, qty } : null;
})
.filter(Boolean) as { item: MenuItem; qty: number }[];
const subtotal = cartEntries.reduce((sum, entry) => sum + entry.item.price * entry.qty, 0);
const cgst = subtotal * 0.025; // CGST 2.5%
const sgst = subtotal * 0.025; // SGST 2.5%
const serviceCharge = subtotal * 0.05; // 5% service charge
const grandTotal = subtotal + cgst + sgst + serviceCharge;
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
{/* Header */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 50,
bgcolor: '#ffffff',
borderBottom: '1px solid #f0ebe7',
py: 1.5,
px: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton onClick={() => onNavigate('menu')} size="small">
<ArrowBackIcon sx={{ color: '#ac2d00' }} />
</IconButton>
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Your Cart
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068' }}>
🪑 Table {sessionStorage.getItem('customer_table_id') || '12'}
</Typography>
</Box>
</Box>
<Chip
label={`${cartEntries.length} item${cartEntries.length !== 1 ? 's' : ''}`}
sx={{ fontWeight: 800, bgcolor: '#ffdbd1', color: '#ac2d00' }}
/>
</Box>
<Container maxWidth="sm" sx={{ pt: 2 }}>
{cartEntries.length === 0 ? (
<Paper
elevation={0}
sx={{ p: 5, textAlign: 'center', borderRadius: '20px', border: '2px dashed #e4ddd8', mt: 4 }}
>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#8f7068', mb: 1 }}>
Your cart is empty
</Typography>
<Typography variant="body2" sx={{ color: '#b0a09a', mb: 3 }}>
Go back and add some delicious dishes!
</Typography>
<Button variant="contained" color="primary" onClick={() => onNavigate('menu')}
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}>
Browse Menu
</Button>
</Paper>
) : (
<>
{/* Cart Items */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 2 }}>
{cartEntries.map(({ item, qty }) => (
<Card
key={item.id}
elevation={0}
sx={{
p: 1.5,
borderRadius: '16px',
border: '1px solid #f0ebe7',
display: 'flex',
alignItems: 'center',
gap: 1.5,
bgcolor: '#ffffff',
}}
>
<CardMedia
component="img"
sx={{ width: 72, height: 72, borderRadius: '12px', objectFit: 'cover', flexShrink: 0 }}
image={item.image}
alt={item.name}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
{item.name}
</Typography>
<StatusBadge dietary={item.dietary} />
</Box>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00', my: 0.5 }}>
{(item.price * qty).toFixed(0)}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', bgcolor: '#f8f5f2', borderRadius: '9999px' }}>
<IconButton size="small" onClick={() => onUpdateCart(item.id, qty - 1)}>
<RemoveIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography variant="body2" sx={{ px: 1, fontWeight: 900, fontFamily: 'monospace', color: '#ac2d00' }}>
{qty}
</Typography>
<IconButton size="small" onClick={() => onUpdateCart(item.id, qty + 1)}>
<AddIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<IconButton size="small" color="error" onClick={() => onUpdateCart(item.id, 0)}>
<DeleteIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
</Box>
</Card>
))}
</Box>
{/* Instructions */}
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
📝 Special Instructions
</Typography>
<TextField
placeholder="E.g. Extra spicy, no onion, serve starters first..."
multiline
rows={2}
fullWidth
size="small"
value={orderNotes}
onChange={(e) => setOrderNotes(e.target.value)}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: '10px', bgcolor: '#f8f5f2' } }}
/>
</Paper>
{/* Bill Summary */}
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<ReceiptLongIcon sx={{ color: '#ac2d00', fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Bill Summary
</Typography>
</Box>
{[
{ label: 'Items Total', value: `${subtotal.toFixed(0)}` },
{ label: 'CGST (2.5%)', value: `${cgst.toFixed(0)}` },
{ label: 'SGST (2.5%)', value: `${sgst.toFixed(0)}` },
{ label: 'Service Charge (5%)', value: `${serviceCharge.toFixed(0)}` },
].map((row) => (
<Box key={row.label} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.6 }}>
<Typography variant="body2" sx={{ color: '#6b5c57' }}>{row.label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{row.value}</Typography>
</Box>
))}
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#1a1c1c' }}>Grand Total</Typography>
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00' }}>{grandTotal.toFixed(0)}</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#8f7068', display: 'block', mt: 0.5 }}>
*Inclusive of all taxes as per GST regulations
</Typography>
</Paper>
{/* Place Order CTA */}
<Button
fullWidth
variant="contained"
size="large"
onClick={() => onPlaceOrder(orderNotes)}
endIcon={<SendIcon />}
sx={{
py: 2,
borderRadius: '9999px',
fontSize: '1.05rem',
fontWeight: 900,
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
boxShadow: '0 8px 24px rgba(172, 45, 0, 0.35)',
'&:hover': { background: 'linear-gradient(135deg, #872100 0%, #ac2d00 100%)' },
}}
>
Place Order {grandTotal.toFixed(0)}
</Button>
</>
)}
</Container>
</Box>
);
};

View File

@ -0,0 +1,187 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
Box,
Button,
Radio,
RadioGroup,
FormControlLabel,
Checkbox,
TextField,
Divider,
} from '@mui/material';
import type { MenuItem } from '../../data/menuData';
import { StatusBadge } from '../common/StatusBadge';
interface CustomizationModalProps {
open: boolean;
item: MenuItem | null;
onClose: () => void;
onConfirm: (item: MenuItem, quantity: number, notes: string) => void;
}
export const CustomizationModal: React.FC<CustomizationModalProps> = ({
open,
item,
onClose,
onConfirm,
}) => {
if (!item) return null;
const [spiceLevel, setSpiceLevel] = useState<string>('Medium');
const [extraCheese, setExtraCheese] = useState<boolean>(false);
const [extraTruffle, setExtraTruffle] = useState<boolean>(false);
const [specialNotes, setSpecialNotes] = useState<string>('');
const [quantity, setQuantity] = useState<number>(1);
const addOnCost = (extraCheese ? 40 : 0) + (extraTruffle ? 60 : 0);
const totalPrice = (item.price + addOnCost) * quantity;
const handleAdd = () => {
const formattedNotes = [
`Spice: ${spiceLevel}`,
extraCheese ? 'Extra Cheese (+ $2.50)' : '',
extraTruffle ? 'Extra Truffle Oil (+ $3.00)' : '',
specialNotes ? `Note: ${specialNotes}` : '',
]
.filter(Boolean)
.join(' | ');
onConfirm(item, quantity, formattedNotes);
onClose();
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{item.name}
</Typography>
<StatusBadge dietary={item.dietary} />
</Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00' }}>
{item.price}
</Typography>
</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" sx={{ color: '#5b4139', mb: 2 }}>
{item.description}
</Typography>
{/* Spice Level Selector */}
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mt: 1, mb: 1 }}>
🌶 Select Spice Level:
</Typography>
<RadioGroup
row
value={spiceLevel}
onChange={(e) => setSpiceLevel(e.target.value)}
sx={{ mb: 2 }}
>
{['Mild', 'Medium', 'Spicy', 'Extra Hot'].map((level) => (
<FormControlLabel
key={level}
value={level}
control={<Radio size="small" sx={{ color: '#ac2d00', '&.Mui-checked': { color: '#ac2d00' } }} />}
label={<Typography variant="body2" sx={{ fontWeight: 600 }}>{level}</Typography>}
/>
))}
</RadioGroup>
<Divider sx={{ my: 2 }} />
{/* Extra Add-ons */}
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
Extra Add-ons:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={extraCheese}
onChange={(e) => setExtraCheese(e.target.checked)}
size="small"
sx={{ color: '#ac2d00', '&.Mui-checked': { color: '#ac2d00' } }}
/>
}
label={
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: 300 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Extra Butter / Cream</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#ac2d00' }}>+40</Typography>
</Box>
}
/>
<FormControlLabel
control={
<Checkbox
checked={extraTruffle}
onChange={(e) => setExtraTruffle(e.target.checked)}
size="small"
sx={{ color: '#ac2d00', '&.Mui-checked': { color: '#ac2d00' } }}
/>
}
label={
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: 300 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Extra Masala / Spice Blend</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#ac2d00' }}>+60</Typography>
</Box>
}
/>
</Box>
<Divider sx={{ my: 2 }} />
{/* Special Instructions */}
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1 }}>
📝 Special Instructions / Allergy Notes:
</Typography>
<TextField
placeholder="E.g., No cilantro, extra crispy, sauce on the side..."
multiline
rows={2}
fullWidth
size="small"
value={specialNotes}
onChange={(e) => setSpecialNotes(e.target.value)}
/>
</DialogContent>
<DialogActions sx={{ p: 2, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', bgcolor: '#eeeeee', borderRadius: '9999px', p: 0.5 }}>
<Button
size="small"
onClick={() => setQuantity(Math.max(1, quantity - 1))}
sx={{ minWidth: 32, fontWeight: 800, color: '#ac2d00' }}
>
-
</Button>
<Typography variant="body2" sx={{ px: 2, fontWeight: 800, fontFamily: 'monospace' }}>
{quantity}
</Typography>
<Button
size="small"
onClick={() => setQuantity(quantity + 1)}
sx={{ minWidth: 32, fontWeight: 800, color: '#ac2d00' }}
>
+
</Button>
</Box>
<Button
variant="contained"
color="primary"
onClick={handleAdd}
sx={{ borderRadius: '9999px', px: 4, fontWeight: 800 }}
>
Add to Order {totalPrice.toFixed(0)}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@ -0,0 +1,542 @@
import React, { useState, useMemo } from 'react';
import {
Box,
Typography,
Paper,
IconButton,
Chip,
TextField,
InputAdornment,
Card,
CardMedia,
Badge,
BottomNavigation,
BottomNavigationAction,
Button,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import RemoveIcon from '@mui/icons-material/Remove';
import SearchIcon from '@mui/icons-material/Search';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import MicIcon from '@mui/icons-material/Mic';
import HomeIcon from '@mui/icons-material/Home';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import TrackChangesIcon from '@mui/icons-material/TrackChanges';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import StarIcon from '@mui/icons-material/Star';
import { MENU_ITEMS, CATEGORIES } from '../../data/menuData';
import type { MenuItem, CategoryType } from '../../data/menuData';
import { StatusBadge } from '../common/StatusBadge';
interface MenuBrowseViewProps {
cart: { [itemId: string]: number };
onUpdateCart: (itemId: string, quantity: number) => void;
onOpenCustomization: (item: MenuItem) => void;
onOpenVoice: () => void;
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
}
const DIETARY_FILTERS = [
{ label: 'All', value: null },
{ label: '🟢 Veg', value: 'veg' },
{ label: '🔴 Non-Veg', value: 'non-veg' },
{ label: '🌿 Jain', value: 'jain' },
];
const SPICE_ICONS: Record<string, string> = {
mild: '🟡',
medium: '🟠',
hot: '🔴',
'extra-hot': '🔥',
};
export const MenuBrowseView: React.FC<MenuBrowseViewProps> = ({
cart,
onUpdateCart,
onOpenCustomization,
onOpenVoice,
onNavigate,
}) => {
const [activeCategory, setActiveCategory] = useState<CategoryType>('All');
const [searchQuery, setSearchQuery] = useState('');
const [dietaryFilter, setDietaryFilter] = useState<string | null>(null);
const [bottomNav, setBottomNav] = useState(1); // 1 = Menu
const totalCartItems = Object.values(cart).reduce((a, b) => a + b, 0);
const totalCartValue = Object.entries(cart).reduce((sum, [id, qty]) => {
const item = MENU_ITEMS.find((m) => m.id === id);
return sum + (item ? item.price * qty : 0);
}, 0);
const filteredItems = useMemo(() => {
return MENU_ITEMS.filter((item) => {
const matchCategory = activeCategory === 'All' || item.category === activeCategory;
const matchSearch =
!searchQuery ||
item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(item.nameHindi && item.nameHindi.includes(searchQuery)) ||
item.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchDietary = !dietaryFilter || item.dietary === dietaryFilter;
return matchCategory && matchSearch && matchDietary;
});
}, [activeCategory, searchQuery, dietaryFilter]);
const handleBottomNav = (_: React.SyntheticEvent, newValue: number) => {
setBottomNav(newValue);
if (newValue === 0) onNavigate('landing');
if (newValue === 2) {
if (totalCartItems > 0) onNavigate('cart');
}
if (newValue === 3) onNavigate('status');
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', display: 'flex', flexDirection: 'column' }}>
{/* Sticky Header */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 100,
bgcolor: '#ffffff',
borderBottom: '1px solid #f0ebe7',
boxShadow: '0 2px 12px rgba(0,0,0,0.05)',
}}
>
{/* Top bar */}
<Box sx={{ px: 2, pt: 1.5, pb: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00', lineHeight: 1.1 }}>
RestroAI Menu
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
🪑 Table {sessionStorage.getItem('customer_table_id') || '12'} · Dine-In
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton
onClick={onOpenVoice}
sx={{
bgcolor: '#ffdbd1',
color: '#ac2d00',
'&:hover': { bgcolor: '#ffcab8' },
}}
>
<MicIcon />
</IconButton>
<Badge badgeContent={totalCartItems} color="error">
<IconButton
onClick={() => totalCartItems > 0 && onNavigate('cart')}
sx={{
bgcolor: totalCartItems > 0 ? '#ac2d00' : '#f2ede9',
color: totalCartItems > 0 ? '#fff' : '#8f7068',
'&:hover': { bgcolor: totalCartItems > 0 ? '#872100' : '#ede8e4' },
}}
>
<ShoppingCartIcon />
</IconButton>
</Badge>
</Box>
</Box>
{/* Search Bar */}
<Box sx={{ px: 2, pb: 1 }}>
<TextField
fullWidth
size="small"
placeholder="Search dishes, e.g. biryani, paneer..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: '#8f7068', fontSize: 18 }} />
</InputAdornment>
),
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: '12px',
bgcolor: '#f8f5f2',
'& fieldset': { border: '1px solid #e4ddd8' },
},
}}
/>
</Box>
{/* Category Tabs - Horizontal Scroll */}
<Box
sx={{
display: 'flex',
gap: 0.8,
px: 2,
pb: 1.5,
overflowX: 'auto',
'&::-webkit-scrollbar': { display: 'none' },
}}
>
{CATEGORIES.map((cat) => {
const catEmoji: Record<string, string> = {
All: '🍽️', Starters: '🥗', Biryani: '🍚', Mains: '🍛',
Breads: '🫓', Drinks: '🥤', Desserts: '🍮', Specials: '⭐',
};
const isActive = activeCategory === cat;
return (
<Chip
key={cat}
label={`${catEmoji[cat]} ${cat}`}
onClick={() => setActiveCategory(cat)}
sx={{
fontWeight: 800,
flexShrink: 0,
bgcolor: isActive ? '#ac2d00' : '#ffffff',
color: isActive ? '#fff' : '#5b4139',
border: isActive ? '2px solid #ac2d00' : '1.5px solid #e4ddd8',
'&:hover': { bgcolor: isActive ? '#872100' : '#f5ede9' },
transition: 'all 0.15s ease',
}}
/>
);
})}
</Box>
{/* Dietary Filters */}
<Box sx={{ display: 'flex', gap: 0.8, px: 2, pb: 1.5, overflowX: 'auto', '&::-webkit-scrollbar': { display: 'none' } }}>
{DIETARY_FILTERS.map((f) => (
<Chip
key={f.label}
label={f.label}
size="small"
onClick={() => setDietaryFilter(f.value)}
variant={dietaryFilter === f.value ? 'filled' : 'outlined'}
color={dietaryFilter === f.value ? 'primary' : 'default'}
sx={{ fontWeight: 700, flexShrink: 0 }}
/>
))}
</Box>
</Box>
{/* AI Banner */}
<Box sx={{ px: 2, pt: 2 }}>
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: '16px',
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
color: '#fff',
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 2,
cursor: 'pointer',
}}
onClick={onOpenVoice}
>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '50%',
bgcolor: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<AutoAwesomeIcon />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
🍛 Today's AI Pick: Hyderabadi Chicken Biryani
</Typography>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
Tap to voice order or browse below · 380
</Typography>
</Box>
</Paper>
</Box>
{/* Menu Cards */}
<Box sx={{ flex: 1, px: 2, pb: 20 }}>
{activeCategory !== 'All' && (
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#5b4139', mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{activeCategory} ({filteredItems.length} items)
</Typography>
)}
{filteredItems.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#8f7068' }}>No dishes found</Typography>
<Typography variant="body2" sx={{ color: '#b0a09a' }}>Try a different search or filter</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{filteredItems.map((item) => {
const qty = cart[item.id] || 0;
return (
<Card
key={item.id}
elevation={0}
sx={{
borderRadius: '20px',
border: '1px solid #f0ebe7',
overflow: 'hidden',
bgcolor: '#ffffff',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
transition: 'box-shadow 0.2s ease',
'&:hover': { boxShadow: '0 6px 20px rgba(0,0,0,0.08)' },
}}
>
{/* Food Image */}
<Box sx={{ position: 'relative' }}>
<CardMedia
component="img"
height="180"
image={item.image}
alt={item.name}
sx={{ objectFit: 'cover' }}
/>
{/* Badges on image */}
<Box sx={{ position: 'absolute', top: 10, left: 10, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{item.isChefSpecial && (
<Chip
icon={<AutoAwesomeIcon sx={{ fontSize: '14px !important' }} />}
label="Chef's Special"
size="small"
sx={{
bgcolor: 'rgba(172,45,0,0.9)',
color: '#fff',
fontWeight: 800,
fontSize: '0.65rem',
backdropFilter: 'blur(4px)',
}}
/>
)}
{item.isBestseller && (
<Chip
icon={<StarIcon sx={{ fontSize: '14px !important', color: '#FFD700' }} />}
label="Bestseller"
size="small"
sx={{
bgcolor: 'rgba(0,0,0,0.7)',
color: '#FFD700',
fontWeight: 800,
fontSize: '0.65rem',
backdropFilter: 'blur(4px)',
}}
/>
)}
</Box>
{item.spiceLevel && (
<Chip
label={`${SPICE_ICONS[item.spiceLevel]} ${item.spiceLevel}`}
size="small"
sx={{
position: 'absolute',
top: 10,
right: 10,
bgcolor: 'rgba(255,255,255,0.9)',
fontWeight: 700,
fontSize: '0.65rem',
textTransform: 'capitalize',
}}
/>
)}
</Box>
{/* Card Content */}
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Box sx={{ flex: 1, mr: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.2 }}>
{item.name}
</Typography>
{item.nameHindi && (
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
{item.nameHindi}
</Typography>
)}
</Box>
<StatusBadge dietary={item.dietary} />
</Box>
<Typography variant="body2" sx={{ color: '#6b5c57', mb: 1.5, lineHeight: 1.5, fontSize: '0.8rem' }}>
{item.description}
</Typography>
{/* Tags */}
{item.tags && item.tags.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.5 }}>
{item.tags.slice(0, 3).map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
sx={{ fontSize: '0.62rem', fontWeight: 700, bgcolor: '#f8f5f2', height: 20 }}
/>
))}
</Box>
)}
{/* Price + Action Row */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#ac2d00' }}>
{item.price}
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068' }}>
{item.prepTimeMinutes} min
</Typography>
</Box>
{qty === 0 ? (
<Button
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={() => onOpenCustomization(item)}
sx={{
borderRadius: '9999px',
fontWeight: 800,
color: '#ac2d00',
borderColor: '#ac2d00',
px: 2,
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
}}
>
Add
</Button>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: '#ac2d00',
borderRadius: '9999px',
overflow: 'hidden',
}}
>
<IconButton
size="small"
onClick={() => onUpdateCart(item.id, qty - 1)}
sx={{ color: '#fff', p: 0.8 }}
>
<RemoveIcon fontSize="small" />
</IconButton>
<Typography sx={{ px: 1.5, color: '#fff', fontWeight: 900, fontFamily: 'monospace' }}>
{qty}
</Typography>
<IconButton
size="small"
onClick={() => onUpdateCart(item.id, qty + 1)}
sx={{ color: '#fff', p: 0.8 }}
>
<AddIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
</Box>
</Card>
);
})}
</Box>
)}
</Box>
{/* Floating Cart Pill */}
{totalCartItems > 0 && (
<Box
sx={{
position: 'fixed',
bottom: 72,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 200,
}}
>
<Paper
elevation={8}
onClick={() => onNavigate('cart')}
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
px: 3,
py: 1.5,
borderRadius: '9999px',
bgcolor: '#ac2d00',
color: '#fff',
cursor: 'pointer',
boxShadow: '0 8px 28px rgba(172,45,0,0.45)',
'&:hover': { bgcolor: '#872100' },
transition: 'all 0.2s ease',
minWidth: 260,
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Badge badgeContent={totalCartItems} color="warning" sx={{ '& .MuiBadge-badge': { fontWeight: 800 } }}>
<ShoppingCartIcon />
</Badge>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
{totalCartItems} item{totalCartItems > 1 ? 's' : ''} in cart
</Typography>
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>
{totalCartValue.toFixed(0)}
</Typography>
</Paper>
</Box>
)}
{/* Bottom Navigation */}
<Paper
elevation={8}
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
borderTop: '1px solid #f0ebe7',
}}
>
<BottomNavigation
value={bottomNav}
onChange={handleBottomNav}
sx={{ bgcolor: '#ffffff', height: 64 }}
>
<BottomNavigationAction
label="Home"
icon={<HomeIcon />}
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
<BottomNavigationAction
label="Menu"
icon={<MenuBookIcon />}
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
<BottomNavigationAction
label="Cart"
icon={
<Badge badgeContent={totalCartItems} color="error">
<ShoppingCartIcon />
</Badge>
}
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
<BottomNavigationAction
label="My Order"
icon={<TrackChangesIcon />}
sx={{ '&.Mui-selected': { color: '#ac2d00' } }}
/>
</BottomNavigation>
</Paper>
</Box>
);
};

View File

@ -0,0 +1,284 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Typography,
Paper,
Button,
Chip,
Alert,
Stepper,
Step,
StepLabel,
StepContent,
} from '@mui/material';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import AddIcon from '@mui/icons-material/Add';
import PaymentIcon from '@mui/icons-material/Payment';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import RestaurantIcon from '@mui/icons-material/Restaurant';
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
import SentimentSatisfiedAltIcon from '@mui/icons-material/SentimentSatisfiedAlt';
import { api, RestroWebSocket, subscribeToWsEvents } from '../../services/api';
interface OrderStatusViewProps {
onNavigate: (view: 'landing' | 'menu' | 'cart' | 'status' | 'bill' | 'voice') => void;
}
const ORDER_STEPS = [
{
label: 'Order Confirmed',
description: 'Your order has been received and sent to the kitchen.',
icon: <CheckCircleIcon />,
time: 'Just now',
},
{
label: 'Being Prepared',
description: 'Chef Ravi is preparing your order with fresh ingredients.',
icon: <RestaurantIcon />,
time: '~10 min',
},
{
label: 'Ready for Service',
description: 'Your dishes are plated and ready to be brought to your table.',
icon: <DeliveryDiningIcon />,
time: '~15 min',
},
{
label: 'Order Served 🎉',
description: 'Your meal has been served. Enjoy your dining experience!',
icon: <SentimentSatisfiedAltIcon />,
time: '~20 min',
},
];
export const OrderStatusView: React.FC<OrderStatusViewProps> = ({ onNavigate }) => {
const [activeStep, setActiveStep] = useState<number>(0);
const [ticketNum, setTicketNum] = useState<string>('---');
const [tableNum, setTableNum] = useState<string>('12');
const [waiterNotified, setWaiterNotified] = useState<boolean>(false);
const [eta, setEta] = useState(15);
const sessionId = sessionStorage.getItem('customer_session_id') || '112';
const loadOrder = async () => {
try {
const order = await api.getOrderForSession(sessionId);
if (order) {
setTicketNum(order.ticketNumber || '105');
setTableNum(order.tableNumber || sessionStorage.getItem('customer_table_id') || '12');
let step = 0;
if (order.status === 'served') step = 3;
else if (order.status === 'ready') step = 2;
else if (order.status === 'in_preparation' || order.status === 'preparing') step = 1;
setActiveStep(step);
setEta(step === 0 ? 15 : step === 1 ? 10 : step === 2 ? 3 : 0);
}
} catch (e) {
console.error('Failed to load active order', e);
}
};
useEffect(() => {
loadOrder();
// Start WebSocket
const ws = new RestroWebSocket('orders', sessionId);
ws.connect();
const unsubscribe = subscribeToWsEvents((event) => {
if (event.type === 'kds.item_updated') {
loadOrder();
}
});
return () => {
ws.close();
unsubscribe();
};
}, []);
// Countdown timer
useEffect(() => {
if (eta <= 0) return;
const timer = setInterval(() => setEta((prev) => Math.max(0, prev - 1)), 60000);
return () => clearInterval(timer);
}, [eta]);
const handleCallWaiter = () => {
setWaiterNotified(true);
setTimeout(() => setWaiterNotified(false), 5000);
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f8f5f2', pb: 12 }}>
{/* Header */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 50,
bgcolor: '#ffffff',
borderBottom: '1px solid #f0ebe7',
py: 1.5,
px: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
}}
>
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Live Order Status
</Typography>
<Typography variant="caption" sx={{ color: '#8f7068' }}>
Ticket #{ticketNum} · Table {tableNum}
</Typography>
</Box>
<Chip
icon={<AccessTimeIcon fontSize="small" />}
label={eta > 0 ? `ETA ~${eta} min` : 'Ready soon!'}
sx={{
fontWeight: 800,
bgcolor: '#ffdbd1',
color: '#ac2d00',
}}
/>
</Box>
<Container maxWidth="sm" sx={{ pt: 3 }}>
{waiterNotified && (
<Alert severity="success" sx={{ mb: 2, fontWeight: 700, borderRadius: '12px' }}>
🔔 Waiter has been notified to visit Table {tableNum}!
</Alert>
)}
{/* Live Status Card */}
<Paper
elevation={0}
sx={{
p: 3,
mb: 3,
borderRadius: '20px',
background: 'linear-gradient(135deg, #fff9f7 0%, #ffdbd1 100%)',
border: '1px solid #e4beb4',
}}
>
<Typography variant="h5" sx={{ fontWeight: 900, color: '#ac2d00', mb: 0.5 }}>
{activeStep === 0 && '✅ Order Placed!'}
{activeStep === 1 && '👨‍🍳 Chef is Cooking...'}
{activeStep === 2 && '🍽️ Almost Ready!'}
{activeStep === 3 && '🎉 Meal Served!'}
</Typography>
<Typography variant="body2" sx={{ color: '#5b4139', mb: 3 }}>
{ORDER_STEPS[activeStep].description}
</Typography>
{/* Vertical Stepper */}
<Stepper activeStep={activeStep} orientation="vertical">
{ORDER_STEPS.map((step, index) => (
<Step key={step.label} completed={index < activeStep}>
<StepLabel
slotProps={{
label: {
sx: {
fontWeight: index <= activeStep ? 800 : 500,
color: index <= activeStep ? '#ac2d00' : '#8f7068',
},
},
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<span>{step.label}</span>
<Typography variant="caption" sx={{ color: '#8f7068', fontWeight: 600 }}>
{step.time}
</Typography>
</Box>
</StepLabel>
{index === activeStep && (
<StepContent>
<Typography variant="caption" sx={{ color: '#5b4139' }}>
{step.description}
</Typography>
</StepContent>
)}
</Step>
))}
</Stepper>
</Paper>
{/* Ordered Items Summary */}
<Paper elevation={0} sx={{ p: 2.5, mb: 3, borderRadius: '16px', border: '1px solid #f0ebe7', bgcolor: '#ffffff' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1a1c1c', mb: 1.5 }}>
🧾 Your Order Summary
</Typography>
{[
{ name: 'Hyderabadi Chicken Biryani', qty: 1, price: 380 },
{ name: 'Paneer Tikka', qty: 1, price: 280 },
{ name: 'Garlic Naan', qty: 2, price: 80 },
{ name: 'Mango Lassi', qty: 2, price: 120 },
].map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', justifyContent: 'space-between', py: 0.6, borderBottom: idx < 3 ? '1px solid #f5f0ec' : 'none' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{item.qty}× {item.name}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#ac2d00' }}>
{item.qty * item.price}
</Typography>
</Box>
))}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1.5, pt: 1, borderTop: '2px solid #f0ebe7' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Estimated Total</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 900, color: '#ac2d00' }}>960</Typography>
</Box>
</Paper>
{/* Action Buttons */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={handleCallWaiter}
startIcon={<NotificationsActiveIcon />}
sx={{
py: 1.5,
borderRadius: '12px',
fontWeight: 800,
color: '#845000',
borderColor: '#845000',
'&:hover': { borderColor: '#845000', bgcolor: '#fff8ee' },
}}
>
Call Waiter to Table 12
</Button>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
fullWidth
variant="outlined"
onClick={() => onNavigate('menu')}
startIcon={<AddIcon />}
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, color: '#ac2d00', borderColor: '#ac2d00' }}
>
Add More
</Button>
<Button
fullWidth
variant="contained"
onClick={() => onNavigate('bill')}
startIcon={<PaymentIcon />}
sx={{ py: 1.5, borderRadius: '12px', fontWeight: 800, bgcolor: '#ac2d00', '&:hover': { bgcolor: '#872100' } }}
>
Request Bill
</Button>
</Box>
</Box>
</Container>
</Box>
);
};

View File

@ -0,0 +1,338 @@
import React, { useState } from 'react';
import {
Box,
Container,
Typography,
Button,
Paper,
Chip,
Grid,
IconButton,
} from '@mui/material';
import RestaurantMenuIcon from '@mui/icons-material/RestaurantMenu';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import LanguageIcon from '@mui/icons-material/Language';
import TranslateIcon from '@mui/icons-material/Translate';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import WifiIcon from '@mui/icons-material/Wifi';
import MicIcon from '@mui/icons-material/Mic';
import LockIcon from '@mui/icons-material/Lock';
interface TableLandingViewProps {
onStartOrdering: (language: string) => void;
onOpenStaffLogin?: () => void;
onOpenVoice?: () => void;
}
export const TableLandingView: React.FC<TableLandingViewProps> = ({
onStartOrdering,
onOpenStaffLogin,
onOpenVoice,
}) => {
const [selectedLang, setSelectedLang] = useState<'english' | 'hindi' | 'hinglish'>('english');
const languages = [
{
id: 'english',
emoji: '🇬🇧',
name: 'English',
subText: 'English',
icon: <LanguageIcon fontSize="large" />,
},
{
id: 'hindi',
emoji: '🇮🇳',
name: 'हिन्दी',
subText: 'Hindi',
icon: <Typography variant="h5" sx={{ fontWeight: 900, lineHeight: 1 }}></Typography>,
},
{
id: 'hinglish',
emoji: '🤝',
name: 'Hinglish',
subText: 'Mix',
icon: <TranslateIcon fontSize="large" />,
},
];
return (
<Box
sx={{
minHeight: '100vh',
bgcolor: '#fff9f7',
color: '#1a1c1c',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Hero Image Banner */}
<Box
sx={{
height: 260,
position: 'relative',
backgroundImage: `url('https://images.unsplash.com/photo-1585937421612-70a008356fbe?w=800&auto=format&fit=crop&q=80')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
flexShrink: 0,
}}
>
{/* Dark overlay gradient */}
<Box
sx={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to bottom, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.6) 100%)',
}}
/>
{/* Top bar inside hero */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
px: 2.5,
py: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: '10px',
background: 'linear-gradient(135deg, #ac2d00, #d53e0b)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
boxShadow: '0 2px 8px rgba(172,45,0,0.5)',
}}
>
<RestaurantMenuIcon fontSize="small" />
</Box>
<Typography variant="h6" sx={{ fontWeight: 900, color: '#fff', letterSpacing: '-0.02em' }}>
RestroAI
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={`🪑 Table ${(() => {
const params = new URLSearchParams(window.location.search);
const tableParam = params.get('table');
if (tableParam) {
const parsed = parseInt(tableParam);
if (!isNaN(parsed)) return parsed;
}
const path = window.location.pathname;
const match = path.match(/\/scan\/(\d+)/);
if (match) {
return parseInt(match[1]);
}
return 12;
})()}`}
sx={{
fontWeight: 800,
fontFamily: '"JetBrains Mono", monospace',
bgcolor: 'rgba(255,255,255,0.2)',
color: '#fff',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.35)',
}}
/>
<IconButton size="small" onClick={onOpenStaffLogin} sx={{ color: 'rgba(255,255,255,0.7)' }}>
<LockIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* Hero text at bottom of image */}
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, px: 2.5, pb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8, mb: 0.5 }}>
<AutoAwesomeIcon sx={{ fontSize: 14, color: '#ffb5a0' }} />
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ffb5a0', letterSpacing: '0.05em' }}>
AI-POWERED DINING
</Typography>
</Box>
<Typography variant="h5" sx={{ fontWeight: 900, color: '#fff', lineHeight: 1.2, mb: 0.5 }}>
Scan. Order. Enjoy.
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', fontWeight: 500 }}>
Fresh Indian cuisine, served with precision & joy
</Typography>
</Box>
</Box>
{/* Content Area */}
<Box sx={{ flex: 1, overflow: 'auto', pb: 4 }}>
<Container maxWidth="xs" sx={{ pt: 3 }}>
{/* AI Voice Section */}
<Paper
elevation={0}
sx={{
p: 2.5,
mb: 3,
borderRadius: '20px',
background: 'linear-gradient(135deg, #ffdbd1 0%, #fff3f0 100%)',
border: '1.5px solid #e4beb4',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': { boxShadow: '0 8px 24px rgba(172,45,0,0.15)' },
}}
onClick={onOpenVoice}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
width: 52,
height: 52,
borderRadius: '50%',
background: 'linear-gradient(135deg, #ac2d00, #d53e0b)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
flexShrink: 0,
boxShadow: '0 4px 12px rgba(172,45,0,0.4)',
animation: 'pulse 2.5s ease-in-out infinite',
}}
>
<MicIcon />
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', lineHeight: 1.2 }}>
Order with Voice 🎙
</Typography>
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
Tap & say your order in English or Hindi
</Typography>
</Box>
<ArrowForwardIcon sx={{ color: '#ac2d00', ml: 'auto' }} />
</Box>
</Paper>
{/* Language Selection */}
<Box sx={{ mb: 3 }}>
<Typography
variant="caption"
sx={{
fontWeight: 800,
color: '#8f7068',
letterSpacing: '0.08em',
display: 'block',
mb: 1.5,
textTransform: 'uppercase',
}}
>
Choose Your Language
</Typography>
<Grid container spacing={1.5}>
{languages.map((lang) => {
const isSelected = selectedLang === lang.id;
return (
<Grid size={{ xs: 4 }} key={lang.id}>
<Paper
elevation={isSelected ? 3 : 0}
onClick={() => setSelectedLang(lang.id as 'english' | 'hindi' | 'hinglish')}
sx={{
p: 2,
borderRadius: '16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.8,
backgroundColor: isSelected ? '#ac2d00' : '#f2ede9',
color: isSelected ? '#ffffff' : '#5b403d',
border: isSelected ? '2px solid #872100' : '2px solid transparent',
transition: 'all 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
'&:hover': {
backgroundColor: isSelected ? '#872100' : '#e8e2de',
transform: 'scale(1.04)',
},
}}
>
<Typography sx={{ fontSize: '1.6rem', lineHeight: 1 }}>{lang.emoji}</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, textAlign: 'center', lineHeight: 1.2 }}>
{lang.name}
</Typography>
</Paper>
</Grid>
);
})}
</Grid>
</Box>
{/* Today's Highlights */}
<Paper
elevation={0}
sx={{
p: 2,
mb: 3,
borderRadius: '16px',
bgcolor: '#f6f7f8',
border: '1px solid #e0e0e0',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8, mb: 1.5 }}>
<AutoAwesomeIcon sx={{ fontSize: 16, color: '#ac2d00' }} />
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Today's Must-Try
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{['Hyderabadi Biryani ₹380', 'Butter Chicken ₹360', "Chef's Thali ₹550"].map((item) => (
<Chip
key={item}
label={item}
size="small"
sx={{ fontWeight: 700, bgcolor: '#fff', border: '1px solid #e4beb4' }}
/>
))}
</Box>
</Paper>
{/* Main CTA Button */}
<Button
fullWidth
variant="contained"
size="large"
onClick={() => onStartOrdering(selectedLang)}
endIcon={<ArrowForwardIcon />}
sx={{
py: 2,
borderRadius: '9999px',
fontSize: '1.1rem',
fontWeight: 900,
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
boxShadow: '0 10px 28px rgba(172, 45, 0, 0.4)',
'&:hover': {
background: 'linear-gradient(135deg, #872100 0%, #ac2d00 100%)',
boxShadow: '0 12px 32px rgba(172, 45, 0, 0.5)',
},
transition: 'all 0.25s ease',
}}
>
Browse Full Menu
</Button>
{/* Footer */}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 1, color: '#8f7068' }}>
<WifiIcon fontSize="small" />
<Typography variant="caption" sx={{ fontWeight: 600 }}>
Free WiFi: RestroAI_Guest
</Typography>
</Box>
</Container>
</Box>
</Box>
);
};

View File

@ -0,0 +1,440 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Drawer,
Typography,
IconButton,
Chip,
Paper,
Alert,
Button,
Divider,
Fade,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import MicIcon from '@mui/icons-material/Mic';
import StopIcon from '@mui/icons-material/Stop';
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import { MENU_ITEMS } from '../../data/menuData';
import type { MenuItem } from '../../data/menuData';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { useSpeechSynthesis } from '../../hooks/useSpeechSynthesis';
import type { VoiceState } from '../../hooks/useSpeechRecognition';
interface DetectedItem {
menuItem: MenuItem;
qty: number;
}
interface VoiceAssistantModalProps {
open: boolean;
onClose: () => void;
onAddToCart: (itemId: string, quantity: number) => void;
}
// Detect menu items from voice transcript
function parseTranscriptToItems(text: string): DetectedItem[] {
const lower = text.toLowerCase();
const detectedItems: DetectedItem[] = [];
for (const item of MENU_ITEMS) {
const itemNameLower = item.name.toLowerCase();
// Check for item name in transcript
if (lower.includes(itemNameLower) || (item.nameHindi && lower.includes(item.nameHindi.toLowerCase()))) {
// Try to detect quantity (look for digits near the item name)
let qty = 1;
const qtxMatch = lower.match(/(\d+)\s*(?:plate|order|piece|nos|number)?(?:of\s+)?(?:\w+\s+)*/);
if (qtxMatch) {
const num = parseInt(qtxMatch[1], 10);
if (num >= 1 && num <= 10) qty = num;
}
if (/\btwo\b|\bdo\b/i.test(lower)) qty = 2;
if (/\bthree\b|\bteen\b/i.test(lower)) qty = 3;
if (/\bfour\b|\bchar\b/i.test(lower)) qty = 4;
// avoid duplicates
if (!detectedItems.find((d) => d.menuItem.id === item.id)) {
detectedItems.push({ menuItem: item, qty });
}
}
}
return detectedItems;
}
const QUICK_COMMANDS = [
{ label: '2 Butter Chicken aur Naan', text: '2 butter chicken and 2 garlic naan please' },
{ label: 'Hyderabadi Chicken Biryani', text: 'one hyderabadi chicken biryani for table 12' },
{ label: 'Veg Thali with Lassi', text: "chef's thali and one mango lassi" },
{ label: 'Paneer Tikka Starter', text: 'paneer tikka and masala chai' },
{ label: 'Seekh Kebab + Rogan Josh', text: 'seekh kebab starter and rogan josh main' },
];
export const VoiceAssistantModal: React.FC<VoiceAssistantModalProps> = ({
open,
onClose,
onAddToCart,
}) => {
const [internalVoiceState, setInternalVoiceState] = useState<VoiceState>('idle');
const [detectedItems, setDetectedItems] = useState<DetectedItem[]>([]);
const [confirmed, setConfirmed] = useState(false);
const [selectedLang, setSelectedLang] = useState<'en-IN' | 'hi-IN'>('en-IN');
const {
voiceState: recognitionState,
transcript,
interimTranscript,
isSupported,
startListening,
stopListening,
resetTranscript,
error,
} = useSpeechRecognition();
const { speak, cancel } = useSpeechSynthesis((s) => setInternalVoiceState(s));
const voiceState = internalVoiceState !== 'idle' ? internalVoiceState : recognitionState;
// Parse items when transcript updates
useEffect(() => {
if (transcript.trim().length > 0 && recognitionState === 'thinking') {
const items = parseTranscriptToItems(transcript);
setDetectedItems(items);
setInternalVoiceState('thinking');
// AI confirmation response via TTS
if (items.length > 0) {
const itemList = items.map((d) => `${d.qty} ${d.menuItem.name}`).join(', ');
setTimeout(() => {
speak(
`Got it! I found ${items.length} item${items.length > 1 ? 's' : ''}: ${itemList}. Please confirm to add them to your cart.`,
selectedLang,
() => setInternalVoiceState('idle')
);
}, 500);
} else {
setTimeout(() => {
speak(
"Sorry, I couldn't identify any items from your order. Please try again or use the quick commands below.",
selectedLang,
() => setInternalVoiceState('idle')
);
setInternalVoiceState('idle');
}, 400);
}
}
}, [transcript, recognitionState]);
const handleClose = useCallback(() => {
cancel();
resetTranscript();
setDetectedItems([]);
setConfirmed(false);
setInternalVoiceState('idle');
onClose();
}, [cancel, resetTranscript, onClose]);
const handleMicToggle = () => {
if (voiceState === 'listening') {
stopListening();
} else {
setDetectedItems([]);
setConfirmed(false);
resetTranscript();
startListening(selectedLang);
}
};
const handleQuickCommand = (text: string) => {
const items = parseTranscriptToItems(text);
setDetectedItems(items);
setInternalVoiceState('thinking');
if (items.length > 0) {
const itemList = items.map((d) => `${d.qty} ${d.menuItem.name}`).join(', ');
speak(
`Great choice! Found ${itemList}. Tap confirm to add to your cart.`,
selectedLang,
() => setInternalVoiceState('idle')
);
}
};
const handleConfirm = () => {
detectedItems.forEach((d) => onAddToCart(d.menuItem.id, d.qty));
setConfirmed(true);
speak('Items added to your cart! Enjoy your meal.', selectedLang);
setTimeout(() => {
handleClose();
}, 2000);
};
const orbColor = {
idle: { bg: 'linear-gradient(135deg, #ac2d00, #d53e0b)', shadow: '0 8px 24px rgba(172,45,0,0.4)' },
listening: { bg: 'linear-gradient(135deg, #d53e0b, #e75a2b)', shadow: '0 0 0 16px rgba(213,62,11,0.2), 0 0 0 32px rgba(172,45,0,0.1)' },
thinking: { bg: 'linear-gradient(135deg, #845000, #b07000)', shadow: '0 8px 24px rgba(132,80,0,0.5)' },
speaking: { bg: 'linear-gradient(135deg, #006a2e, #009c44)', shadow: '0 0 0 16px rgba(0,106,46,0.2), 0 0 0 32px rgba(0,106,46,0.1)' },
error: { bg: 'linear-gradient(135deg, #ba1a1a, #ff5449)', shadow: '0 8px 24px rgba(186,26,26,0.5)' },
};
const orbLabel = {
idle: 'Tap to Speak',
listening: '🎙️ Listening...',
thinking: '🤔 Processing...',
speaking: '🔊 AI Speaking...',
error: '⚠️ Try Again',
};
return (
<Drawer
anchor="bottom"
open={open}
onClose={handleClose}
slotProps={{
paper: {
sx: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '92vh',
overflow: 'hidden',
},
},
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', maxHeight: '92vh' }}>
{/* Handle Bar */}
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 0.5 }}>
<Box sx={{ width: 40, height: 4, borderRadius: 2, bgcolor: '#e0e0e0' }} />
</Box>
{/* Header */}
<Box sx={{ px: 3, pb: 2, pt: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 28 }} />
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c', lineHeight: 1.1 }}>
AI Voice Assistant
</Typography>
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600 }}>
Speak your order naturally in English or Hindi
</Typography>
</Box>
</Box>
<IconButton onClick={handleClose} size="small">
<CloseIcon />
</IconButton>
</Box>
{/* Scrollable Body */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 3 }}>
{!isSupported && (
<Alert severity="warning" sx={{ mb: 2, fontWeight: 700 }}>
Voice recognition requires Google Chrome browser. You can still use quick commands below.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2, fontWeight: 700 }}>
{error}
</Alert>
)}
{/* Voice Orb */}
<Box sx={{ textAlign: 'center', py: 3 }}>
<Box
onClick={handleMicToggle}
sx={{
width: 120,
height: 120,
borderRadius: '50%',
background: orbColor[voiceState].bg,
boxShadow: orbColor[voiceState].shadow,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
transform: voiceState === 'listening' || voiceState === 'speaking' ? 'scale(1.12)' : 'scale(1)',
mb: 2,
}}
>
{voiceState === 'listening' ? (
<GraphicEqIcon sx={{ color: '#fff', fontSize: 52 }} />
) : voiceState === 'speaking' ? (
<VolumeUpIcon sx={{ color: '#fff', fontSize: 52 }} />
) : voiceState === 'thinking' ? (
<SmartToyIcon sx={{ color: '#fff', fontSize: 52 }} />
) : (
<MicIcon sx={{ color: '#fff', fontSize: 52 }} />
)}
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}>
{orbLabel[voiceState]}
</Typography>
{voiceState === 'listening' && (
<Button
variant="outlined"
size="small"
startIcon={<StopIcon />}
onClick={stopListening}
sx={{ fontWeight: 700, color: '#ac2d00', borderColor: '#ac2d00' }}
>
Stop & Process
</Button>
)}
{/* Language Toggle */}
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 1.5 }}>
{['en-IN', 'hi-IN'].map((lang) => (
<Chip
key={lang}
label={lang === 'en-IN' ? '🇮🇳 English' : '🇮🇳 हिन्दी'}
size="small"
clickable
onClick={() => setSelectedLang(lang as 'en-IN' | 'hi-IN')}
color={selectedLang === lang ? 'primary' : 'default'}
variant={selectedLang === lang ? 'filled' : 'outlined'}
sx={{ fontWeight: 700 }}
/>
))}
</Box>
</Box>
{/* Live Transcript */}
{(transcript || interimTranscript) && (
<Paper
elevation={0}
sx={{
p: 2,
mb: 2,
borderRadius: '12px',
bgcolor: '#f6f7f8',
border: '2px solid #ac2d00',
}}
>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
WHAT I HEARD:
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
"{transcript}{interimTranscript && <span style={{ color: '#9e9e9e' }}>{interimTranscript}</span>}"
</Typography>
</Paper>
)}
{/* Detected Items */}
<Fade in={detectedItems.length > 0}>
<Box>
{detectedItems.length > 0 && (
<Paper
elevation={0}
sx={{
p: 2.5,
mb: 2,
borderRadius: '16px',
border: '1px solid #c8e6c9',
bgcolor: '#f1f8f2',
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#1b5e20', mb: 1.5 }}>
Items Detected:
</Typography>
{detectedItems.map((d) => (
<Box
key={d.menuItem.id}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 0.8,
borderBottom: '1px solid rgba(0,0,0,0.06)',
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
{d.qty}× {d.menuItem.name}
</Typography>
<Typography variant="caption" sx={{ color: '#546067' }}>
{(d.qty * d.menuItem.price).toFixed(0)}
</Typography>
</Box>
<Chip
label={d.menuItem.dietary === 'non-veg' ? '🔴 Non-Veg' : '🟢 Veg'}
size="small"
sx={{ fontWeight: 700 }}
/>
</Box>
))}
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
<Button
fullWidth
variant="outlined"
size="small"
onClick={() => { setDetectedItems([]); resetTranscript(); }}
sx={{ fontWeight: 700 }}
>
Retry
</Button>
<Button
fullWidth
variant="contained"
size="large"
startIcon={confirmed ? <CheckCircleIcon /> : <AddShoppingCartIcon />}
onClick={handleConfirm}
disabled={confirmed}
sx={{
fontWeight: 800,
bgcolor: confirmed ? '#006a2e' : '#ac2d00',
}}
>
{confirmed ? 'Added!' : 'Add to Cart'}
</Button>
</Box>
</Paper>
)}
</Box>
</Fade>
{/* Quick Commands */}
<Divider sx={{ my: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#8f7068' }}>
QUICK COMMANDS
</Typography>
</Divider>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{QUICK_COMMANDS.map((cmd, idx) => (
<Paper
key={idx}
elevation={0}
onClick={() => handleQuickCommand(cmd.text)}
sx={{
px: 2,
py: 1.5,
borderRadius: '12px',
border: '1px solid #e4beb4',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 1.5,
'&:hover': { bgcolor: '#ffdbd1', borderColor: '#ac2d00' },
transition: 'all 0.15s ease',
}}
>
<SmartToyIcon sx={{ color: '#ac2d00', fontSize: 18 }} />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
"{cmd.label}"
</Typography>
</Paper>
))}
</Box>
</Box>
</Box>
</Drawer>
);
};

View File

@ -0,0 +1,202 @@
import React, { useState } from 'react';
import {
Box,
Grid,
Paper,
Typography,
Chip,
Button,
TextField,
InputAdornment,
ButtonGroup,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add';
import RestaurantMenuIcon from '@mui/icons-material/RestaurantMenu';
import type { KDSOrder, OrderStatus, OrderType } from '../../types';
import { KDSTicketCard } from './KDSTicketCard';
interface KDSKanbanProps {
orders: KDSOrder[];
onStatusChange: (id: string, newStatus: OrderStatus) => void;
onToggleItem: (orderId: string, itemId: string) => void;
onAddSampleOrder: () => void;
}
export const KDSKanban: React.FC<KDSKanbanProps> = ({
orders,
onStatusChange,
onToggleItem,
onAddSampleOrder,
}) => {
const [filterType, setFilterType] = useState<'ALL' | OrderType>('ALL');
const [searchQuery, setSearchQuery] = useState('');
const filteredOrders = orders.filter((order) => {
const matchesType = filterType === 'ALL' || order.orderType === filterType;
const matchesSearch =
order.ticketNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
(order.tableNumber && order.tableNumber.includes(searchQuery)) ||
(order.customerName && order.customerName.toLowerCase().includes(searchQuery.toLowerCase())) ||
order.items.some((i) => i.name.toLowerCase().includes(searchQuery.toLowerCase()));
return matchesType && matchesSearch;
});
const columns: { status: OrderStatus; title: string; color: string; bg: string }[] = [
{ status: 'pending', title: 'PENDING', color: '#ba1a1a', bg: '#ffdad6' },
{ status: 'preparing', title: 'IN PREPARATION', color: '#845000', bg: '#ffddba' },
{ status: 'ready', title: 'READY FOR PICKUP / SERVE', color: '#11651d', bg: '#a3f69c' },
{ status: 'served', title: 'SERVED & COMPLETED', color: '#546067', bg: '#e2e2e2' },
];
return (
<Box sx={{ p: { xs: 1.5, md: 3 } }}>
{/* Top Filter Bar */}
<Paper
elevation={0}
sx={{
p: 2,
mb: 3,
display: 'flex',
flexWrap: 'wrap',
gap: 2,
alignItems: 'center',
justifyContent: 'space-between',
border: '1px solid #e2e2e2',
borderRadius: '12px',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', display: 'flex', alignItems: 'center', gap: 1 }}>
<RestaurantMenuIcon />
KDS Kanban Board
</Typography>
<ButtonGroup variant="outlined" size="small">
{(['ALL', 'Dine-In', 'Takeaway', 'Delivery'] as const).map((type) => (
<Button
key={type}
variant={filterType === type ? 'contained' : 'outlined'}
onClick={() => setFilterType(type)}
sx={{
fontWeight: 700,
color: filterType === type ? '#ffffff' : '#ac2d00',
borderColor: '#ac2d00',
'&:hover': {
borderColor: '#ac2d00',
},
}}
>
{type}
</Button>
))}
</ButtonGroup>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<TextField
placeholder="Search Ticket, Table, Item..."
size="small"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
sx={{ width: { xs: 200, sm: 260 } }}
/>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={onAddSampleOrder}
sx={{ fontWeight: 800 }}
>
NEW TEST ORDER
</Button>
</Box>
</Paper>
{/* 4 Column Kanban Board Grid */}
<Grid container spacing={2}>
{columns.map((col) => {
const colOrders = filteredOrders.filter((o) => o.status === col.status);
return (
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={col.status}>
<Paper
elevation={0}
sx={{
p: 1.5,
minHeight: '75vh',
backgroundColor: '#f6f7f8',
border: '1px solid #e4beb4',
borderRadius: '12px',
}}
>
{/* Column Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
pb: 1,
borderBottom: '2px solid #e2e2e2',
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: col.color, letterSpacing: '0.05em' }}>
{col.title}
</Typography>
<Chip
label={colOrders.length}
size="small"
sx={{
fontWeight: 800,
backgroundColor: col.bg,
color: col.color,
}}
/>
</Box>
{/* Column Tickets */}
<Box>
{colOrders.length === 0 ? (
<Box
sx={{
p: 4,
textAlign: 'center',
color: '#8f7068',
border: '2px dashed #e2e2e2',
borderRadius: '8px',
mt: 2,
}}
>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
No orders in {col.title.toLowerCase()}
</Typography>
</Box>
) : (
colOrders.map((order) => (
<KDSTicketCard
key={order.id}
order={order}
onStatusChange={onStatusChange}
onToggleItem={onToggleItem}
/>
))
)}
</Box>
</Paper>
</Grid>
);
})}
</Grid>
</Box>
);
};

View File

@ -0,0 +1,244 @@
import React from 'react';
import {
Card,
CardContent,
CardActions,
Typography,
Box,
Button,
Checkbox,
Divider,
Chip,
IconButton,
Tooltip,
} from '@mui/material';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import PriorityHighIcon from '@mui/icons-material/PriorityHigh';
import type { KDSOrder, OrderStatus } from '../../types';
import { StatusBadge } from '../common/StatusBadge';
interface KDSTicketCardProps {
order: KDSOrder;
onStatusChange: (id: string, newStatus: OrderStatus) => void;
onToggleItem: (orderId: string, itemId: string) => void;
}
export const KDSTicketCard: React.FC<KDSTicketCardProps> = ({
order,
onStatusChange,
onToggleItem,
}) => {
// Calculate SLA timer color
const isUrgent = order.timeElapsedMinutes >= 25 || order.priority === 'urgent';
const isWarning = order.timeElapsedMinutes >= 15 && order.timeElapsedMinutes < 25;
const timerBg = isUrgent ? '#ffdad6' : isWarning ? '#ffddba' : '#e2e2e2';
const timerColor = isUrgent ? '#93000a' : isWarning ? '#673d00' : '#1a1c1c';
const nextStatusMap: Record<OrderStatus, OrderStatus | null> = {
pending: 'preparing',
preparing: 'ready',
ready: 'served',
served: null,
};
const prevStatusMap: Record<OrderStatus, OrderStatus | null> = {
pending: null,
preparing: 'pending',
ready: 'preparing',
served: 'ready',
};
const nextStatus = nextStatusMap[order.status];
const prevStatus = prevStatusMap[order.status];
const actionButtonText = {
pending: 'START PREP',
preparing: 'MARK READY',
ready: 'COMPLETE / SERVE',
served: 'COMPLETED',
}[order.status];
const allItemsChecked = order.items.every((i) => i.completed);
return (
<Card
sx={{
mb: 2,
position: 'relative',
borderLeft: isUrgent ? '5px solid #ba1a1a' : isWarning ? '5px solid #845000' : '5px solid #ac2d00',
transition: 'transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out',
'&:hover': {
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
},
}}
>
{/* Ticket Header */}
<Box
sx={{
p: 1.5,
backgroundColor: '#f3f3f3',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid #e2e2e2',
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontFamily: '"JetBrains Mono", monospace', fontWeight: 800 }}>
#{order.ticketNumber}
</Typography>
<StatusBadge type={order.orderType} />
{order.priority === 'urgent' && (
<Chip
icon={<PriorityHighIcon fontSize="small" />}
label="URGENT"
size="small"
color="error"
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 800 }}
/>
)}
</Box>
<Typography variant="caption" sx={{ color: '#5b4139', display: 'block', mt: 0.2 }}>
{order.tableNumber ? `Table ${order.tableNumber}` : order.customerName || 'Walk-in Guest'}
{order.serverName && ` • Server: ${order.serverName}`}
</Typography>
</Box>
{/* SLA Timer Badge */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.4,
borderRadius: '6px',
backgroundColor: timerBg,
color: timerColor,
fontWeight: 700,
}}
>
<AccessTimeIcon fontSize="small" />
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: '0.8rem' }}>
{order.timeElapsedMinutes}m
</Typography>
</Box>
</Box>
{/* Ticket Body / Items */}
<CardContent sx={{ p: 1.5, '&:last-child': { pb: 1.5 } }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{order.items.map((item) => (
<Box
key={item.id}
onClick={() => onToggleItem(order.id, item.id)}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
p: 0.8,
borderRadius: '6px',
cursor: 'pointer',
backgroundColor: item.completed ? '#f0eded' : 'transparent',
textDecoration: item.completed ? 'line-through' : 'none',
opacity: item.completed ? 0.6 : 1,
'&:hover': {
backgroundColor: '#f6f3f2',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Checkbox
checked={!!item.completed}
size="small"
sx={{ p: 0, mt: 0.2, color: '#ac2d00', '&.Mui-checked': { color: '#11651d' } }}
/>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontFamily: '"JetBrains Mono", monospace',
color: '#ac2d00',
}}
>
{item.quantity}x
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{item.name}
</Typography>
{item.dietary && <StatusBadge dietary={item.dietary} />}
</Box>
{item.notes && (
<Typography
variant="caption"
sx={{
color: '#ba1a1a',
fontWeight: 700,
display: 'block',
mt: 0.2,
backgroundColor: '#ffdad6',
px: 0.8,
py: 0.2,
borderRadius: '4px',
}}
>
{item.notes}
</Typography>
)}
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
${(item.price * item.quantity).toFixed(2)}
</Typography>
</Box>
))}
</Box>
</CardContent>
<Divider />
{/* Ticket Footer Actions */}
<CardActions sx={{ p: 1, justifyContent: 'space-between', backgroundColor: '#fafafa' }}>
<Box>
{prevStatus && (
<Tooltip title={`Revert to ${prevStatus}`}>
<IconButton size="small" onClick={() => onStatusChange(order.id, prevStatus)}>
<ArrowBackIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{nextStatus ? (
<Button
size="small"
variant="contained"
color={allItemsChecked ? 'success' : order.status === 'pending' ? 'primary' : 'warning'}
onClick={() => onStatusChange(order.id, nextStatus)}
endIcon={<ArrowForwardIcon fontSize="small" />}
sx={{ fontWeight: 800, fontSize: '0.75rem' }}
>
{actionButtonText}
</Button>
) : (
<Chip
icon={<CheckCircleIcon fontSize="small" />}
label="SERVED"
color="success"
size="small"
sx={{ fontWeight: 800 }}
/>
)}
</CardActions>
</Card>
);
};

View File

@ -0,0 +1,283 @@
import React, { useState } from 'react';
import {
Box,
Grid,
Paper,
Typography,
Button,
Card,
CardMedia,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Divider,
Alert,
} from '@mui/material';
import TableRestaurantIcon from '@mui/icons-material/TableRestaurant';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import RestaurantIcon from '@mui/icons-material/Restaurant';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { StatusBadge } from '../common/StatusBadge';
export const TableLandingView: React.FC = () => {
const [waiterCalled, setWaiterCalled] = useState(false);
const [billOpen, setBillOpen] = useState(false);
const activeTableOrder = [
{ name: 'Butter Chicken Special', qty: 2, price: 18.0, total: 36.0 },
{ name: 'Paneer Tikka Masala', qty: 1, price: 16.0, total: 16.0 },
{ name: 'Truffle Garlic Naan', qty: 4, price: 5.0, total: 20.0 },
{ name: 'Mango Lassi Craft', qty: 3, price: 5.0, total: 15.0 },
];
const subtotal = activeTableOrder.reduce((acc, i) => acc + i.total, 0);
const tax = subtotal * 0.1;
const grandTotal = subtotal + tax;
const chefRecommendations = [
{
name: 'Gulab Jamun Flambé',
desc: 'Warm milk dumplings soaked in saffron syrup with pistachio crumble.',
price: '$8.50',
dietary: 'veg' as const,
image: 'https://images.unsplash.com/photo-1589301760014-d929f3979dbc?w=500&auto=format&fit=crop&q=60',
},
{
name: 'Masala Chai Gelato',
desc: 'Artisanal spice-infused Indian tea gelato topped with cardamom tuile.',
price: '$7.00',
dietary: 'veg' as const,
image: 'https://images.unsplash.com/photo-1563805042-7684c019e1cb?w=500&auto=format&fit=crop&q=60',
},
];
const handleCallWaiter = () => {
setWaiterCalled(true);
setTimeout(() => {
setWaiterCalled(false);
}, 4000);
};
return (
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1100, mx: 'auto' }}>
{/* Table Welcome Banner */}
<Paper
elevation={0}
sx={{
p: 3.5,
mb: 4,
borderRadius: '20px',
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
color: '#ffffff',
boxShadow: '0 8px 24px rgba(172, 45, 0, 0.25)',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}>
<Box>
<Chip
icon={<TableRestaurantIcon style={{ color: '#ffffff' }} />}
label="TABLE #12 • SEATED (4 GUESTS)"
sx={{ bgcolor: 'rgba(255, 255, 255, 0.2)', color: '#ffffff', fontWeight: 800, mb: 1 }}
/>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Welcome to RestroAI Dining
</Typography>
<Typography variant="body1" sx={{ opacity: 0.9, mt: 0.5 }}>
Active Session Started 42 mins ago Server: Rahul S.
</Typography>
</Box>
<Button
variant="contained"
size="large"
onClick={handleCallWaiter}
startIcon={waiterCalled ? <CheckCircleIcon /> : <NotificationsActiveIcon />}
sx={{
bgcolor: '#ffffff',
color: '#ac2d00',
fontWeight: 800,
px: 3,
py: 1.5,
'&:hover': {
bgcolor: '#ffdbd1',
},
}}
>
{waiterCalled ? 'WAITER NOTIFIED!' : 'CALL WAITER'}
</Button>
</Box>
</Paper>
{waiterCalled && (
<Alert severity="success" sx={{ mb: 3, fontWeight: 700 }}>
🔔 Alert sent to Server Rahul S. A staff member is on their way to Table 12!
</Alert>
)}
{/* Quick Action Grid */}
<Grid container spacing={2.5} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card
onClick={() => setBillOpen(true)}
sx={{
p: 2.5,
cursor: 'pointer',
bgcolor: '#ffffff',
border: '1px solid #e4beb4',
'&:hover': { bgcolor: '#ffdbd1', transform: 'translateY(-2px)' },
transition: 'all 0.2s ease',
}}
>
<ReceiptLongIcon sx={{ fontSize: 40, color: '#ac2d00', mb: 1 }} />
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
View Table Bill
</Typography>
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
Instant summary: ${grandTotal.toFixed(2)}
</Typography>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card
onClick={handleCallWaiter}
sx={{
p: 2.5,
cursor: 'pointer',
bgcolor: '#ffffff',
border: '1px solid #e4beb4',
'&:hover': { bgcolor: '#ffdbd1', transform: 'translateY(-2px)' },
transition: 'all 0.2s ease',
}}
>
<NotificationsActiveIcon sx={{ fontSize: 40, color: '#845000', mb: 1 }} />
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Request Water
</Typography>
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
Refill table water jug
</Typography>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card
sx={{
p: 2.5,
cursor: 'pointer',
bgcolor: '#ffffff',
border: '1px solid #e4beb4',
'&:hover': { bgcolor: '#ffdbd1', transform: 'translateY(-2px)' },
transition: 'all 0.2s ease',
}}
>
<RestaurantIcon sx={{ fontSize: 40, color: '#11651d', mb: 1 }} />
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Add Extra Breads
</Typography>
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
Quick re-order Naan / Roti
</Typography>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Card
sx={{
p: 2.5,
cursor: 'pointer',
bgcolor: '#ffffff',
border: '1px solid #e4beb4',
'&:hover': { bgcolor: '#ffdbd1', transform: 'translateY(-2px)' },
transition: 'all 0.2s ease',
}}
>
<LocalOfferIcon sx={{ fontSize: 40, color: '#00a6e0', mb: 1 }} />
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>
Special Offers
</Typography>
<Typography variant="caption" sx={{ color: '#5b4139', fontWeight: 600 }}>
15% off on Chef Desserts
</Typography>
</Card>
</Grid>
</Grid>
{/* AI Recommended Desserts */}
<Paper elevation={0} sx={{ p: 3, border: '1px solid #e2e2e2', borderRadius: '16px' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', mb: 2 }}>
Chef's Pairing Recommendations for Table 12
</Typography>
<Grid container spacing={2}>
{chefRecommendations.map((item, index) => (
<Grid size={{ xs: 12, sm: 6 }} key={index}>
<Card sx={{ display: 'flex', border: '1px solid #e2e2e2', borderRadius: '12px' }}>
<CardMedia component="img" sx={{ width: 110 }} image={item.image} alt={item.name} />
<Box sx={{ display: 'flex', flexDirection: 'column', p: 1.5, flex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{item.name}
</Typography>
<StatusBadge dietary={item.dietary} />
</Box>
<Typography variant="caption" sx={{ color: '#546067', my: 0.5 }}>
{item.desc}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#ac2d00' }}>
{item.price}
</Typography>
</Box>
</Card>
</Grid>
))}
</Grid>
</Paper>
{/* Digital Bill Modal */}
<Dialog open={billOpen} onClose={() => setBillOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800, color: '#ac2d00' }}>
Table 12 - Digital Bill Summary
</DialogTitle>
<DialogContent dividers>
{activeTableOrder.map((i, idx) => (
<Box key={idx} sx={{ display: 'flex', justifyContent: 'space-between', py: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{i.qty}x {i.name}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, fontFamily: 'monospace' }}>
${i.total.toFixed(2)}
</Typography>
</Box>
))}
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5 }}>
<Typography variant="body2" sx={{ color: '#5b4139' }}>Subtotal</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>${subtotal.toFixed(2)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5 }}>
<Typography variant="body2" sx={{ color: '#5b4139' }}>GST / Tax (10%)</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>${tax.toFixed(2)}</Typography>
</Box>
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 0.5 }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00' }}>Grand Total</Typography>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00' }}>${grandTotal.toFixed(2)}</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setBillOpen(false)} color="inherit">
Close
</Button>
<Button variant="contained" color="primary" onClick={() => setBillOpen(false)} sx={{ fontWeight: 800 }}>
Request Pay via Server
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@ -0,0 +1,210 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Button,
Chip,
Grid,
List,
ListItem,
ListItemText,
Paper,
Divider,
} from '@mui/material';
import MicIcon from '@mui/icons-material/Mic';
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart';
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
import type { VoiceCommandSuggestion } from '../../types';
interface VoiceOrderingViewProps {
onAddOrderFromVoice: (transcript: string) => void;
}
export const VoiceOrderingView: React.FC<VoiceOrderingViewProps> = ({ onAddOrderFromVoice }) => {
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState(
'Do Butter Chicken, teen Garlic Naan aur ek Mango Lassi Table 12 ke liye'
);
const [detectedItems] = useState([
{ name: 'Butter Chicken', qty: 2, price: 360, dietary: 'non-veg' as const },
{ name: 'Garlic Naan', qty: 3, price: 80, dietary: 'veg' as const },
{ name: 'Mango Lassi', qty: 1, price: 120, dietary: 'veg' as const },
]);
const [submitted, setSubmitted] = useState(false);
const samplePrompts: VoiceCommandSuggestion[] = [
{
id: '1',
label: '2 Butter Chicken & Garlic Naan Table 12',
prompt: 'Do Butter Chicken aur do Garlic Naan Table 12 ke liye',
category: 'order',
},
{
id: '2',
label: 'Hyderabadi Biryani + Raita Table 8',
prompt: 'Ek Hyderabadi Chicken Biryani with extra raita for Table 8',
category: 'order',
},
{
id: '3',
label: 'Jain Palak Paneer no onion no garlic',
prompt: 'Add 1 Palak Paneer Jain preparation no onion no garlic for Table 4',
category: 'order',
},
{
id: '4',
label: 'Extra Cutlery + Tissue for Table 3',
prompt: 'Please send extra cutlery and tissue paper to Table 3',
category: 'action',
},
];
const handleConfirmOrder = () => {
onAddOrderFromVoice(transcript);
setSubmitted(true);
setTimeout(() => setSubmitted(false), 3000);
};
const totalValue = detectedItems.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1100, mx: 'auto' }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#ac2d00', mb: 1 }}>
Staff Voice Assistant Terminal
</Typography>
<Typography variant="body1" sx={{ color: '#5b4139' }}>
Hands-free voice intake for servers & kitchen staff. Supports Hindi, English & Hinglish.
</Typography>
</Box>
{/* Voice Orb */}
<Paper
elevation={0}
sx={{
p: 4,
mb: 4,
textAlign: 'center',
borderRadius: '24px',
background: 'linear-gradient(180deg, #ffffff 0%, #ffdbd1 100%)',
border: '1px solid #e4beb4',
}}
>
<Box
onClick={() => setIsListening(!isListening)}
sx={{
width: 130,
height: 130,
borderRadius: '50%',
mx: 'auto',
mb: 3,
cursor: 'pointer',
background: isListening
? 'radial-gradient(circle, #d53e0b 0%, #ac2d00 70%)'
: 'radial-gradient(circle, #ac2d00 0%, #872100 100%)',
boxShadow: isListening
? '0 0 0 18px rgba(213, 62, 11, 0.2), 0 0 0 36px rgba(172, 45, 0, 0.1)'
: '0 4px 20px rgba(172, 45, 0, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
transition: 'all 0.3s ease-in-out',
transform: isListening ? 'scale(1.08)' : 'scale(1)',
}}
>
{isListening ? <GraphicEqIcon sx={{ fontSize: 60 }} /> : <MicIcon sx={{ fontSize: 60 }} />}
</Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#ac2d00', mb: 0.5 }}>
{isListening ? '🎙️ Listening & Parsing Voice Input...' : 'Tap Orb or Speak to Begin Order'}
</Typography>
<Typography variant="caption" sx={{ color: '#546067', fontWeight: 600, display: 'block', mb: 3 }}>
Multilingual: English · Hindi · Hinglish
</Typography>
{/* Live Transcript */}
<Paper
elevation={0}
sx={{ p: 2.5, maxWidth: 700, mx: 'auto', bgcolor: '#ffffff', borderRadius: '12px', border: '2px solid #ac2d00', textAlign: 'left' }}
>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#ac2d00', display: 'block', mb: 0.5 }}>
LIVE TRANSCRIPT:
</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, fontStyle: 'italic', color: '#1a1c1c' }}>
"{transcript}"
</Typography>
</Paper>
</Paper>
{/* Quick Prompts */}
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#546067', mb: 1.5, textTransform: 'uppercase' }}>
Suggested Quick Voice Commands
</Typography>
<Grid container spacing={1.5}>
{samplePrompts.map((s) => (
<Grid size={{ xs: 12, sm: 6 }} key={s.id}>
<Chip
icon={<SmartToyIcon fontSize="small" />}
label={s.label}
onClick={() => { setTranscript(s.prompt); setIsListening(true); setTimeout(() => setIsListening(false), 1500); }}
clickable
sx={{ width: '100%', justifyContent: 'flex-start', py: 2.5, px: 1, fontWeight: 700, bgcolor: '#ffffff', border: '1px solid #e4beb4', '&:hover': { bgcolor: '#ffdbd1' } }}
/>
</Grid>
))}
</Grid>
</Box>
{/* Detected Items */}
<Paper elevation={0} sx={{ p: 3, border: '1px solid #e2e2e2', borderRadius: '16px' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1a1c1c' }}>AI Extracted Items</Typography>
<Chip label="Table 12" color="primary" sx={{ fontWeight: 800 }} />
</Box>
<List disablePadding>
{detectedItems.map((item, idx) => (
<ListItem
key={idx}
sx={{ py: 1.5, px: 2, mb: 1, borderRadius: '8px', bgcolor: '#f6f7f8', display: 'flex', justifyContent: 'space-between' }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h6" sx={{ color: '#ac2d00', fontWeight: 800, fontFamily: 'monospace' }}>
{item.qty}×
</Typography>
<ListItemText
primary={<Typography sx={{ fontWeight: 700 }}>{item.name}</Typography>}
secondary={`Subtotal: ₹${(item.qty * item.price)}`}
/>
</Box>
<Chip label={item.dietary.toUpperCase()} size="small" variant="outlined" sx={{ fontWeight: 700 }} />
</ListItem>
))}
</List>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>Total: {totalValue}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
size="large"
disabled={submitted}
startIcon={submitted ? <CheckCircleOutlinedIcon /> : <AddShoppingCartIcon />}
onClick={handleConfirmOrder}
sx={{ fontWeight: 800, px: 4 }}
>
{submitted ? 'ORDER SENT TO KDS!' : 'DISPATCH TO KDS'}
</Button>
</Box>
</Paper>
</Box>
);
};

376
src/data/menuData.ts Normal file
View File

@ -0,0 +1,376 @@
export interface MenuItem {
id: string;
name: string;
nameHindi?: string;
category: 'Starters' | 'Biryani' | 'Mains' | 'Breads' | 'Drinks' | 'Desserts' | 'Specials';
price: number; // INR ₹
prepTimeMinutes: number;
dietary: 'veg' | 'non-veg' | 'jain' | 'vegan';
isChefSpecial?: boolean;
isBestseller?: boolean;
description: string;
image: string;
spiceLevel?: 'mild' | 'medium' | 'hot' | 'extra-hot';
tags?: string[];
}
export const MENU_ITEMS: MenuItem[] = [
// ─── STARTERS ───────────────────────────────────────────────────
{
id: 'm1',
name: 'Samosa Chaat',
nameHindi: 'समोसा चाट',
category: 'Starters',
price: 160,
prepTimeMinutes: 8,
dietary: 'veg',
isBestseller: true,
description: 'Crispy golden samosas crushed and topped with tangy tamarind chutney, mint chutney, sev, and fresh pomegranate.',
image: 'https://images.unsplash.com/photo-1630409346824-4f0e7b080087?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'medium',
tags: ['Popular', 'Street Food'],
},
{
id: 'm2',
name: 'Paneer Tikka',
nameHindi: 'पनीर टिक्का',
category: 'Starters',
price: 280,
prepTimeMinutes: 15,
dietary: 'veg',
isChefSpecial: true,
description: 'Marinated cottage cheese cubes grilled in tandoor with bell peppers, onions and a smoky tikka masala spice blend.',
image: 'https://images.unsplash.com/photo-1567188040759-fb8a883dc6d8?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'medium',
tags: ["Chef's Special", 'Tandoor'],
},
{
id: 'm3',
name: 'Chicken 65',
nameHindi: 'चिकन 65',
category: 'Starters',
price: 320,
prepTimeMinutes: 15,
dietary: 'non-veg',
isBestseller: true,
description: 'South Indian style crispy deep-fried chicken marinated in yogurt, red chilli, ginger-garlic and curry leaves.',
image: 'https://images.unsplash.com/photo-1610057099443-fde8c4d50f91?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'hot',
tags: ['South Indian', 'Crispy'],
},
{
id: 'm4',
name: 'Hara Bhara Kabab',
nameHindi: 'हरा भरा कबाब',
category: 'Starters',
price: 240,
prepTimeMinutes: 12,
dietary: 'veg',
description: 'Spinach and peas patties blended with paneer and mild spices, pan-fried golden. Served with mint chutney.',
image: 'https://images.unsplash.com/photo-1555126634-323283e090fa?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'mild',
tags: ['Healthy', 'Spinach'],
},
{
id: 'm5',
name: 'Seekh Kebab',
nameHindi: 'सीख कबाब',
category: 'Starters',
price: 340,
prepTimeMinutes: 18,
dietary: 'non-veg',
isChefSpecial: true,
description: 'Minced lamb kebabs skewered and slow-cooked over charcoal. Served with laccha onions and green chutney.',
image: 'https://images.unsplash.com/photo-1599487488170-d11ec9c172f0?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'medium',
tags: ['Charcoal Grilled', "Chef's Special"],
},
// ─── BIRYANI ────────────────────────────────────────────────────
{
id: 'm6',
name: 'Hyderabadi Chicken Biryani',
nameHindi: 'हैदराबादी चिकन बिरयानी',
category: 'Biryani',
price: 380,
prepTimeMinutes: 30,
dietary: 'non-veg',
isChefSpecial: true,
isBestseller: true,
description: 'Slow-cooked dum biryani with tender chicken pieces, aged basmati rice, saffron, caramelized onions and whole spices.',
image: 'https://images.unsplash.com/photo-1563379091339-03b21ab4a4f8?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'medium',
tags: ['Dum Style', 'Hyderabadi', 'Bestseller'],
},
{
id: 'm7',
name: 'Veg Dum Biryani',
nameHindi: 'वेज दम बिरयानी',
category: 'Biryani',
price: 300,
prepTimeMinutes: 25,
dietary: 'veg',
description: 'Fragrant basmati rice layered with seasonal vegetables, paneer, saffron and aromatic whole spices. Sealed and dum cooked.',
image: 'https://images.unsplash.com/photo-1600891964599-f61ba0e24092?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'medium',
tags: ['Dum Style', 'Veg'],
},
{
id: 'm8',
name: 'Mutton Biryani',
nameHindi: 'मटन बिरयानी',
category: 'Biryani',
price: 460,
prepTimeMinutes: 35,
dietary: 'non-veg',
description: 'Tender slow-braised mutton pieces layered with fragrant saffron rice, fried onions, fresh mint and yogurt marinade.',
image: 'https://images.unsplash.com/photo-1589302168068-964664d93dc0?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'hot',
tags: ['Premium', 'Slow Cooked'],
},
// ─── MAINS ──────────────────────────────────────────────────────
{
id: 'm9',
name: 'Butter Chicken',
nameHindi: 'बटर चिकन',
category: 'Mains',
price: 360,
prepTimeMinutes: 20,
dietary: 'non-veg',
isBestseller: true,
description: 'Iconic tandoori chicken simmered in velvety smooth tomato-butter-cream gravy with aromatic spices. Pairs perfectly with naan.',
image: 'https://images.unsplash.com/photo-1588166524941-3bf61a9c41db?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'mild',
tags: ['Classic', 'Bestseller'],
},
{
id: 'm10',
name: 'Dal Makhani',
nameHindi: 'दाल मखनी',
category: 'Mains',
price: 280,
prepTimeMinutes: 20,
dietary: 'veg',
isBestseller: true,
description: 'Black lentils and red kidney beans slow-cooked overnight in a rich buttery tomato gravy. A Punjabi classic.',
image: 'https://images.unsplash.com/photo-1546833999-b9f581a1996d?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'mild',
tags: ['Punjabi', 'Classic'],
},
{
id: 'm11',
name: 'Palak Paneer',
nameHindi: 'पालक पनीर',
category: 'Mains',
price: 300,
prepTimeMinutes: 18,
dietary: 'jain',
description: 'Fresh cottage cheese cubes in a vibrant, spiced pureed spinach gravy. Jain preparation available without onion and garlic.',
image: 'https://images.unsplash.com/photo-1626132647523-66d34b9f7f99?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'mild',
tags: ['Healthy', 'Jain Available'],
},
{
id: 'm12',
name: 'Chicken Kadhai',
nameHindi: 'चिकन कड़ाही',
category: 'Mains',
price: 380,
prepTimeMinutes: 22,
dietary: 'non-veg',
isChefSpecial: true,
description: 'Bone-in chicken cooked in a traditional iron kadhai with fresh tomatoes, capsicum, ginger and whole spices.',
image: 'https://images.unsplash.com/photo-1631452180519-c014fe946bc7?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'hot',
tags: ['Kadhai Style', "Chef's Special"],
},
{
id: 'm13',
name: 'Paneer Butter Masala',
nameHindi: 'पनीर बटर मसाला',
category: 'Mains',
price: 320,
prepTimeMinutes: 18,
dietary: 'veg',
description: 'Soft paneer chunks in a rich, creamy, mildly spiced tomato-cashew-butter gravy. A restaurant favourite.',
image: 'https://images.unsplash.com/photo-1565557623262-b51c2513a641?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'mild',
tags: ['Popular'],
},
{
id: 'm14',
name: 'Rogan Josh',
nameHindi: 'रोगन जोश',
category: 'Mains',
price: 440,
prepTimeMinutes: 25,
dietary: 'non-veg',
description: 'Authentic Kashmiri slow-braised mutton in a deep burgundy gravy of Kashmiri chillies, fennel, and dry ginger.',
image: 'https://images.unsplash.com/photo-1545247181-516773cae754?w=600&auto=format&fit=crop&q=80',
spiceLevel: 'medium',
tags: ['Kashmiri', 'Premium'],
},
// ─── BREADS ──────────────────────────────────────────────────────
{
id: 'm15',
name: 'Butter Naan',
nameHindi: 'बटर नान',
category: 'Breads',
price: 60,
prepTimeMinutes: 5,
dietary: 'veg',
description: 'Soft, fluffy leavened bread freshly baked in a tandoor and generously brushed with salted cultured butter.',
image: 'https://images.unsplash.com/photo-1601050690597-df0568f70950?w=600&auto=format&fit=crop&q=80',
tags: ['Tandoor'],
},
{
id: 'm16',
name: 'Garlic Naan',
nameHindi: 'लहसुन नान',
category: 'Breads',
price: 80,
prepTimeMinutes: 5,
dietary: 'veg',
isBestseller: true,
description: 'Tandoori naan generously topped with roasted garlic, fresh coriander, and whipped butter. Best paired with any gravy.',
image: 'https://images.unsplash.com/photo-1574071318508-1cdbab80d002?w=600&auto=format&fit=crop&q=80',
tags: ['Bestseller', 'Tandoor'],
},
{
id: 'm17',
name: 'Lachha Paratha',
nameHindi: 'लच्छा पराठा',
category: 'Breads',
price: 70,
prepTimeMinutes: 7,
dietary: 'veg',
description: 'Crispy, flaky multi-layered whole wheat paratha cooked on a tawa with pure ghee. A Punjabi comfort classic.',
image: 'https://images.unsplash.com/photo-1667872078958-4c47b44f4f37?w=600&auto=format&fit=crop&q=80',
tags: ['Ghee', 'Crispy'],
},
// ─── DRINKS ──────────────────────────────────────────────────────
{
id: 'm18',
name: 'Mango Lassi',
nameHindi: 'आम की लस्सी',
category: 'Drinks',
price: 120,
prepTimeMinutes: 3,
dietary: 'veg',
isBestseller: true,
description: 'Fresh Alphonso mango puree blended with thick homemade yogurt, a pinch of cardamom and rose water. Chilled and served tall.',
image: 'https://images.unsplash.com/photo-1527161153332-99adcc6f2966?w=600&auto=format&fit=crop&q=80',
tags: ['Summer Special', 'Fresh'],
},
{
id: 'm19',
name: 'Masala Chai',
nameHindi: 'मसाला चाय',
category: 'Drinks',
price: 80,
prepTimeMinutes: 5,
dietary: 'veg',
description: 'Aromatic Indian spiced tea brewed with ginger, cardamom, cloves, cinnamon and full-fat milk. Served in a kulhad.',
image: 'https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=600&auto=format&fit=crop&q=80',
tags: ['Kulhad', 'Hot'],
},
{
id: 'm20',
name: 'Sweet Lime Soda',
nameHindi: 'नींबू सोडा',
category: 'Drinks',
price: 90,
prepTimeMinutes: 3,
dietary: 'vegan',
description: 'Freshly squeezed lime juice with black salt, roasted cumin, and chilled sparkling soda. Refreshing and tangy.',
image: 'https://images.unsplash.com/photo-1513558161293-cdaf765ed2fd?w=600&auto=format&fit=crop&q=80',
tags: ['Fresh', 'Refreshing'],
},
{
id: 'm21',
name: 'Rose Sharbat',
nameHindi: 'गुलाब शरबत',
category: 'Drinks',
price: 100,
prepTimeMinutes: 3,
dietary: 'vegan',
description: 'Traditional rose syrup with chilled water, sabja seeds and a splash of lime. A classic summer cooler.',
image: 'https://images.unsplash.com/photo-1541014741259-de529411b96a?w=600&auto=format&fit=crop&q=80',
tags: ['Refreshing', 'Traditional'],
},
// ─── DESSERTS ────────────────────────────────────────────────────
{
id: 'm22',
name: 'Gulab Jamun',
nameHindi: 'गुलाब जामुन',
category: 'Desserts',
price: 140,
prepTimeMinutes: 5,
dietary: 'veg',
isBestseller: true,
description: 'Soft, melt-in-your-mouth khoya dumplings soaked in warm cardamom and saffron-scented rose sugar syrup. Served hot.',
image: 'https://images.unsplash.com/photo-1666190494547-9e02d01c32e2?w=600&auto=format&fit=crop&q=80',
tags: ['Hot', 'Classic'],
},
{
id: 'm23',
name: 'Rasmalai',
nameHindi: 'रसमलाई',
category: 'Desserts',
price: 160,
prepTimeMinutes: 5,
dietary: 'veg',
isChefSpecial: true,
description: 'Delicate soft chenna dumplings soaked in chilled thickened saffron milk (rabri) garnished with crushed pistachios.',
image: 'https://images.unsplash.com/photo-1601303516534-bf4c44e9fedb?w=600&auto=format&fit=crop&q=80',
tags: ['Bengali', "Chef's Special", 'Cold'],
},
{
id: 'm24',
name: 'Gajar Halwa',
nameHindi: 'गाजर का हलवा',
category: 'Desserts',
price: 150,
prepTimeMinutes: 5,
dietary: 'veg',
description: 'Slow-cooked grated carrots in pure ghee, whole milk, sugar, and khoya. Garnished with almonds and cardamom.',
image: 'https://images.unsplash.com/photo-1604152135912-04a022e23696?w=600&auto=format&fit=crop&q=80',
tags: ['Winter Special', 'Ghee'],
},
// ─── SPECIALS ─────────────────────────────────────────────────────
{
id: 'm25',
name: 'Chef\'s Thali',
nameHindi: 'शेफ की थाली',
category: 'Specials',
price: 550,
prepTimeMinutes: 25,
dietary: 'veg',
isChefSpecial: true,
isBestseller: true,
description: 'A complete Indian feast: Dal Makhani, Paneer Butter Masala, 2 Rotis, Steamed Rice, Salad, Papad, Pickle and Gulab Jamun.',
image: 'https://images.unsplash.com/photo-1585937421612-70a008356fbe?w=600&auto=format&fit=crop&q=80',
tags: ['Thali', 'Value', 'Complete Meal'],
},
{
id: 'm26',
name: 'Non-Veg Thali',
nameHindi: 'नॉन-वेज थाली',
category: 'Specials',
price: 650,
prepTimeMinutes: 30,
dietary: 'non-veg',
isChefSpecial: true,
description: 'Butter Chicken, Mutton Keema, 3 Butter Naans, Steamed Rice, Raita, Salad, Papad, Pickle and Gulab Jamun.',
image: 'https://images.unsplash.com/photo-1567337710282-00832b415979?w=600&auto=format&fit=crop&q=80',
tags: ['Thali', 'Value', 'Complete Meal'],
},
];
export const CATEGORIES = ['All', 'Starters', 'Biryani', 'Mains', 'Breads', 'Drinks', 'Desserts', 'Specials'] as const;
export type CategoryType = typeof CATEGORIES[number];

View File

@ -0,0 +1,153 @@
import { useState, useEffect, useRef, useCallback } from 'react';
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList;
resultIndex: number;
}
interface SpeechRecognitionErrorEvent extends Event {
error: string;
}
interface ISpeechRecognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
maxAlternatives: number;
start(): void;
stop(): void;
abort(): void;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
}
declare global {
interface Window {
SpeechRecognition: new () => ISpeechRecognition;
webkitSpeechRecognition: new () => ISpeechRecognition;
}
}
export type VoiceState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'error';
interface UseSpeechRecognitionReturn {
voiceState: VoiceState;
transcript: string;
interimTranscript: string;
isSupported: boolean;
startListening: (lang?: string) => void;
stopListening: () => void;
resetTranscript: () => void;
error: string | null;
}
export const useSpeechRecognition = (): UseSpeechRecognitionReturn => {
const [voiceState, setVoiceState] = useState<VoiceState>('idle');
const [transcript, setTranscript] = useState<string>('');
const [interimTranscript, setInterimTranscript] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const recognitionRef = useRef<ISpeechRecognition | null>(null);
const isSupported =
typeof window !== 'undefined' &&
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
useEffect(() => {
return () => {
recognitionRef.current?.abort();
};
}, []);
const startListening = useCallback((lang: string = 'en-IN') => {
if (!isSupported) {
setError('Voice recognition is not supported in this browser. Please use Chrome.');
setVoiceState('error');
return;
}
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognitionAPI();
recognitionRef.current = recognition;
recognition.lang = lang;
recognition.continuous = false;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.onstart = () => {
setVoiceState('listening');
setError(null);
setInterimTranscript('');
};
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interim = '';
let final = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) {
final += result[0].transcript;
} else {
interim += result[0].transcript;
}
}
setInterimTranscript(interim);
if (final) {
setTranscript((prev) => prev + ' ' + final.trim());
setVoiceState('thinking');
}
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
const errorMessages: Record<string, string> = {
'no-speech': 'No speech detected. Please try again.',
'audio-capture': 'Microphone not accessible.',
'not-allowed': 'Microphone permission denied. Please allow access.',
'network': 'Network error. Check your internet connection.',
};
setError(errorMessages[event.error] || `Voice error: ${event.error}`);
setVoiceState('error');
};
recognition.onend = () => {
setInterimTranscript('');
if (voiceState === 'listening') {
setVoiceState('thinking');
}
};
try {
recognition.start();
} catch (e) {
setError('Could not start voice recognition.');
setVoiceState('error');
}
}, [isSupported, voiceState]);
const stopListening = useCallback(() => {
recognitionRef.current?.stop();
setVoiceState('thinking');
}, []);
const resetTranscript = useCallback(() => {
setTranscript('');
setInterimTranscript('');
setVoiceState('idle');
setError(null);
}, []);
return {
voiceState,
transcript,
interimTranscript,
isSupported,
startListening,
stopListening,
resetTranscript,
error,
};
};

View File

@ -0,0 +1,52 @@
import { useCallback } from 'react';
import type { VoiceState } from './useSpeechRecognition';
interface UseSpeechSynthesisReturn {
speak: (text: string, lang?: string, onEnd?: () => void) => void;
cancel: () => void;
isSupported: boolean;
}
export const useSpeechSynthesis = (
setVoiceState?: (state: VoiceState) => void
): UseSpeechSynthesisReturn => {
const isSupported =
typeof window !== 'undefined' && 'speechSynthesis' in window;
const speak = useCallback(
(text: string, lang: string = 'en-IN', onEnd?: () => void) => {
if (!isSupported) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang;
utterance.rate = 0.95;
utterance.pitch = 1.05;
utterance.volume = 1;
utterance.onstart = () => {
setVoiceState?.('speaking');
};
utterance.onend = () => {
setVoiceState?.('idle');
onEnd?.();
};
utterance.onerror = () => {
setVoiceState?.('idle');
};
window.speechSynthesis.speak(utterance);
},
[isSupported, setVoiceState]
);
const cancel = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.cancel();
setVoiceState?.('idle');
}, [isSupported, setVoiceState]);
return { speak, cancel, isSupported };
};

111
src/index.css Normal file
View File

@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

913
src/services/api.ts Normal file
View File

@ -0,0 +1,913 @@
import { type MenuItem, MENU_ITEMS } from '../data/menuData';
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000';
// Helpers to get/set auth tokens
const getAccessToken = () => localStorage.getItem('access_token');
export const getRefreshToken = () => localStorage.getItem('refresh_token');
const setTokens = (access: string, refresh: string) => {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
};
const clearTokens = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
};
const getCustomerToken = () => sessionStorage.getItem('customer_session_token');
const getCustomerSessionId = () => sessionStorage.getItem('customer_session_id');
const setCustomerSession = (token: string, sessionId: string, tableId: string, restaurantId: string) => {
sessionStorage.setItem('customer_session_token', token);
sessionStorage.setItem('customer_session_id', sessionId);
sessionStorage.setItem('customer_table_id', tableId);
sessionStorage.setItem('customer_restaurant_id', restaurantId);
};
export const clearCustomerSession = () => {
sessionStorage.removeItem('customer_session_token');
sessionStorage.removeItem('customer_session_id');
sessionStorage.removeItem('customer_table_id');
sessionStorage.removeItem('customer_restaurant_id');
};
// Map front-end item ID (e.g. 'm1') to back-end numeric ID
export const getBackendMenuId = (frontendId: string): number => {
return parseInt(frontendId.replace(/[^0-9]/g, '')) || 1;
};
// Map back-end ID to front-end menu item
export const getFrontendMenuItem = (backendId: number | string): MenuItem | undefined => {
const numId = typeof backendId === 'number' ? backendId : parseInt(String(backendId)) || 1;
return MENU_ITEMS.find(m => m.id === `m${numId}`) || MENU_ITEMS[0];
};
// Connection State tracking
export let isDemoMode = false;
let onDemoModeChangeCallback: ((demo: boolean) => void) | null = null;
export const setDemoModeChangeCallback = (cb: (demo: boolean) => void) => {
onDemoModeChangeCallback = cb;
};
const triggerDemoMode = (active: boolean) => {
if (isDemoMode !== active) {
isDemoMode = active;
if (onDemoModeChangeCallback) {
onDemoModeChangeCallback(active);
}
}
};
// In-memory mock database for Demo Mode fallback
const mockDb = {
tables: [
{ id: 1, number: '1', status: 'available', qr_codes: [{ encoded_url: 'http://localhost:3000/scan/1', qr_image_path: '' }] },
{ id: 2, number: '2', status: 'available', qr_codes: [{ encoded_url: 'http://localhost:3000/scan/2', qr_image_path: '' }] },
{ id: 3, number: '3', status: 'available', qr_codes: [{ encoded_url: 'http://localhost:3000/scan/3', qr_image_path: '' }] },
{ id: 4, number: '4', status: 'occupied', qr_codes: [{ encoded_url: 'http://localhost:3000/scan/4', qr_image_path: '' }] },
{ id: 5, number: '5', status: 'available', qr_codes: [{ encoded_url: 'http://localhost:3000/scan/5', qr_image_path: '' }] },
{ id: 8, number: '8', status: 'available', qr_codes: [{ encoded_url: 'http://localhost:3000/scan/8', qr_image_path: '' }] },
{ id: 12, number: '12', status: 'available', qr_codes: [{ encoded_url: 'http://localhost:3000/scan/12', qr_image_path: '' }] },
],
orders: [] as any[],
ingredients: [
{ id: '1', name: 'Chicken Breast', stock: 15, unit: 'kg', minStock: 5 },
{ id: '2', name: 'Basmati Rice', stock: 45, unit: 'kg', minStock: 10 },
{ id: '3', name: 'Cottage Cheese (Paneer)', stock: 3, unit: 'kg', minStock: 5 },
{ id: '4', name: 'Butter', stock: 8, unit: 'kg', minStock: 3 },
{ id: '5', name: 'Garlic', stock: 2, unit: 'kg', minStock: 2 },
],
recipes: {} as Record<number, { ingredientId: string; qty: number }[]>,
suppliers: [
{ id: '1', name: 'Delhi Agro Farms', phone: '9876543210', email: 'agro@farm.com', address: 'Delhi Outer Ring Rd' },
{ id: '2', name: 'Punjab Dairy Syndicate', phone: '9123456780', email: 'contact@pb-dairy.com', address: 'Amritsar bypass' },
],
purchaseOrders: [] as any[],
reservations: [
{ id: 'res-1', name: 'Mehta Party', size: 4, tableId: 3, time: '19:30', date: new Date().toISOString().split('T')[0], status: 'confirmed' },
{ id: 'res-2', name: 'Kapoor Diner', size: 2, tableId: 5, time: '20:30', date: new Date().toISOString().split('T')[0], status: 'seated' },
],
shifts: [
{ id: 'shift-1', staffName: 'Chef Rahul S.', role: 'chef', clockIn: '09:00', clockOut: '17:00', date: new Date().toISOString().split('T')[0], status: 'completed' },
{ id: 'shift-2', staffName: 'Waiter Amit K.', role: 'waiter', clockIn: '10:00', clockOut: '', date: new Date().toISOString().split('T')[0], status: 'active' },
],
staff: [
{ id: '1', name: 'Chef Rahul S.', role: 'chef', email: 'chef@test.com' },
{ id: '2', name: 'Waiter Amit K.', role: 'waiter', email: 'waiter@test.com' },
{ id: '3', name: 'Manager Sarah D.', role: 'manager', email: 'manager@test.com' },
],
feedback: [
{ id: 'f-1', star_rating: 5, comment_text: 'Excellent Dum Biryani!', date: new Date().toISOString().split('T')[0] },
{ id: 'f-2', star_rating: 4, comment_text: 'Naan was super soft, butter chicken could be sweeter', date: new Date().toISOString().split('T')[0] },
],
bills: [] as any[],
};
// Generic HTTP fetch wrapper with Demo fallback support
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
const url = `${BASE_URL}${path}`;
const headers = new Headers(options.headers || {});
// Inject Staff Auth Token
const staffToken = getAccessToken();
if (staffToken && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${staffToken}`);
}
// Inject Customer Session Token
const customerToken = getCustomerToken();
if (customerToken && !headers.has('X-Session-Token')) {
headers.set('X-Session-Token', customerToken);
}
// Ensure content-type
if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
try {
const res = await fetch(url, { ...options, headers });
triggerDemoMode(false);
if (res.status === 401 && staffToken) {
// Token expired, clear it
clearTokens();
window.location.reload();
throw new Error('Staff session expired. Please log in again.');
}
if (!res.ok) {
const errText = await res.text();
let parsedErr;
try {
parsedErr = JSON.parse(errText);
} catch {
parsedErr = { error: errText };
}
throw new Error(parsedErr.error || parsedErr.message || `Request failed: ${res.status}`);
}
return await res.json();
} catch (error) {
console.warn(`API Request to ${path} failed, falling back to mock database.`, error);
triggerDemoMode(true);
return handleMockRequest(path, options);
}
}
// Intercept requests and simulate them in-memory
function handleMockRequest(path: string, options: RequestInit): any {
const method = options.method || 'GET';
const body = options.body ? (typeof options.body === 'string' ? JSON.parse(options.body) : options.body) : null;
// 1. Health
if (path === '/health') {
return { status: 'healthy', database: 'connected', mode: 'demo' };
}
// 2. Auth Login
if (path === '/auth/login') {
const bodyStr = typeof options.body === 'string' ? options.body : '';
const params = new URLSearchParams(bodyStr);
const username = params.get('username') || 'manager@test.com';
const role = username.includes('manager') ? 'manager' : 'kitchen';
return {
access_token: `mock-jwt-token-for-${username}`,
refresh_token: 'mock-refresh-token',
token_type: 'bearer',
role: role
};
}
// 3. Tables start session
if (path.startsWith('/tables/') && path.endsWith('/start-session')) {
const tableId = parseInt(path.split('/')[2]) || 12;
const sessionToken = `mock-session-token-tab-${tableId}-${Date.now()}`;
const sessionId = String(100 + tableId);
// Update table status
const table = mockDb.tables.find(t => t.id === tableId);
if (table) table.status = 'occupied';
return {
session_id: sessionId,
table_id: tableId,
restaurant_id: 1,
session_token: sessionToken,
language: body?.language || 'en',
expires_in_seconds: 10800
};
}
// 4. Tables list or operations
if (path === '/tables') {
return mockDb.tables;
}
if (path.startsWith('/tables/') && path.endsWith('/close-session')) {
const tableId = parseInt(path.split('/')[2]);
const table = mockDb.tables.find(t => t.id === tableId);
if (table) table.status = 'awaiting_clearance';
return { success: true };
}
if (path.startsWith('/tables/') && path.endsWith('/reset')) {
const tableId = parseInt(path.split('/')[2]);
const table = mockDb.tables.find(t => t.id === tableId);
if (table) table.status = 'available';
return { success: true };
}
// 5. Orders
if (path === '/orders' && method === 'POST') {
const sessionId = getCustomerSessionId() || '112';
const tableId = sessionStorage.getItem('customer_table_id') || '12';
const orderItems = body.items.map((it: any, index: number) => {
const original = getFrontendMenuItem(it.menu_item_id);
return {
id: `oi-${Date.now()}-${index}`,
menu_item_id: it.menu_item_id,
name: original?.name || `Dish ${it.menu_item_id}`,
quantity: it.quantity,
price: original?.price || 150,
kds_status: 'queued',
customization_notes: it.customization_notes || []
};
});
const newOrder = {
id: `ord-mock-${Date.now()}`,
ticketNumber: String(100 + mockDb.orders.length + 1),
tableNumber: tableId,
session_id: sessionId,
orderType: 'Dine-In',
status: 'placed',
createdAt: new Date().toISOString(),
timeElapsedMinutes: 0,
priority: 'normal',
serverName: `Self-Order Table ${tableId}`,
totalAmount: orderItems.reduce((acc: number, item: any) => acc + (item.price * item.quantity), 0),
items: orderItems
};
mockDb.orders.push(newOrder);
return newOrder;
}
if (path.startsWith('/orders/') || path.startsWith('/staff/orders/')) {
const parts = path.split('/');
const sessionId = parts[parts.length - 1];
const found = mockDb.orders.find(o => o.session_id === sessionId);
if (found) return found;
return mockDb.orders[0] || {
id: 'ord-empty',
ticketNumber: '000',
tableNumber: '12',
items: [],
status: 'placed',
totalAmount: 0
};
}
// Generate bill
if (path.startsWith('/orders/') && path.endsWith('/generate-bill')) {
const orderId = path.split('/')[2];
const order = mockDb.orders.find(o => o.id === orderId) || mockDb.orders[0];
const subtotal = order ? order.totalAmount : 500;
const cgst = subtotal * 0.025;
const sgst = subtotal * 0.025;
const serviceCharge = subtotal * 0.05;
const grandTotal = subtotal + cgst + sgst + serviceCharge;
const newBill = {
id: `bill-${Date.now()}`,
order_id: orderId,
subtotal,
cgst,
sgst,
serviceCharge,
grandTotal,
status: 'unpaid'
};
mockDb.bills.push(newBill);
return newBill;
}
// 6. Bills
if (path.startsWith('/bills/')) {
const parts = path.split('/');
const billId = parts[2];
if (parts[3] === 'record-payment') {
const bill = mockDb.bills.find(b => b.id === billId);
if (bill) {
bill.status = 'paid';
bill.paymentMethod = body.method;
}
return { success: true, bill };
}
if (parts[3] === 'credit-note') {
return { success: true, message: 'Credit note issued' };
}
return mockDb.bills.find(b => b.id === billId) || mockDb.bills[0];
}
// 7. KDS Board
if (path.startsWith('/kds/')) {
return mockDb.orders;
}
// Patch KDS order item status
if (path.startsWith('/order-items/') && path.endsWith('/status')) {
const item_id = path.split('/')[2];
const newStatus = body.kds_status;
mockDb.orders.forEach(ord => {
ord.items.forEach((item: any) => {
if (item.id === item_id) {
item.kds_status = newStatus;
}
});
const allReady = ord.items.every((it: any) => it.kds_status === 'ready' || it.kds_status === 'served');
const allServed = ord.items.every((it: any) => it.kds_status === 'served');
if (allServed) {
ord.status = 'served';
} else if (allReady) {
ord.status = 'ready';
} else {
ord.status = 'in_preparation';
}
});
notifyWsListeners({
type: 'kds.item_updated',
item_id,
status: newStatus
});
return { success: true };
}
// 8. Ingredients
if (path === '/ingredients') {
if (method === 'POST') {
const newIng = { ...body, id: `ing-${Date.now()}` };
mockDb.ingredients.push(newIng);
return newIng;
}
return mockDb.ingredients;
}
if (path.startsWith('/ingredients/')) {
const id = path.split('/')[2];
if (method === 'DELETE') {
mockDb.ingredients = mockDb.ingredients.filter(i => i.id !== id);
return { success: true };
}
if (method === 'PATCH' || method === 'PUT') {
const idx = mockDb.ingredients.findIndex(i => i.id === id);
if (idx !== -1) {
mockDb.ingredients[idx] = { ...mockDb.ingredients[idx], ...body };
return mockDb.ingredients[idx];
}
}
}
if (path === '/inventory/low-stock' || path === '/analytics/low-stock-summary') {
return mockDb.ingredients.filter(i => i.stock <= i.minStock);
}
// Recipes
if (path.startsWith('/menu-items/') && path.endsWith('/recipe')) {
const menuId = parseInt(path.split('/')[2]);
if (method === 'POST') {
mockDb.recipes[menuId] = body.ingredients;
return { success: true };
}
return { ingredients: mockDb.recipes[menuId] || [] };
}
// 9. Suppliers
if (path === '/suppliers') {
if (method === 'POST') {
const newSupp = { ...body, id: `supp-${Date.now()}` };
mockDb.suppliers.push(newSupp);
return newSupp;
}
return mockDb.suppliers;
}
if (path.startsWith('/suppliers/')) {
const id = path.split('/')[2];
if (method === 'DELETE') {
mockDb.suppliers = mockDb.suppliers.filter(s => s.id !== id);
return { success: true };
}
}
// Purchase Orders
if (path === '/purchase-orders') {
if (method === 'POST') {
const newPO = {
...body,
id: `po-${Date.now()}`,
status: 'draft',
createdAt: new Date().toISOString()
};
mockDb.purchaseOrders.push(newPO);
return newPO;
}
return mockDb.purchaseOrders;
}
if (path.startsWith('/purchase-orders/')) {
const id = path.split('/')[2];
const po = mockDb.purchaseOrders.find(p => p.id === id);
if (method === 'PATCH' || method === 'PUT') {
if (po) {
Object.assign(po, body);
if (body.status === 'received') {
po.items?.forEach((item: any) => {
const ingredient = mockDb.ingredients.find(ing => ing.id === item.ingredientId);
if (ingredient) {
ingredient.stock += Number(item.qty);
}
});
}
}
return po;
}
if (method === 'DELETE') {
mockDb.purchaseOrders = mockDb.purchaseOrders.filter(p => p.id !== id);
return { success: true };
}
}
// 10. Reservations
if (path.startsWith('/reservations')) {
if (method === 'POST') {
const newRes = { ...body, id: `res-${Date.now()}`, status: 'confirmed' };
mockDb.reservations.push(newRes);
const conflicts = mockDb.reservations.filter(r => r.tableId === body.tableId && r.id !== newRes.id);
const warnings = conflicts.length > 0 ? ['Soft conflict: table is booked within 90 minutes'] : [];
return { ...newRes, warnings };
}
return mockDb.reservations;
}
// Staff and Shifts
if (path === '/staff') {
if (method === 'POST') {
const member = { ...body, id: `st-${Date.now()}` };
mockDb.staff.push(member);
return member;
}
return mockDb.staff;
}
if (path === '/roles') {
return ['waiter', 'chef', 'cashier', 'manager'];
}
if (path === '/shifts') {
return mockDb.shifts;
}
if (path.startsWith('/shifts/') && path.endsWith('/clock-in')) {
const shiftId = path.split('/')[2];
const shift = mockDb.shifts.find(s => s.id === shiftId);
if (shift) {
shift.clockIn = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
shift.status = 'active';
}
return { success: true, shift };
}
if (path.startsWith('/shifts/') && path.endsWith('/clock-out')) {
const shiftId = path.split('/')[2];
const shift = mockDb.shifts.find(s => s.id === shiftId);
if (shift) {
shift.clockOut = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
shift.status = 'completed';
}
return { success: true, shift };
}
// Feedback
if (path === '/feedback') {
if (method === 'POST') {
const newFb = { ...body, id: `fb-${Date.now()}`, date: new Date().toISOString().split('T')[0] };
mockDb.feedback.push(newFb);
return newFb;
}
return mockDb.feedback;
}
// Analytics
if (path.startsWith('/analytics/revenue-trend')) {
return [
{ date: 'Mon', revenue: 15400 },
{ date: 'Tue', revenue: 18200 },
{ date: 'Wed', revenue: 16500 },
{ date: 'Thu', revenue: 21000 },
{ date: 'Fri', revenue: 29500 },
{ date: 'Sat', revenue: 38000 },
{ date: 'Sun', revenue: 34000 },
];
}
if (path.startsWith('/analytics/top-items')) {
return [
{ name: 'Hyderabadi Chicken Biryani', ordersCount: 84, revenue: 31920 },
{ name: 'Butter Chicken', ordersCount: 65, revenue: 23400 },
{ name: 'Garlic Naan', ordersCount: 140, revenue: 11200 },
{ name: 'Palak Paneer', ordersCount: 38, revenue: 11400 },
{ name: 'Mango Lassi', ordersCount: 42, revenue: 5040 },
];
}
return { success: true };
}
// WebSocket connection handlers
let wsListeners: ((event: any) => void)[] = [];
export const subscribeToWsEvents = (listener: (event: any) => void) => {
wsListeners.push(listener);
return () => {
wsListeners = wsListeners.filter(l => l !== listener);
};
};
const notifyWsListeners = (event: any) => {
wsListeners.forEach(listener => {
try {
listener(event);
} catch (e) {
console.error('Error triggering WS listener', e);
}
});
};
export class RestroWebSocket {
private ws: WebSocket | null = null;
private url: string;
private reconnectTimer: any = null;
private isClosedIntentional = false;
constructor(channel: 'kds' | 'orders', param: string) {
if (channel === 'kds') {
const token = getAccessToken() || '';
this.url = `${WS_BASE_URL}/ws/kds/${param}?token=${encodeURIComponent(token)}`;
} else {
const token = getCustomerToken() || '';
this.url = `${WS_BASE_URL}/ws/orders/${param}?session_token=${encodeURIComponent(token)}`;
}
}
connect() {
if (isDemoMode) {
console.log('Skipping real WebSocket connection in Demo Mode');
return;
}
this.isClosedIntentional = false;
try {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
notifyWsListeners(data);
} catch (e) {
console.error('Error parsing WS message', e);
}
};
this.ws.onclose = () => {
if (!this.isClosedIntentional) {
console.log('WebSocket closed. Attempting reconnect in 5s...');
this.reconnectTimer = setTimeout(() => this.connect(), 5000);
}
};
this.ws.onerror = (err) => {
console.error('WebSocket error:', err);
this.ws?.close();
};
} catch (e) {
console.error('WebSocket connection setup failed', e);
}
}
close() {
this.isClosedIntentional = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
// Export API Client namespace
export const api = {
// Auth
login: async (username: string, password: string) => {
const details = { username, password };
const formBody = Object.entries(details)
.map(([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`)
.join('&');
const res = await apiRequest('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formBody
});
if (res.access_token) {
setTokens(res.access_token, res.refresh_token);
}
return res;
},
logout: () => {
clearTokens();
},
isLoggedIn: () => {
return !!getAccessToken();
},
// Tables & Session
startSession: async (tableId: number, language = 'en') => {
const res = await apiRequest(`/tables/${tableId}/start-session`, {
method: 'POST',
body: JSON.stringify({ language })
});
if (res.session_token) {
setCustomerSession(res.session_token, String(res.session_id), String(res.table_id), String(res.restaurant_id));
}
return res;
},
closeSession: async (tableId: number) => {
return apiRequest(`/tables/${tableId}/close-session`, { method: 'POST' });
},
resetTable: async (tableId: number) => {
return apiRequest(`/tables/${tableId}/reset`, { method: 'POST' });
},
getTables: async () => {
return apiRequest('/tables');
},
// Backend schema: { table_number (string), capacity (int) } - manager role required
createTable: async (table_number: string, capacity = 4) => {
return apiRequest('/tables', {
method: 'POST',
body: JSON.stringify({ table_number, capacity })
});
},
// Orders
placeOrder: async (items: { menu_item_id: number; quantity: number; customization_notes?: string[] }[]) => {
const uuid = `guest-${Math.random().toString(36).substring(2, 9)}`;
return apiRequest('/orders', {
method: 'POST',
headers: {
'Idempotency-Key': uuid
},
body: JSON.stringify({
channel: 'touch',
items
})
});
},
getOrderForSession: async (sessionId: string) => {
return apiRequest(`/orders/${sessionId}`);
},
getStaffOrderForSession: async (sessionId: string) => {
return apiRequest(`/staff/orders/${sessionId}`);
},
// KDS Board — returns { restaurant_id, orders: [...] }, extract .orders in caller
getKDSBoard: async (restaurantId: string | number) => {
const data = await apiRequest(`/kds/${restaurantId}/board`);
// Backend returns KdsBoardResponse: { restaurant_id, orders: KdsBoardOrder[] }
// Return the raw object so callers can access data.orders
return data;
},
updateOrderItemStatus: async (orderItemId: string, newKDSStatus: string) => {
return apiRequest(`/order-items/${orderItemId}/status`, {
method: 'PATCH',
body: JSON.stringify({ kds_status: newKDSStatus })
});
},
// Billing
generateBill: async (orderId: string) => {
return apiRequest(`/orders/${orderId}/generate-bill`, { method: 'POST' });
},
recordPayment: async (billId: string, method: string, amount: number) => {
return apiRequest(`/bills/${billId}/record-payment`, {
method: 'POST',
body: JSON.stringify({ method, amount })
});
},
issueCreditNote: async (billId: string) => {
return apiRequest(`/bills/${billId}/credit-note`, { method: 'POST' });
},
getBill: async (billId: string) => {
return apiRequest(`/bills/${billId}`);
},
// Ingredients CRUD
getIngredients: async () => {
return apiRequest('/ingredients');
},
// Backend schema: { name, unit, current_stock, reorder_threshold, unit_cost }
createIngredient: async (name: string, current_stock: number, unit: string, reorder_threshold: number, unit_cost = 0) => {
return apiRequest('/ingredients', {
method: 'POST',
body: JSON.stringify({ name, unit, current_stock, reorder_threshold, unit_cost })
});
},
// Backend only allows: name, unit, reorder_threshold, unit_cost
updateIngredient: async (id: string, updates: { name?: string; unit?: string; reorder_threshold?: number; unit_cost?: number }) => {
return apiRequest(`/ingredients/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
});
},
deleteIngredient: async (id: string) => {
return apiRequest(`/ingredients/${id}`, { method: 'DELETE' });
},
getLowStockInventory: async () => {
return apiRequest('/inventory/low-stock');
},
// Recipes
getRecipe: async (menuItemId: number) => {
return apiRequest(`/menu-items/${menuItemId}/recipe`);
},
// Backend schema: { ingredients: [{ingredient_id, quantity_required}] }
saveRecipe: async (menuItemId: number, ingredients: { ingredient_id: number; quantity_required: number }[]) => {
return apiRequest(`/menu-items/${menuItemId}/recipe`, {
method: 'POST',
body: JSON.stringify({ ingredients })
});
},
// Suppliers CRUD
getSuppliers: async () => {
return apiRequest('/suppliers');
},
// Backend schema: { name, contact_phone?, email? } — no address field
createSupplier: async (supplier: { name: string; contact_phone?: string; email?: string }) => {
return apiRequest('/suppliers', {
method: 'POST',
body: JSON.stringify(supplier)
});
},
deleteSupplier: async (id: string) => {
return apiRequest(`/suppliers/${id}`, { method: 'DELETE' });
},
// Purchase Orders CRUD & Receipt
getPurchaseOrders: async () => {
return apiRequest('/purchase-orders');
},
// Backend schema: { supplier_id, expected_delivery?, items: [{ingredient_id, quantity, unit_price}] }
createPurchaseOrder: async (supplier_id: number, items: { ingredient_id: number; quantity: number; unit_price: number }[], expected_delivery?: string) => {
return apiRequest('/purchase-orders', {
method: 'POST',
body: JSON.stringify({ supplier_id, items, ...(expected_delivery ? { expected_delivery } : {}) })
});
},
// Backend endpoint: PATCH /purchase-orders/{id}/status with { status }
updatePurchaseOrderStatus: async (id: string, status: 'draft' | 'sent' | 'received' | 'cancelled') => {
return apiRequest(`/purchase-orders/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status })
});
},
// Reservations
getReservations: async (date: string) => {
return apiRequest(`/reservations?date=${date}`);
},
// Backend schema: { table_id, customer_name, customer_phone, party_size, reservation_time (ISO datetime), special_requirements? }
createReservation: async (reservation: {
table_id: number;
customer_name: string;
customer_phone: string;
party_size: number;
reservation_time: string; // ISO 8601 datetime string
special_requirements?: string;
}) => {
return apiRequest('/reservations', {
method: 'POST',
body: JSON.stringify(reservation)
});
},
updateReservationStatus: async (id: string, status: 'confirmed' | 'seated' | 'cancelled' | 'no_show') => {
return apiRequest(`/reservations/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status })
});
},
// Staff and Shifts
getStaff: async () => {
return apiRequest('/staff');
},
// Backend schema: { full_name, email, password, role_id (int), phone? }
createStaff: async (staff: { full_name: string; email: string; password: string; role_id: number; phone?: string }) => {
return apiRequest('/staff', {
method: 'POST',
body: JSON.stringify(staff)
});
},
getRoles: async () => {
return apiRequest('/roles');
},
getShifts: async (staff_id?: number) => {
const query = staff_id ? `?staff_id=${staff_id}` : '';
return apiRequest(`/shifts${query}`);
},
// Backend clock-in/clock-out requires staff auth (not just any POST)
clockIn: async (shiftId: string) => {
return apiRequest(`/shifts/${shiftId}/clock-in`, { method: 'POST' });
},
clockOut: async (shiftId: string) => {
return apiRequest(`/shifts/${shiftId}/clock-out`, { method: 'POST' });
},
// Feedback
sendFeedback: async (rating: number, comment: string) => {
return apiRequest('/feedback', {
method: 'POST',
body: JSON.stringify({
star_rating: rating,
comment_text: comment,
channel: 'text_form'
})
});
},
getFeedback: async (minRating = 0, restaurantId = 1) => {
return apiRequest(`/feedback?min_rating=${minRating}&restaurant_id=${restaurantId}`);
},
// Analytics — backend requires non-empty ISO date strings for revenue-trend
getRevenueTrend: async (from?: string, to?: string) => {
const today = new Date().toISOString().split('T')[0];
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const fromDate = from || sevenDaysAgo;
const toDate = to || today;
return apiRequest(`/analytics/revenue-trend?from=${fromDate}&to=${toDate}`);
},
getTopItems: async (from?: string, to?: string, limit = 5) => {
const params = new URLSearchParams({ limit: String(limit) });
if (from) params.set('from', from);
if (to) params.set('to', to);
return apiRequest(`/analytics/top-items?${params.toString()}`);
},
getLowStockSummary: async () => {
return apiRequest('/analytics/low-stock-summary');
}
};

145
src/theme/theme.ts Normal file
View File

@ -0,0 +1,145 @@
import { createTheme } from '@mui/material/styles';
export const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#ac2d00',
light: '#ffb5a0',
dark: '#872100',
contrastText: '#ffffff',
},
secondary: {
main: '#546067',
light: '#818e95',
dark: '#2a363d',
contrastText: '#ffffff',
},
background: {
default: '#f8f9fa',
paper: '#ffffff',
},
error: {
main: '#ba1a1a',
light: '#ffdad6',
dark: '#93000a',
},
warning: {
main: '#845000',
light: '#ffddba',
dark: '#2b1700',
},
success: {
main: '#11651d',
light: '#a3f69c',
dark: '#003915',
},
info: {
main: '#00a6e0',
light: '#c4e7ff',
dark: '#00374d',
},
text: {
primary: '#1a1c1c',
secondary: '#5b4139',
},
divider: '#e4beb4',
},
typography: {
fontFamily: '"Inter", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
h1: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 800,
letterSpacing: '-0.02em',
},
h2: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 700,
letterSpacing: '-0.01em',
},
h3: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 700,
},
h4: {
fontFamily: '"Plus Jakarta Sans", "Inter", sans-serif',
fontWeight: 600,
},
h5: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
},
h6: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
},
subtitle1: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
},
body1: {
fontFamily: '"Inter", sans-serif',
lineHeight: 1.5,
},
body2: {
fontFamily: '"Inter", sans-serif',
lineHeight: 1.43,
},
button: {
fontFamily: '"Inter", sans-serif',
fontWeight: 600,
textTransform: 'none',
},
caption: {
fontFamily: '"JetBrains Mono", monospace',
fontWeight: 500,
},
},
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
padding: '8px 16px',
boxShadow: 'none',
'&:hover': {
boxShadow: '0px 2px 8px rgba(172, 45, 0, 0.25)',
},
},
contained: {
background: 'linear-gradient(135deg, #ac2d00 0%, #d53e0b 100%)',
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
boxShadow: '0px 2px 12px rgba(0, 0, 0, 0.05)',
border: '1px solid rgba(228, 190, 180, 0.4)',
},
},
},
MuiChip: {
styleOverrides: {
root: {
fontWeight: 600,
borderRadius: 6,
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#ffffff',
color: '#1a1c1c',
boxShadow: '0px 1px 10px rgba(0,0,0,0.05)',
borderBottom: '1px solid #e2e2e2',
},
},
},
},
});

52
src/types/index.ts Normal file
View File

@ -0,0 +1,52 @@
export type OrderStatus = 'pending' | 'preparing' | 'ready' | 'served';
export type OrderType = 'Dine-In' | 'Takeaway' | 'Delivery';
export interface OrderItem {
id: string;
name: string;
quantity: number;
notes?: string;
completed?: boolean;
dietary?: 'veg' | 'non-veg' | 'jain' | 'vegan';
price: number;
}
export interface KDSOrder {
id: string;
ticketNumber: string;
tableNumber?: string;
orderType: OrderType;
customerName?: string;
items: OrderItem[];
status: OrderStatus;
createdAt: string; // ISO timestamp
timeElapsedMinutes: number;
priority: 'normal' | 'high' | 'urgent';
serverName?: string;
totalAmount: number;
}
export interface AnalyticsMetric {
title: string;
value: string;
change: string;
isPositive: boolean;
period: string;
}
export interface DishPerformance {
id: string;
name: string;
category: string;
ordersCount: number;
revenue: number;
growth: string;
dietary: 'veg' | 'non-veg' | 'jain' | 'vegan';
}
export interface VoiceCommandSuggestion {
id: string;
label: string;
prompt: string;
category: 'order' | 'status' | 'modify' | 'action';
}

26
tsconfig.app.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
tsconfig.node.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})