first commit
This commit is contained in:
commit
a03a620acc
|
|
@ -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)
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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?
|
||||||
|
|
@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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"}
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
C:\Users\hp\AppData\Local\Programs\Python\Python312\python.exe
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
C:\Users\hp\Desktop\projects\restroAI
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"nodes": [],
|
||||||
|
"edges": [],
|
||||||
|
"hyperedges": [],
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -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 |
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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 |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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} • 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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];
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>,
|
||||||
|
)
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue