Compare commits

...

18 Commits

Author SHA1 Message Date
b6f5cdcaa8 updates on the dispatch page and the css 2026-06-09 18:08:11 +05:30
be0ff70ee4 updates on the dispatch page and active section 2026-06-09 15:56:06 +05:30
fd27ac92d8 updates on the dispatch active page and the navbar design 2026-06-08 20:21:36 +05:30
bbec0aa910 updates on the invoice endpoint fix 2026-06-08 10:59:28 +05:30
174ea5e8f2 removed the all location button inthe dispatch page and the rdierlogs page only show the active riders 2026-06-05 09:33:41 +05:30
b78730a9a5 updates on the scroll fix 2026-06-05 08:42:26 +05:30
1978b2bb18 updates on the redesign changes 2026-06-05 08:24:54 +05:30
12341b1a0c updates on the design in the order summary page and the orders details page 2026-06-04 02:10:19 +05:30
8d0c796ba5 updates on the dispatch page and redesigned the maximum pages 2026-06-02 13:09:29 +05:30
joshikannan
c882dbdcdd multiple orders solved 2026-05-10 12:44:44 +05:30
joshikannan
fe80659ccf git checkup 2026-05-10 08:14:03 +05:30
joshikannan
2455088234 git checkup 2026-05-10 08:10:52 +05:30
joshikannan
15b12ae0ec git checkup 2026-05-10 08:09:51 +05:30
joshikannan
5bee7c6392 Ignore environment files 2026-05-10 08:09:03 +05:30
joshikannan
0d78530b25 git checkup 2026-05-10 08:06:55 +05:30
joshikannan
671da509c1 git checkup 2026-05-10 08:06:37 +05:30
joshikannan
37ba849bfb git check 2026-05-10 08:05:18 +05:30
joshikannan
f38853bec1 overall commit 2026-05-10 08:03:41 +05:30
44 changed files with 31383 additions and 4963 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(node -e \"require\\('@babel/parser'\\).parse\\(require\\('fs'\\).readFileSync\\('src/pages/nearle/reports/orderSummary.js','utf8'\\), { sourceType: 'module', plugins: ['jsx', 'optionalChaining', 'nullishCoalescingOperator', 'classProperties', 'objectRestSpread'] }\\); console.log\\('OK'\\)\")",
"Bash(node -e \"require\\('@babel/parser'\\).parse\\(require\\('fs'\\).readFileSync\\('src/pages/nearle/reports/ridersummary.js','utf8'\\), { sourceType: 'module', plugins: ['jsx', 'optionalChaining', 'nullishCoalescingOperator', 'classProperties', 'objectRestSpread'] }\\); console.log\\('OK'\\)\")",
"Bash(node -e \"require\\('@babel/parser'\\).parse\\(require\\('fs'\\).readFileSync\\('src/pages/nearle/invoice/invoice.js','utf8'\\), { sourceType: 'module', plugins: ['jsx', 'optionalChaining', 'nullishCoalescingOperator', 'classProperties', 'objectRestSpread'] }\\); console.log\\('OK'\\)\")"
]
}
}

31
.env
View File

@@ -1,31 +0,0 @@
REACT_APP_VERSION = v2.1.0
GENERATE_SOURCEMAP = false
## Backend API URL
REACT_APP_API_URL=https://mock-data-api-nextjs.vercel.app/
## Google Map Key
REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCF4KatYCI3vqz1_H3kiHeyS3yCMfYToh8
REACT_APP_RIDER_ACCESS_ID=AAAAILMpCEU:APA91bEavuOllBI6sFgYtxXAgNmAVwNA-MnCMHLGlR4_t7UqpLajAkdn3T0CZr_zaLBknLyim9ytFLMZgbeXmKqTad_PKCbqlYjHpaizVrLXtecxqyEy4UktIacK2UvHVUATHL-7VQQk
## Firebase - Google Auth
REACT_APP_FIREBASE_API_KEY=
REACT_APP_FIREBASE_AUTH_DOMAIN=
REACT_APP_FIREBASE_PROJECT_ID=
REACT_APP_FIREBASE_STORAGE_BUCKET=
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=
REACT_APP_FIREBASE_APP_ID=
REACT_APP_FIREBASE_MEASUREMENT_ID=
## AWS
REACT_APP_AWS_POOL_ID=
REACT_APP_AWS_APP_CLIENT_ID=
## Auth0
REACT_APP_AUTH0_CLIENT_ID=
REACT_APP_AUTH0_DOMAIN=
DISABLE_ESLINT_PLUGIN=true

View File

@@ -1,3 +0,0 @@
REACT_APP_URL='https://jupiter.nearle.app/live/api/v1'
REACT_APP_URL2='https://jupiter.nearle.app/live/api/v2'
REACT_APP_STAFF_TOKEN=''

View File

@@ -1,3 +0,0 @@
REACT_APP_URL='https://jupiter.nearle.app/live/api/v1'
REACT_APP_URL2='https://jupiter.nearle.app/live/api/v2'
REACT_APP_STAFF_TOKEN=''

137
.gitignore vendored
View File

@@ -1,108 +1,107 @@
# Dependencies
node_modules/
# Production build
dist/
build/
# Environment variables
.env
.env.development
.env.staging
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Diagnostic reports
report.*.json
# Coverage directory used by tools like istanbul
coverage
# Coverage
coverage/
.nyc_output/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
build
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
# Cache
.cache/
.parcel-cache/
.eslintcache
# Microbundle cache
.npm
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# TypeScript
*.tsbuildinfo
# IDE / Editor
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Package manager
.yarn-integrity
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Temporary files
*.tmp
*.temp
*.bak
# Yarn Integrity file
.yarn-integrity
# Next.js
.next/
# dotenv environment variables file
# .env
.env.test
# Nuxt.js
.nuxt/
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Gatsby
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
# VuePress
.vuepress/dist
# Serverless directories
# Serverless
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
# DynamoDB
.dynamodb/
# TernJS port file
# TernJS
.tern-port
# wincompare file
*.bak
# Bower
bower_components/
# JSPM
jspm_packages/
# Optional test outputs
test-results/
playwright-report/
# Vite
vite.svg

177
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"lodash": "^4.17.21",
"mui-daterange-picker": "^1.0.5",
"notistack": "^3.0.1",
"papaparse": "^5.5.3",
"process": "^0.11.10",
"prop-types": "^15.8.1",
"react": "^18.2.0",
@@ -71,6 +72,7 @@
"stylis-plugin-rtl": "^2.1.1",
"util": "^0.12.5",
"web-vitals": "^3.3.1",
"xlsx": "^0.18.5",
"yup": "^1.1.1"
},
"devDependencies": {
@@ -5648,6 +5650,15 @@
"node": ">=8.9"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -6825,6 +6836,19 @@
"node": ">=4"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -6989,6 +7013,15 @@
"node": ">= 4.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
@@ -7242,6 +7275,18 @@
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.10.tgz",
"integrity": "sha512-x3elaK+ZY23W1YtFsNQknRdURzkV7g3Z93AoA7SHZJUEXbVjRsNh4h9Uf09+OjWF/4u8tXeAt37gezGRdwR/2g=="
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-ecdh": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
@@ -9907,6 +9952,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
@@ -14337,6 +14391,12 @@
"node": ">=8"
}
},
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -18683,6 +18743,18 @@
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@@ -20176,6 +20248,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
@@ -20560,6 +20650,27 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",
@@ -24455,6 +24566,11 @@
"regex-parser": "^2.2.11"
}
},
"adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="
},
"agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -25342,6 +25458,15 @@
"resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz",
"integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw=="
},
"cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"requires": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
}
},
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -25468,6 +25593,11 @@
"q": "^1.1.2"
}
},
"codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="
},
"collect-v8-coverage": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
@@ -25667,6 +25797,11 @@
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.10.tgz",
"integrity": "sha512-x3elaK+ZY23W1YtFsNQknRdURzkV7g3Z93AoA7SHZJUEXbVjRsNh4h9Uf09+OjWF/4u8tXeAt37gezGRdwR/2g=="
},
"crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="
},
"create-ecdh": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
@@ -27606,6 +27741,11 @@
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="
},
"frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="
},
"fraction.js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
@@ -30787,6 +30927,11 @@
"retry": "^0.13.1"
}
},
"papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
},
"param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -33726,6 +33871,14 @@
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"requires": {
"frac": "~1.1.2"
}
},
"stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@@ -34810,6 +34963,16 @@
"is-typed-array": "^1.1.10"
}
},
"wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="
},
"word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="
},
"word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
@@ -35140,6 +35303,20 @@
"integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==",
"requires": {}
},
"xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"requires": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
}
},
"xml-name-validator": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",

View File

@@ -35,6 +35,7 @@
"lodash": "^4.17.21",
"mui-daterange-picker": "^1.0.5",
"notistack": "^3.0.1",
"papaparse": "^5.5.3",
"process": "^0.11.10",
"prop-types": "^15.8.1",
"react": "^18.2.0",
@@ -66,6 +67,7 @@
"stylis-plugin-rtl": "^2.1.1",
"util": "^0.12.5",
"web-vitals": "^3.3.1",
"xlsx": "^0.18.5",
"yup": "^1.1.1"
},
"scripts": {

View File

@@ -0,0 +1,3 @@
export const getValueColor = (value) => {
return Number(value) !== 0 ? 'red' : 'inherit';
};

View File

@@ -5,8 +5,10 @@ import React, { forwardRef, useEffect, useRef, useState } from 'react';
import { styled, useTheme } from '@mui/material/styles';
import {
Box,
Button,
Checkbox,
Chip,
CircularProgress,
FormControl,
Grid,
ListItemText,
@@ -493,20 +495,36 @@ SortingSelect.propTypes = {
// ==============================|| CSV EXPORT ||============================== //
export const CSVExport = ({ data, filename, headers }) => {
export const CSVExport = ({ data, filename, headers, label, style, btnLoading, onClick }) => {
return (
<CSVLink data={data} filename={filename} headers={headers}>
<Tooltip title="Download CSV">
<DownloadOutlined style={{ fontSize: '24px', color: 'gray', marginTop: 4, marginRight: 4, marginLeft: 4 }} />
<Tooltip title="CSV Export">
<Button
startIcon={!btnLoading && <DownloadOutlined />}
variant={btnLoading ? 'outlined' : 'contained'}
sx={{ ...style }}
disabled={btnLoading}
onClick={(e) => {
onClick?.(e);
}}
>
{btnLoading ? <CircularProgress size={20} thickness={5} /> : label || 'Download'}
</Button>
</Tooltip>
</CSVLink>
);
};
export default CSVExport;
CSVExport.propTypes = {
data: PropTypes.array,
headers: PropTypes.any,
filename: PropTypes.string
filename: PropTypes.string,
label: PropTypes.node,
style: PropTypes.object,
btnLoading: PropTypes.bool,
onClick: PropTypes.func
};
// ==============================|| EMPTY TABLE - NO DATA ||============================== //

View File

@@ -5,7 +5,7 @@ export const facebookColor = '#3b5998';
export const linkedInColor = '#0e76a8';
// export const APP_DEFAULT_PATH = '/sample-page';
export const APP_DEFAULT_PATH = '/nearle/orders';
export const APP_DEFAULT_PATH = '/nearle/dispatch';
export const HORIZONTAL_MAX_ITEM = 6;
export const DRAWER_WIDTH = 260;

View File

@@ -210,10 +210,14 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
const isSelected = selected === menu.id;
const borderIcon = level === 1 ? <BorderOutlined style={{ fontSize: '1rem' }} /> : false;
const Icon = menu.icon;
const menuIcon = menu.icon ? <Icon style={{ fontSize: drawerOpen ? '1rem' : '1.25rem' }} /> : borderIcon;
const iconSelectedColor = theme.palette.mode === ThemeMode.DARK && drawerOpen ? theme.palette.text.primary : theme.palette.primary.main;
const menuIcon = menu.icon ? <Icon style={{ fontSize: drawerOpen ? '1rem' : '1.25rem', color: 'white' }} /> : borderIcon;
// const textColor = theme.palette.mode === ThemeMode.DARK ? 'grey.400' : 'text.primary';
// const iconSelectedColor = theme.palette.mode === ThemeMode.DARK && drawerOpen ? theme.palette.text.primary : theme.palette.primary.main;
const popperId = miniMenuOpened ? `collapse-pop-${menu.id}` : undefined;
const FlexBox = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' };
const textColor = 'white';
const iconSelectedColor = 'white';
// const isSelected = true;
return (
<>
@@ -227,9 +231,11 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
sx={{
pl: drawerOpen ? `${level * 28}px` : 1.5,
py: !drawerOpen && level === 1 ? 1.25 : 1,
...(drawerOpen && {
'&:hover': {
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.light'
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.lighter'
bgcolor: '#7b1fa2'
},
'&.Mui-selected': {
bgcolor: 'transparent',
@@ -239,13 +245,14 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
}),
...(!drawerOpen && {
'&:hover': {
bgcolor: 'primary.light'
bgcolor: 'transparent'
// bgcolor:'#7b1fa2'
},
'&.Mui-selected': {
'&:hover': {
bgcolor: 'white'
bgcolor: 'transparent'
},
bgcolor: 'white'
bgcolor: 'transparent'
}
})
}}
@@ -255,7 +262,10 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
onClick={handlerIconLink}
sx={{
minWidth: 28,
color: selected === menu.id ? 'primary.main' : 'white',
// color: selected === menu.id ? 'primary.main' : textColor,
// color: selected === menu.id ? textColor : textColor,
// bgcolor:'white',
// color:'white',
...(!drawerOpen && {
borderRadius: 1.5,
width: 36,
@@ -264,13 +274,17 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
justifyContent: 'center',
'&:hover': {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'secondary.light' : 'secondary.lighter'
bgcolor: '#7b1fa2',
color: 'white'
}
}),
...(!drawerOpen &&
selected === menu.id && {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.900' : 'primary.lighter',
bgcolor: 'primary.light',
color: 'primary.main',
'&:hover': {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.darker' : 'primary.lighter'
bgcolor: '#7b1fa2',
color: 'primary.main'
}
})
}}
@@ -281,7 +295,12 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
{(drawerOpen || (!drawerOpen && level !== 1)) && (
<ListItemText
primary={
<Typography variant="h6" color={selected === menu.id ? 'white' : 'white'}>
<Typography
variant="h6"
// color={selected === menu.id ? 'primary' : textColor}
// color={'white'}
color={selected === menu.id ? textColor : textColor}
>
{menu.title}
</Typography>
}
@@ -296,9 +315,22 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
)}
{(drawerOpen || (!drawerOpen && level !== 1)) &&
(miniMenuOpened || open ? (
<UpOutlined style={{ fontSize: '0.625rem', marginLeft: 1, color: theme.palette.primary.main }} />
<UpOutlined
style={{
fontSize: '0.625rem',
marginLeft: 1,
// color: theme.palette.primary.main
color: 'white'
}}
/>
) : (
<DownOutlined style={{ fontSize: '0.625rem', marginLeft: 1, color: 'white' }} />
<DownOutlined
style={{
fontSize: '0.625rem',
marginLeft: 1,
color: 'white'
}}
/>
))}
{!drawerOpen && (
@@ -328,8 +360,8 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
mt: 1.5,
boxShadow: theme.customShadows.z1,
backgroundImage: 'none',
border: `1px solid ${theme.palette.primary.main}`,
bgcolor: 'primary.main'
border: `2px solid ${theme.palette.primary.main}`,
width: 'auto'
}}
>
<ClickAwayListener onClickAway={handleClose}>
@@ -373,7 +405,14 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
>
<Box onClick={handlerIconLink} sx={FlexBox}>
{menuIcon && (
<ListItemIcon sx={{ my: 'auto', minWidth: !menu.icon ? 18 : 36, color: theme.palette.secondary.dark }}>
<ListItemIcon
sx={{
my: 'auto',
minWidth: !menu.icon ? 18 : 36
// color: theme.palette.secondary.dark
// color:'white'
}}
>
{menuIcon}
</ListItemIcon>
)}
@@ -386,7 +425,12 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
)}
<ListItemText
primary={
<Typography variant="body1" color="inherit" sx={{ my: 'auto' }}>
<Typography
variant="body1"
// color="inherit"
// color="white"
sx={{ my: 'auto' }}
>
{menu.title}
</Typography>
}

View File

@@ -26,7 +26,7 @@ import NavCollapse from './NavCollapse';
import SimpleBar from 'components/third-party/SimpleBar';
import Transitions from 'components/@extended/Transitions';
import { MenuOrientation, ThemeMode } from 'config';
import { MenuOrientation } from 'config';
import useConfig from 'hooks/useConfig';
import { dispatch, useSelector } from 'store';
import { activeID } from 'store/reducers/menu';
@@ -227,9 +227,9 @@ const NavGroup = ({ item, lastItem, remItems, lastItemId, setSelectedItems, sele
item.title &&
drawerOpen && (
<Box sx={{ pl: 3, mb: 1.5 }}>
<Typography variant="subtitle2"
// color={theme.palette.mode === ThemeMode.DARK ? 'textSecondary' : 'text.secondary'}
sx={{color:'#fff'}}
<Typography
variant="subtitle2"
sx={{ color: '#fff' }}
>
{item.title}
</Typography>

View File

@@ -9,9 +9,10 @@ import { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Typography, u
// project import
import Dot from 'components/@extended/Dot';
import { MenuOrientation, ThemeMode } from 'config';
import useConfig from 'hooks/useConfig';
import { activeItem, openDrawer } from 'store/reducers/menu';
import { activeItem, openDrawer, setSelectedMenu } from 'store/reducers/menu';
// ==============================|| NAVIGATION - LIST ITEM ||============================== //
@@ -35,10 +36,15 @@ const NavItem = ({ item, level }) => {
}
const Icon = item.icon;
const itemIcon = item.icon ? <Icon style={{ fontSize: drawerOpen ? '1rem' : '1.25rem' }} /> : false;
const isSelected = openItem.findIndex((id) => id === item.id) > -1;
const itemIcon = item.icon ? (
<Icon style={{ fontSize: drawerOpen ? '1rem' : '1.25rem', color: isSelected ? '#662582' : '#fff' }} />
) : (
false
);
// const { pathname } = useLocation();
const pathname = document.location.pathname;
@@ -59,10 +65,15 @@ const NavItem = ({ item, level }) => {
if (pathname.includes(item.url)) {
dispatch(activeItem({ openItem: [item.id] }));
}
// eslint-disable-next-line
}, [pathname]);
const textColor = theme.palette.mode === ThemeMode.DARK ? 'grey.400' : 'text.primary';
useEffect(() => {
dispatch(setSelectedMenu(pathname));
}, [pathname]);
const textColor = theme.palette.mode === ThemeMode.DARK ? 'grey.400' : '#fff';
const iconSelectedColor = theme.palette.mode === ThemeMode.DARK && drawerOpen ? 'text.primary' : 'primary.main';
return (
@@ -72,14 +83,16 @@ const NavItem = ({ item, level }) => {
{...listItemProps}
disabled={item.disabled}
selected={isSelected}
onClick={() => {
// dispatch(setSelectedMenu(item));
}}
sx={{
zIndex: 1201,
pl: drawerOpen ? `${level * 28}px` : 1.5,
py: !drawerOpen && level === 1 ? 1.25 : 1,
...(drawerOpen && {
// bgcolor: 'primary.light',
'&:hover': {
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.light'
bgcolor: '#7b1fa2'
},
'&.Mui-selected': {
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.lighter',
@@ -92,41 +105,43 @@ const NavItem = ({ item, level }) => {
}
}),
...(!drawerOpen && {
bgcolor: '#662582',
'&:hover': {
bgcolor: 'primary.light'
bgcolor: '#662582'
},
'&.Mui-selected': {
'&:hover': {
bgcolor: 'white'
bgcolor: 'transparent'
},
bgcolor: 'white'
bgcolor: 'transparent'
}
})
}}
{...(downLG && {
onClick: () => dispatch(openDrawer(false))
onClick: () => {
dispatch(openDrawer(false));
}
})}
>
{itemIcon && (
<ListItemIcon
sx={{
minWidth: 28,
color: isSelected ? iconSelectedColor : 'white',
...(!drawerOpen && {
// borderRadius: 1.5,
borderRadius: 1.5,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center'
// '&:hover': {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'secondary.light' : 'primary.lighter'
// }
justifyContent: 'center',
'&:hover': {
bgcolor: '#7b1fa2'
}
}),
...(!drawerOpen &&
isSelected && {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.900' : 'primary.lighter',
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.900' : 'primary.lighter',
'&:hover': {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.darker' : 'primary.lighter'
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.darker' : 'primary.lighter'
}
})
}}
@@ -137,13 +152,7 @@ const NavItem = ({ item, level }) => {
{(drawerOpen || (!drawerOpen && level !== 1)) && (
<ListItemText
primary={
<Typography
variant="h6"
sx={{
ml: 1,
color: isSelected ? theme.palette.primary.main : 'white'
}}
>
<Typography variant="h6" sx={{ color: isSelected ? iconSelectedColor : textColor, whiteSpace: 'nowrap' }}>
{item.title}
</Typography>
}

View File

@@ -169,7 +169,7 @@ const HeaderContent = () => {
<ListItemIcon sx={{ mr: 1, fontSize: '20px' }}>
<TbBoxMultiple1 />
</ListItemIcon>
<Typography color="textPrimary"> Orders</Typography>
<Typography color="textPrimary">Create Orders</Typography>
</Grid>
}
/>
@@ -188,7 +188,7 @@ const HeaderContent = () => {
<ListItemIcon sx={{ mr: 1, fontSize: '20px' }}>
<GrMultiple />
</ListItemIcon>
<Typography color="textPrimary"> Group Orders</Typography>
<Typography color="textPrimary">Create Group Orders</Typography>
</Grid>
}
/>

View File

@@ -9,7 +9,7 @@ import AppBarStyled from './AppBarStyled';
import HeaderContent from './HeaderContent';
import IconButton from 'components/@extended/IconButton';
import { MenuOrientation, ThemeMode } from 'config';
import { MenuOrientation } from 'config';
import useConfig from 'hooks/useConfig';
import { dispatch, useSelector } from 'store';
import { openDrawer } from 'store/reducers/menu';
@@ -32,9 +32,6 @@ const Header = () => {
// header content
const headerContent = useMemo(() => <HeaderContent />, []);
const iconBackColorOpen = theme.palette.mode === ThemeMode.DARK ? 'grey.200' : 'grey.300';
const iconBackColor = theme.palette.mode === ThemeMode.DARK ? 'background.default' : 'grey.100';
// common header
const mainHeader = (
<Toolbar>
@@ -43,9 +40,6 @@ const Header = () => {
aria-label="open drawer"
onClick={() => dispatch(openDrawer(!drawerOpen))}
edge="start"
// color="secondary"
// variant="light"
// sx={{ color: 'text.primary', bgcolor: drawerOpen ? iconBackColorOpen : iconBackColor, ml: { xs: 0, lg: -2 } }}
sx={{
color: '#fff',
bgcolor: 'transparent',

View File

@@ -5,6 +5,7 @@ import { AiOutlineDashboard } from 'react-icons/ai';
import { TbListDetails } from 'react-icons/tb';
import { LiaFileInvoiceSolid } from 'react-icons/lia';
import DirectionsBikeOutlinedIcon from '@mui/icons-material/DirectionsBikeOutlined';
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
// assets
import {
@@ -49,6 +50,13 @@ const nearle = {
title: <FormattedMessage id="MENU" />,
type: 'group',
children: [
{
id: 'dispatch',
title: <FormattedMessage id="Dispatch" />,
type: 'item',
url: '/nearle/dispatch',
icon: RouteOutlinedIcon
},
{
id: 'orders',
title: <FormattedMessage id="Orders" />,
@@ -96,14 +104,14 @@ const nearle = {
type: 'item',
url: '/nearle/reports/ridersummary',
icon: DirectionsBikeOutlinedIcon
},
{
id: 'RiderLogs',
title: <FormattedMessage id="RiderLogs" />,
type: 'item',
url: '/nearle/reports/riderlogs',
icon: DirectionsBikeOutlinedIcon
}
// {
// id: 'RiderLogs',
// title: <FormattedMessage id="RiderLogs" />,
// type: 'item',
// url: '/nearle/reports/riderlogs',
// icon: DirectionsBikeOutlinedIcon
// }
]
},
{

View File

@@ -0,0 +1,341 @@
// ============================================================================
// ordersDesign.js — shared design-system primitives for the operator pages.
//
// Source of truth for the look-and-feel established on the Orders Details
// screen (src/pages/nearle/reports/ordersDetails.js). All other report and
// list pages import from here so the entire admin panel reads as one product.
//
// Exports:
// DT, BRAND, BRAND_LIGHT — palette tokens
// tint / soft / ring / edge — alpha helpers (08 / 18 / 26 / 55 hex)
// formatNumberToRupees — INR currency formatter
// STATUS_META — semantic per-status meta (label/color/icon)
// StatusBadge — filled pill, white text on solid color
// TimelineCell — time-dominant (bold) / date-secondary (muted)
// MetricPill — small money/number pill for table cells
// SoftPaper — autocomplete dropdown surface
// AccentAvatar — colored circular icon avatar
// pillFieldSx — rounded pill text-field styling
// PrimaryButtonSx / SecondaryButtonSx — unified 32-px action-button system
// ============================================================================
import React from 'react';
import { Avatar, Box, Paper, Stack, Typography } from '@mui/material';
import {
MdLocalShipping,
MdHourglassEmpty,
MdCheckCircle,
MdCancel,
MdAccessTime,
MdHistoryToggleOff,
MdAssignmentTurnedIn
} from 'react-icons/md';
import dayjs from 'dayjs';
// ----------------------------------------------------------------------------
// Design tokens
// ----------------------------------------------------------------------------
export const DT = {
radiusPill: 999,
radiusCard: 16,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
export const BRAND = '#662582';
export const BRAND_LIGHT = '#9255AB';
const dtA = (c, suffix) => `${c}${suffix}`;
export const tint = (c) => dtA(c, '08');
export const soft = (c) => dtA(c, '18');
export const ring = (c) => dtA(c, '26');
export const edge = (c) => dtA(c, '55');
// ----------------------------------------------------------------------------
// Money formatter
// ----------------------------------------------------------------------------
export function formatNumberToRupees(value) {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 2
}).format(value || 0);
}
// ----------------------------------------------------------------------------
// Status meta — colors per brand standard:
// green=delivered, amber=pending, blue=created/processing,
// red=cancelled, dark-red=failed, purple=on-hold.
// ----------------------------------------------------------------------------
export const STATUS_META = {
created: { label: 'Created', color: '#3b82f6', icon: MdLocalShipping },
pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
accepted: { label: 'Accepted', color: '#6366f1', icon: MdAssignmentTurnedIn },
ready: { label: 'Accepted', color: '#6366f1', icon: MdAssignmentTurnedIn },
arrived: { label: 'Arrived', color: '#06b6d4', icon: MdCheckCircle },
picked: { label: 'Picked', color: '#8b5cf6', icon: MdLocalShipping },
active: { label: 'Active', color: '#0ea5e9', icon: MdLocalShipping },
modified: { label: 'Confirmed', color: '#10b981', icon: MdCheckCircle },
confirmed: { label: 'Confirmed', color: '#10b981', icon: MdCheckCircle },
processing: { label: 'Processing', color: '#3b82f6', icon: MdAccessTime },
onhold: { label: 'On Hold', color: '#8b5cf6', icon: MdHistoryToggleOff },
'on hold': { label: 'On Hold', color: '#8b5cf6', icon: MdHistoryToggleOff },
closed: { label: 'Closed', color: '#06b6d4', icon: MdCheckCircle },
completed: { label: 'Completed', color: '#10b981', icon: MdCheckCircle },
delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
skipped: { label: 'Skipped', color: '#f97316', icon: MdCancel },
failed: { label: 'Failed', color: '#991b1b', icon: MdCancel },
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel }
};
// ----------------------------------------------------------------------------
// StatusBadge — filled pill, white text on solid color, high-contrast.
// ----------------------------------------------------------------------------
export const StatusBadge = ({ status, minWidth = 86 }) => {
if (!status) return null;
const meta = STATUS_META[String(status).toLowerCase()] || {
label: status,
color: DT.textMuted,
icon: MdHistoryToggleOff
};
const Icon = meta.icon;
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.125,
py: 0.375,
borderRadius: 999,
bgcolor: meta.color,
color: '#fff',
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.2,
whiteSpace: 'nowrap',
minWidth,
justifyContent: 'center',
boxShadow: `0 1px 2px ${ring(meta.color)}`
}}
>
<Icon size={12} /> {meta.label}
</Box>
);
};
// ----------------------------------------------------------------------------
// TimelineCell — time large/bold/high-contrast, date small/muted/secondary.
// No decorative dot.
// ----------------------------------------------------------------------------
export const TimelineCell = ({ value, utc: useUtc = false }) => {
if (!value) {
return (
<Typography sx={{ fontSize: 12, color: DT.textMuted, fontWeight: 700 }}></Typography>
);
}
const d = useUtc ? dayjs(value).utc() : dayjs(value);
return (
<Stack spacing={0} sx={{ lineHeight: 1.1 }}>
<Typography
sx={{
fontSize: 12.5,
fontWeight: 800,
color: DT.textPrimary,
letterSpacing: 0.1,
lineHeight: 1.15
}}
noWrap
>
{d.format('hh:mm A')}
</Typography>
<Typography
sx={{
fontSize: 10.5,
fontWeight: 600,
color: DT.textMuted,
lineHeight: 1.2
}}
noWrap
>
{d.format('DD MMM YYYY')}
</Typography>
</Stack>
);
};
// ----------------------------------------------------------------------------
// MetricPill — small money / number pill used in table cells.
// ----------------------------------------------------------------------------
export const MetricPill = ({ value, color, icon, isMoney = false }) => {
const n = Number(value);
const display = isMoney ? formatNumberToRupees(n) : Number.isFinite(n) ? n : value || 0;
const isZero = !Number.isFinite(n) || n === 0;
if (isZero) {
return (
<Typography sx={{ fontSize: 11.5, color: DT.textMuted, fontWeight: 700 }}>
{display}
</Typography>
);
}
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 0.875,
py: 0.25,
borderRadius: 999,
bgcolor: tint(color),
border: `1px solid ${edge(color)}`,
color,
fontSize: 12,
fontWeight: 800,
whiteSpace: 'nowrap'
}}
>
{icon}
{display}
</Box>
);
};
// ----------------------------------------------------------------------------
// SoftPaper — autocomplete dropdown surface.
// ----------------------------------------------------------------------------
export const SoftPaper = (props) => (
<Paper
{...props}
sx={{
mt: 0.75,
borderRadius: 2,
boxShadow: DT.shadowPop,
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden'
}}
/>
);
// ----------------------------------------------------------------------------
// AccentAvatar — colored circular icon used inside filter chips & headers.
// ----------------------------------------------------------------------------
export const AccentAvatar = ({ color, selected, size = 24, children }) => (
<Avatar
sx={{
width: size,
height: size,
bgcolor: selected ? color : soft(color),
color: selected ? '#fff' : color,
transition: 'background-color 0.15s, color 0.15s'
}}
>
{children}
</Avatar>
);
// ----------------------------------------------------------------------------
// pillFieldSx — rounded pill style for Autocomplete/TextField inputs.
// ----------------------------------------------------------------------------
export const pillFieldSx = (color) => ({
'& .MuiOutlinedInput-root': {
borderRadius: DT.radiusPill + 'px',
bgcolor: tint(color),
fontWeight: 600,
'& fieldset': { borderColor: edge(color), borderWidth: 1.5 },
'&:hover fieldset': { borderColor: color },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(color)}` },
'&.Mui-focused fieldset': { borderColor: color, borderWidth: 2 }
}
});
// ----------------------------------------------------------------------------
// Unified action-button styles — both render at 32px height with same typography.
// SecondaryButtonSx → outlined (white bg, neutral border)
// PrimaryButtonSx → filled brand (purple bg, white text)
// ----------------------------------------------------------------------------
export const SecondaryButtonSx = (active = false) => ({
height: 32,
px: 1.25,
borderRadius: 1.5,
textTransform: 'none',
fontSize: 12.5,
fontWeight: 700,
letterSpacing: 0.1,
bgcolor: '#fff',
borderColor: active ? edge(BRAND) : DT.borderSubtle,
color: active ? BRAND : DT.textPrimary,
'&:hover': {
bgcolor: '#fff',
borderColor: BRAND,
color: BRAND,
boxShadow: `0 0 0 3px ${ring(BRAND)}`
},
'&:focus-visible': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
});
export const PrimaryButtonSx = {
height: 32,
minHeight: 32,
px: 1.5,
borderRadius: 1.5,
bgcolor: BRAND,
color: '#fff',
textTransform: 'none',
fontSize: 12.5,
fontWeight: 700,
letterSpacing: 0.1,
boxShadow: `0 2px 6px ${ring(BRAND)}`,
'&:hover': { bgcolor: BRAND_LIGHT, boxShadow: `0 4px 12px ${ring(BRAND)}` },
'&:focus-visible': { boxShadow: `0 0 0 3px ${ring(BRAND)}` },
'& .MuiButton-startIcon': { mr: 0.75, '& svg': { fontSize: 16 } }
};
// ----------------------------------------------------------------------------
// PageHeaderShellSx — the gradient header strip used by every operator page.
// Spread onto the outer <Paper> element.
// ----------------------------------------------------------------------------
export const PageHeaderShellSx = {
mb: { xs: 1, md: 1.25 },
px: { xs: 1.5, sm: 2 },
py: { xs: 1, sm: 1.25 },
borderRadius: 2,
border: '1px solid',
borderColor: DT.borderSubtle,
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
boxShadow: DT.shadowMd
};
// ----------------------------------------------------------------------------
// CompactKpiCardSx — visual envelope for a compact KPI card.
// Caller supplies the colored left bar via the `accent` arg.
// ----------------------------------------------------------------------------
export const CompactKpiCardSx = (accentColor) => ({
position: 'relative',
overflow: 'hidden',
px: { xs: 1.25, sm: 1.5 },
py: { xs: 0.875, sm: 1.125 },
borderRadius: 2,
border: '1px solid',
borderColor: DT.borderSubtle,
background: '#fff',
transition: 'transform 0.15s, box-shadow 0.15s, border-color 0.15s',
'&:hover': {
transform: 'translateY(-1px)',
boxShadow: DT.shadowMd,
borderColor: edge(accentColor)
}
});
// MdAccessTime re-exported so callers can use the inline time icon
// without an extra react-icons import.
export { MdAccessTime };

View File

@@ -147,9 +147,9 @@ export const fetchOrdersSummary = async ({ queryKey }) => {
// ==============================|| getreportlocationsummary (orders summary)||============================== //
export const getreportlocationsummary = async ({ queryKey }) => {
console.log('queryKey for getreportlocationsummary', queryKey);
const [startdate, enddate, locationId] = queryKey;
const [startdate, enddate, locationId, debouncedSearch] = queryKey;
const response = await axios.get(
`${process.env.REACT_APP_URL}/deliveries/getreportlocationsummary/?tenantid=${tenid}&locationid=${locationId}&fromdate=${startdate}&todate=${enddate}`
`${process.env.REACT_APP_URL}/deliveries/getreportlocationsummary/?tenantid=${tenid}&locationid=${locationId}&fromdate=${startdate}&todate=${enddate}&keyword=${debouncedSearch}`
);
console.log('getreportlocationsummary', response.data.details);
@@ -184,10 +184,12 @@ export const gettenantlocations = async ({ queryKey }) => {
const [, searchLocation] = queryKey;
try {
const response = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantlocations?tenantid=${tenid}&keyword=${searchLocation}`);
return response.data?.details || []; // safe fallback
return response.data?.details || [];
} catch (error) {
// Must return an array — downstream consumers do `.map`/`.length` and a
// string here crashes the entire Locations page.
console.error('Error fetching tenant locations:', error);
return error.message;
return [];
}
};
@@ -292,10 +294,140 @@ export const fetchCount = async ({ queryKey }) => {
// ==============================|| fetchRidersLogs (RiderLogs)||============================== //
export const fetchRidersLogs = async ({ queryKey }) => {
const [tenantid, startdate] = queryKey;
const [appId, startdate, riderSearch = ''] = queryKey;
// const riderLogsResponse = await axios.get(
// `${process.env.REACT_APP_URL}/partners/getriderlogs/?applocationid=${appId}&fromdate=${startdate || ''}&todate=${startdate}&keyword=${
// riderSearch || ''
// }`
// );
const riderLogsResponse = await axios.get(
`${process.env.REACT_APP_URL}/partners/getriderlogs/?tenantid=${tenantid}&fromdate=${startdate || ''}`
`https://jupiter.nearle.app/live/api/v2/partners/getriderlogs/?applocationid=${appId}&fromdate=${startdate || ''}&todate=${
startdate || ''
}&keyword=${riderSearch || ''}`
);
console.log('fetchRidersLogs', riderLogsResponse.data.details);
return riderLogsResponse.data.details;
};
// ==============================|| Dispatch / Preview APIs (ported from xpressconsole) ||============================== //
// Returns the rider's latest periodic log entry — battery, GPS, status,
// current order. Used by the Rider Info modal on the Dispatch page.
export const getRiderPeriodicLogs = async (userid) => {
const url = `${process.env.REACT_APP_URL}/utils/getriderperiodiclogs${userid ? `?userid=${userid}` : ''}`;
const response = await axios.get(url);
if (response.data && response.data.status) return response.data.data;
return null;
};
// Fetches the riders dropdown list for an app location.
export const fetchRidersList = async ({ queryKey }) => {
try {
const [, appId] = queryKey;
const { data } = await axios.get(`${process.env.REACT_APP_URL}/partners/getriders/?applocationid=${appId}`);
const response = data?.details
? data.details.map((val) => ({
...val,
label: `${val.firstname} ${val.lastname} | ${val.contactno}`
}))
: [];
return response;
} catch (err) {
OpenToast(err.message, 'error', 2000);
throw err;
}
};
// Optimise the orders (bike solver).
export const createOptimisationDeliveries = async (deliveryData) => {
const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/createdeliveries`, deliveryData.deliveries);
return response.data;
};
// Server-side step reconciliation after manual edits in Preview.
export const reconcileSteps = async ({ riders }) => {
const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/reconcile-steps`, { riders });
return response.data;
};
// Batch efficiency analysis — batch ∈ 'morning' | 'afternoon' | 'evening'.
export const fetchBatchEfficiency = async ({ batch, tenantId }) => {
const response = await axios.post(
`https://routes.workolik.com/api/v1/batch/efficiency`,
{ batch, tenant_id: tenantId },
{
headers: { 'Content-Type': 'application/json' },
validateStatus: () => true
}
);
return response.data;
};
// Final commit of dispatched deliveries — coerces userid/rider_id to int at
// the boundary so a string upstream can't cause a 500 unmarshal error.
export const finalCreatedeliveries = async (deliveryData) => {
const toInt = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : v;
};
const deliveries = (deliveryData.deliveries || []).map((d) => ({
...d,
userid: toInt(d.userid),
rider_id: toInt(d.rider_id)
}));
const response = await axios.post(`https://jupiter.nearle.app/live/api/v1/deliveries/createdeliveries`, deliveries);
return response.data;
};
// Auto rider assignment via either the bike solver or the auto/multi-trip
// solver. Body shape differs per mode; absent_riders is merged through.
export const createAutomationDeliveries = async (variables) => {
const absentRiders = Array.isArray(variables.absent_riders) ? variables.absent_riders : [];
const url =
variables.selectedMode.value == 1
? `https://routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params=${variables.hypertuning_params}`
: `https://routemate.workolik.com/api/v1/optimization/riderassign?strategy=multi_trip`;
const body =
variables.selectedMode.value == 1
? { deliveries: variables.deliveries, absent_riders: absentRiders }
: { ...(variables.data || {}), absent_riders: absentRiders };
const response = await axios.post(url, body);
return response.data;
};
// Push a notification to a rider after assignment.
export const notifyRider = async (riderToken) => {
if (!riderToken) {
throw new Error('Invalid rider token');
}
const response = await axios.post(`${process.env.REACT_APP_URL}/utils/notifyuser`, {
token: riderToken,
notification: {
title: 'NearleXpress',
body: 'Orders have been placed for delivery. Kindly accept and process deliveries',
sound: 'ring',
image: ''
}
});
return response.data;
};
// Paginated deliveries fetch — supports both "All zones" (appId === 0) and
// per-zone scoping. Returns { rows, nextPage } for useInfiniteQuery.
export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
let [, appId, userid, currentStatus, startdate, enddate, rowsPerPage, searchword, tenantid, locationid, riderid] = queryKey;
currentStatus = currentStatus == 'All' ? 'all' : currentStatus;
const url =
appId === 0
? `${process.env.REACT_APP_URL}/deliveries/getdeliveries/?appuserid=${userid}&status=${currentStatus}&fromdate=${startdate}&todate=${enddate}&pageno=${pageParam}&pagesize=${rowsPerPage}&keyword=${searchword}&tenantid=${tenantid}&locationid=${locationid}&userid=${riderid}`
: `${process.env.REACT_APP_URL}/deliveries/getdeliveries/?applocationid=${appId}&status=${currentStatus}&fromdate=${startdate}&todate=${enddate}&pageno=${pageParam}&pagesize=${rowsPerPage}&keyword=${searchword}&tenantid=${tenantid}&locationid=${locationid}&userid=${riderid}`;
const response = await axios.get(url);
return {
rows: response.data.details,
nextPage: response.data.details.length === Number(rowsPerPage) ? pageParam + 1 : undefined
};
};

View File

@@ -0,0 +1,976 @@
import React, { useMemo } from 'react';
import {
MdPublic,
MdSwapHoriz,
MdExpandMore,
MdCheckCircle,
MdAccessTime,
MdStraighten,
MdErrorOutline,
MdFormatListBulleted,
MdTimer,
MdWarning,
MdClose,
MdSpeed,
MdStar,
MdFlag,
MdHourglassBottom
} from 'react-icons/md';
import {
stepColor,
getStatusStyle,
FINAL_STATUSES,
SKIPPED_STATUSES,
ordinal
} from './dispatchShared';
// Right-side data panel rendered in Compare mode. Pure presentation +
// memoized derivations: feed it the comparison state from Dispatch and
// the panel handles its own layout (compliance score, day overview, route
// sequence with cascade grouping, KPIs, highlights, trips, focused-step
// details, deviations, full step list).
//
// Props:
// focusedRider — the rider whose day is being compared
// compareDeltas — per-step actual deltas (see useMemo
// in Dispatch.js)
// compareSummary — day rollup: actualKm/onTime/etc
// actualOrdered — compareDeltas sorted by sequenceStep (visit
// order). Used by the route-sequence section.
// focusedCompareStep — currently focused step (1..N) or null
// setFocusedCompareStep — setter; pass a function-updater for toggle
// sequenceOpen — whether the "Route sequence" section is open
// setSequenceOpen — setter for sequenceOpen
// expandedSeqGroups — Set of expanded sequence-diff group indices
// setExpandedSeqGroups — setter (Set state)
// onClose — called when the user clicks the × header btn
function CompareDataPanel({
focusedRider,
compareDeltas,
compareSummary,
actualOrdered,
focusedCompareStep,
setFocusedCompareStep,
sequenceOpen,
setSequenceOpen,
expandedSeqGroups,
setExpandedSeqGroups,
onClose
}) {
// All derivations live in a single useMemo so the cost of re-running
// them is paid only when an upstream input actually changes — not on
// every parent render (e.g. cursor moving over the map, sync toggle
// toggling, etc.). Keeping them grouped also makes the data contract
// visible at a glance.
const view = useMemo(() => {
const sum = compareSummary;
const totalSteps = sum.onTime + sum.late;
const deviations = compareDeltas.filter((d) => d.anomaly);
const delivered = compareDeltas.filter((d) =>
FINAL_STATUSES.has(String(d.orderstatus || '').toLowerCase())
).length;
const skipped = compareDeltas.filter((d) =>
SKIPPED_STATUSES.has(String(d.orderstatus || '').toLowerCase())
).length;
const stepDeltaPct =
sum.kmDeltaPct == null
? ''
: sum.kmDeltaPct > 25
? 'is-over'
: sum.kmDeltaPct < -5
? 'is-under'
: '';
// Compliance score (0-100): 60% delivered + 25% on-time + 15% no-deviation.
const totalForScore = compareDeltas.length || 1;
const onTimeForScore = sum.onTime + sum.late || 1;
const score = Math.round(
(delivered / totalForScore) * 60 +
(sum.onTime / onTimeForScore) * 25 +
((totalForScore - sum.anomalies) / totalForScore) * 15
);
const scoreColor = score >= 85 ? '#16a34a' : score >= 65 ? '#f59e0b' : '#dc2626';
const scoreLabel = score >= 85 ? 'Excellent' : score >= 65 ? 'Acceptable' : 'Needs review';
// KPIs derived from delivery timestamps.
const withActual = compareDeltas.filter((d) => d.actualTs);
const firstDelivery = withActual.reduce(
(acc, d) => (!acc || d.actualTs.isBefore(acc) ? d.actualTs : acc),
null
);
const lastDelivery = withActual.reduce(
(acc, d) => (!acc || d.actualTs.isAfter(acc) ? d.actualTs : acc),
null
);
const activeMin =
firstDelivery && lastDelivery
? Math.max(0, lastDelivery.diff(firstDelivery, 'minute'))
: 0;
const avgPerStop =
compareDeltas.length > 1
? Math.round(activeMin / (compareDeltas.length - 1))
: 0;
const avgSpeed =
activeMin > 0 ? (sum.actualKm / (activeMin / 60)).toFixed(1) : null;
// Best / worst step.
const readyDeltas = compareDeltas.filter(
(d) => !d.isLoading && d.coordsCount > 0
);
const bestStep =
readyDeltas
.filter((d) => d.timeDeltaMin != null && !d.anomaly)
.sort((a, b) => a.timeDeltaMin - b.timeDeltaMin)[0] || null;
const worstStep =
readyDeltas
.filter((d) => d.anomaly)
.sort((a, b) => {
const sa =
Math.abs(a.kmDeltaPct || 0) + (a.timeDeltaMin > 0 ? a.timeDeltaMin : 0);
const sb =
Math.abs(b.kmDeltaPct || 0) + (b.timeDeltaMin > 0 ? b.timeDeltaMin : 0);
return sb - sa;
})[0] || null;
// Route sequence — which actual-visit positions don't match the
// dispatch-planned step.
const outOfOrderSteps = actualOrdered.filter((d, i) => {
const planned = d.order?.step;
return planned != null && planned !== i + 1;
});
// Cascade-aware grouping of out-of-order steps: consecutive entries
// with the same `delta` collapse into one "N consecutive shifted +K"
// card so a single bad first stop doesn't paint 12 noisy rows.
const seqRuns = [];
outOfOrderSteps.forEach((d) => {
const planned = d.order?.step;
const actualPos =
actualOrdered.findIndex((x) => x.sequenceStep === d.sequenceStep) + 1;
const delta = actualPos - planned;
const last = seqRuns[seqRuns.length - 1];
if (last && last.delta === delta && last.lastActualPos + 1 === actualPos) {
last.items.push({ d, planned, actualPos, delta });
last.lastActualPos = actualPos;
} else {
seqRuns.push({
delta,
items: [{ d, planned, actualPos, delta }],
lastActualPos: actualPos
});
}
});
// Trip-by-trip rollup.
const tripBuckets = {};
focusedRider.orders.forEach((o) => {
const t = o.trip_number || 1;
if (!tripBuckets[t]) tripBuckets[t] = [];
tripBuckets[t].push(o);
});
const tripList = Object.entries(tripBuckets)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([tNum, tOrders]) => ({
tNum,
count: tOrders.length,
actualKm: tOrders.reduce(
(s, o) => s + parseFloat(o.actualkms || o.kms || 0),
0
),
delivered: tOrders.filter((o) =>
FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase())
).length
}));
return {
sum,
totalSteps,
deviations,
delivered,
skipped,
stepDeltaPct,
score,
scoreColor,
scoreLabel,
firstDelivery,
lastDelivery,
activeMin,
avgPerStop,
avgSpeed,
bestStep,
worstStep,
outOfOrderSteps,
seqRuns,
tripList
};
}, [focusedRider, compareDeltas, compareSummary, actualOrdered]);
const focused =
focusedCompareStep != null
? compareDeltas.find((d) => d.sequenceStep === focusedCompareStep)
: null;
const toggleSeqGroup = (idx) => {
setExpandedSeqGroups((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const focusStep = (sequenceStep) => {
setFocusedCompareStep((prev) => (prev === sequenceStep ? null : sequenceStep));
};
// Renders a single shifted-step diff card (used both stand-alone and
// nested under an expanded group).
const renderDiffRow = (item, focusable = true) => {
const { d, planned, actualPos, delta } = item;
return (
<li
key={`diff-${d.sequenceStep}`}
className={`cdp-seq-diff${
focusedCompareStep === d.sequenceStep ? ' is-focused' : ''
}${focusable ? '' : ' is-nested'}`}
onClick={() => focusStep(d.sequenceStep)}
>
<span
className="cdp-seq-diff-num"
style={{ background: stepColor((planned || d.sequenceStep) - 1) }}
>
{planned || d.sequenceStep}
</span>
<div className="cdp-seq-diff-body">
<div className="cdp-seq-diff-title">
{d.deliverycustomer || `Step ${planned || d.sequenceStep}`}
</div>
<div className="cdp-seq-diff-sub">
Visited <strong>{ordinal(actualPos)}</strong>{' '}
· planned <strong>{ordinal(planned)}</strong>
</div>
</div>
<span className="cdp-seq-diff-tag">
{delta > 0 ? `+${delta}` : `${delta}`}
</span>
</li>
);
};
const {
sum,
totalSteps,
deviations,
delivered,
skipped,
stepDeltaPct,
score,
scoreColor,
scoreLabel,
firstDelivery,
lastDelivery,
activeMin,
avgPerStop,
avgSpeed,
bestStep,
worstStep,
outOfOrderSteps,
seqRuns,
tripList
} = view;
return (
<aside id="compare-data-panel" className="compare-data-panel">
<div className="cdp-head">
<div className="cdp-head-title">
<span
className="cdp-rider-dot"
style={{ background: focusedRider.color }}
/>
<div className="cdp-head-text">
<div className="cdp-rider-name">{focusedRider.riderName}</div>
<div className="cdp-head-badge">PLANNED vs ACTUAL</div>
</div>
</div>
<button
type="button"
className="cdp-close"
onClick={onClose}
title="Exit compare"
aria-label="Exit compare"
>
<MdClose />
</button>
</div>
<div className="cdp-scroll">
{/* Compliance score — headline gauge blending delivery, on-time,
and route-fidelity into one number. */}
<section className="cdp-section cdp-score-section">
<div className="cdp-score-wrap">
<div
className="cdp-score-ring"
style={{
background: `conic-gradient(${scoreColor} ${score * 3.6}deg, rgba(15,23,42,0.08) 0deg)`
}}
>
<div className="cdp-score-inner">
<div className="cdp-score-value" style={{ color: scoreColor }}>
{score}
</div>
<div className="cdp-score-unit">/100</div>
</div>
</div>
<div className="cdp-score-body">
<div className="cdp-score-label" style={{ color: scoreColor }}>
{scoreLabel}
</div>
<div className="cdp-score-title">Compliance score</div>
<div className="cdp-score-sub">
{delivered}/{compareDeltas.length} delivered
{sum.anomalies > 0
? ` · ${sum.anomalies} deviation${sum.anomalies > 1 ? 's' : ''}`
: ''}
{sum.late > 0 ? ` · ${sum.late} late` : ''}
{skipped > 0 ? ` · ${skipped} skipped` : ''}
</div>
</div>
</div>
</section>
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdPublic /></span>
<span className="cdp-section-title">Day overview</span>
</div>
<div className="cdp-tiles">
<div className="cdp-tile">
<div className="cdp-tile-label">
<MdStraighten /> Distance
</div>
<div className="cdp-tile-value">
{sum.actualKm.toFixed(1)}
<span className="cdp-tile-unit">km</span>
</div>
<div className="cdp-tile-sub">actual</div>
</div>
<div className={`cdp-tile${sum.anomalies > 0 ? ' is-warn' : ''}`}>
<div className="cdp-tile-label">
<MdWarning /> Deviation
</div>
<div className={`cdp-tile-value ${stepDeltaPct}`}>
{sum.kmDeltaPct != null
? `${sum.kmDeltaPct > 0 ? '+' : ''}${sum.kmDeltaPct.toFixed(0)}%`
: '—'}
</div>
<div className="cdp-tile-sub">
{sum.anomalies > 0 ? `${sum.anomalies} flagged` : 'within plan'}
</div>
</div>
<div className={`cdp-tile${sum.late > 0 ? ' is-warn' : ''}`}>
<div className="cdp-tile-label">
<MdAccessTime /> On-time
</div>
<div className="cdp-tile-value">
{sum.onTime}
{totalSteps > 0 && (
<span className="cdp-tile-unit">/{totalSteps}</span>
)}
</div>
<div className="cdp-tile-sub">
{sum.late > 0 ? `${sum.late} late` : 'all on schedule'}
</div>
</div>
</div>
</section>
{/* Route sequence — collapsible, default open. Shows planned vs
actual visit order with cascade-aware diff grouping. */}
{compareDeltas.length > 0 && (
<section className="cdp-section cdp-seq-section">
<div
className="cdp-section-head cdp-section-head-clickable"
onClick={() => setSequenceOpen((v) => !v)}
role="button"
aria-expanded={sequenceOpen}
title={sequenceOpen ? 'Collapse route sequence' : 'Expand route sequence'}
>
<span className="cdp-section-icon">
<MdSwapHoriz />
</span>
<span className="cdp-section-title">Route sequence</span>
<span
className={`cdp-seq-status${outOfOrderSteps.length > 0 ? ' is-warn' : ' is-good'}`}
>
{outOfOrderSteps.length > 0
? `${outOfOrderSteps.length} out of order`
: 'In order'}
</span>
<span className={`cdp-seq-toggle${sequenceOpen ? ' is-open' : ''}`}>
<MdExpandMore />
</span>
</div>
{sequenceOpen && (
<div className="cdp-seq">
{outOfOrderSteps.length > 0 ? (
<ul className="cdp-seq-diffs">
{seqRuns.map((run, runIdx) => {
if (run.items.length === 1) {
return renderDiffRow(run.items[0]);
}
const first = run.items[0];
const last = run.items[run.items.length - 1];
const isOpen = expandedSeqGroups.has(runIdx);
const deltaStr =
run.delta > 0 ? `+${run.delta}` : `${run.delta}`;
const groupFocused = run.items.some(
(it) => it.d.sequenceStep === focusedCompareStep
);
return (
<React.Fragment key={`run-${runIdx}-${first.d.sequenceStep}`}>
<li
className={`cdp-seq-diff is-group${isOpen ? ' is-expanded' : ''}${groupFocused ? ' is-focused' : ''}`}
onClick={() => toggleSeqGroup(runIdx)}
aria-expanded={isOpen}
>
<span className="cdp-seq-group-num">
<span
className="cdp-seq-group-num-bg"
style={{
background: `linear-gradient(135deg, ${stepColor((first.planned || 1) - 1)}, ${stepColor((last.planned || 1) - 1)})`
}}
/>
<span className="cdp-seq-group-num-label">
{run.items.length}×
</span>
</span>
<div className="cdp-seq-diff-body">
<div className="cdp-seq-diff-title">
{run.items.length} consecutive steps shifted{' '}
<span className="cdp-seq-group-delta">{deltaStr}</span>
</div>
<div className="cdp-seq-diff-sub">
Planned {ordinal(first.planned)}{ordinal(last.planned)}{' '}
visited{' '}
<strong>
{ordinal(first.actualPos)}{ordinal(last.actualPos)}
</strong>
</div>
</div>
<span className="cdp-seq-diff-tag">{deltaStr}</span>
<span
className={`cdp-seq-group-toggle${isOpen ? ' is-open' : ''}`}
aria-hidden="true"
>
<MdExpandMore />
</span>
</li>
{isOpen && (
<li className="cdp-seq-group-children-wrap">
<ul className="cdp-seq-group-children">
{run.items.map((it) => renderDiffRow(it, false))}
</ul>
</li>
)}
</React.Fragment>
);
})}
</ul>
) : (
<div className="cdp-seq-good">
<MdCheckCircle /> Rider followed the planned route in order.
</div>
)}
</div>
)}
</section>
)}
{/* Timing — clock-style timeline. First/last delivery render as
digital clock faces flanking a duration centerpiece. Tiny
"Started" / "Finished" captions give the row a narrative.
Below: avg-per-stop with a dotted stops-row visualization,
and avg speed with a 0-60 gauge bar. */}
{(firstDelivery || lastDelivery) && (
<section className="cdp-section cdp-timing-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdTimer /></span>
<span className="cdp-section-title">Timing</span>
{activeMin > 0 && (
<span className="cdp-timing-active-tag">
<span className="cdp-timing-active-pulse" />
Day window
</span>
)}
</div>
<div className="cdp-timing-clock">
<div className="cdp-clock-card is-start">
<div className="cdp-clock-label">
<MdFlag /> First delivery
</div>
<div className="cdp-clock-face">
<span className="cdp-clock-time">
{firstDelivery ? firstDelivery.format('hh:mm') : '—'}
</span>
<span className="cdp-clock-period">
{firstDelivery ? firstDelivery.format('A') : ''}
</span>
</div>
<div className="cdp-clock-caption">Started</div>
</div>
<div className="cdp-clock-track" aria-hidden="true">
<span className="cdp-clock-track-line" />
<span className="cdp-clock-track-dot is-start" />
<span className="cdp-clock-track-dot is-end" />
<div className="cdp-clock-duration">
<span className="cdp-clock-duration-icon">
<MdHourglassBottom />
</span>
<span className="cdp-clock-duration-val">
{activeMin > 0
? activeMin >= 60
? `${Math.floor(activeMin / 60)}h ${activeMin % 60}m`
: `${activeMin}m`
: '—'}
</span>
<span className="cdp-clock-duration-sub">active</span>
</div>
</div>
<div className="cdp-clock-card is-end">
<div className="cdp-clock-label">
<MdCheckCircle /> Last delivery
</div>
<div className="cdp-clock-face">
<span className="cdp-clock-time">
{lastDelivery ? lastDelivery.format('hh:mm') : '—'}
</span>
<span className="cdp-clock-period">
{lastDelivery ? lastDelivery.format('A') : ''}
</span>
</div>
<div className="cdp-clock-caption">Finished</div>
</div>
</div>
<div className="cdp-timing-stats">
<div className="cdp-timing-stat">
<div className="cdp-timing-stat-head">
<div className="cdp-timing-stat-icon">
<MdAccessTime />
</div>
<div className="cdp-timing-stat-body">
<div className="cdp-timing-stat-value">
{avgPerStop > 0 ? `${avgPerStop}` : '—'}
{avgPerStop > 0 && (
<span className="cdp-timing-stat-unit">min</span>
)}
</div>
<div className="cdp-timing-stat-label">Avg / stop</div>
</div>
</div>
{compareDeltas.length > 0 && (
<div className="cdp-timing-stat-viz cdp-stops-dots" aria-hidden="true">
{Array.from({ length: Math.min(compareDeltas.length, 12) }).map((_, i) => (
<span key={`dot-${i}`} className="cdp-stop-dot" />
))}
<span className="cdp-timing-stat-viz-label">
{compareDeltas.length} stop{compareDeltas.length === 1 ? '' : 's'}
</span>
</div>
)}
</div>
{avgSpeed != null && (
<div className="cdp-timing-stat">
<div className="cdp-timing-stat-head">
<div className="cdp-timing-stat-icon">
<MdSpeed />
</div>
<div className="cdp-timing-stat-body">
<div className="cdp-timing-stat-value">
{avgSpeed}
<span className="cdp-timing-stat-unit">km/h</span>
</div>
<div className="cdp-timing-stat-label">Avg speed</div>
</div>
</div>
<div className="cdp-timing-stat-viz cdp-speed-gauge" aria-hidden="true">
<div className="cdp-speed-gauge-track">
<div
className="cdp-speed-gauge-fill"
style={{
width: `${Math.min(100, (parseFloat(avgSpeed) / 60) * 100)}%`
}}
/>
</div>
<div className="cdp-speed-gauge-scale">
<span>0</span>
<span>30</span>
<span>60 km/h</span>
</div>
</div>
</div>
)}
</div>
</section>
)}
{/* Highlights — best/worst step quick-pick. Full-width cards stacked
vertically: a colored rail on the left side encodes good/bad,
the customer name is the headline, the step number sits as a
right-aligned chip, and the metric line uses bold pills. */}
{(bestStep || worstStep) && (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdStar /></span>
<span className="cdp-section-title">Highlights</span>
</div>
<div className="cdp-highlights">
{bestStep && (
<div
className="cdp-highlight is-best"
onClick={() => focusStep(bestStep.sequenceStep)}
role="button"
title="Focus this step"
>
<span className="cdp-highlight-rail" aria-hidden="true" />
<div className="cdp-highlight-content">
<div className="cdp-highlight-top">
<span className="cdp-highlight-label">
<span className="cdp-highlight-chip">
<MdCheckCircle />
</span>
Fastest stop
</span>
<span
className="cdp-highlight-step-chip"
style={{
background: stepColor(bestStep.sequenceStep - 1)
}}
>
Step {bestStep.sequenceStep}
</span>
</div>
<div className="cdp-highlight-title">
{bestStep.deliverycustomer || `Step ${bestStep.sequenceStep}`}
</div>
<div className="cdp-highlight-meta">
<span className="cdp-highlight-pill is-good">
{bestStep.timeDeltaMin != null
? bestStep.timeDeltaMin === 0
? 'On schedule'
: `${bestStep.timeDeltaMin > 0 ? '+' : ''}${bestStep.timeDeltaMin} min vs plan`
: 'On schedule'}
</span>
</div>
</div>
</div>
)}
{worstStep && (
<div
className="cdp-highlight is-worst"
onClick={() => focusStep(worstStep.sequenceStep)}
role="button"
title="Focus this step"
>
<span className="cdp-highlight-rail" aria-hidden="true" />
<div className="cdp-highlight-content">
<div className="cdp-highlight-top">
<span className="cdp-highlight-label">
<span className="cdp-highlight-chip">
<MdWarning />
</span>
Biggest deviation
</span>
<span
className="cdp-highlight-step-chip"
style={{
background: stepColor(worstStep.sequenceStep - 1)
}}
>
Step {worstStep.sequenceStep}
</span>
</div>
<div className="cdp-highlight-title">
{worstStep.deliverycustomer || `Step ${worstStep.sequenceStep}`}
</div>
<div className="cdp-highlight-meta">
{worstStep.kmDeltaPct != null && (
<span className="cdp-highlight-pill is-bad">
{worstStep.kmDeltaPct > 0 ? '+' : ''}
{worstStep.kmDeltaPct.toFixed(0)}% route
</span>
)}
{worstStep.timeDeltaMin != null && worstStep.timeDeltaMin > 0 && (
<span className="cdp-highlight-pill is-bad">
+{worstStep.timeDeltaMin}m late
</span>
)}
</div>
</div>
</div>
)}
</div>
</section>
)}
{/* Trip breakdown — only when rider ran >1 trip. */}
{tripList.length > 1 && (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdSwapHoriz /></span>
<span className="cdp-section-title">Trips ({tripList.length})</span>
</div>
<div className="cdp-trips">
{tripList.map((t) => (
<div key={`trip-${t.tNum}`} className="cdp-trip">
<div className="cdp-trip-head">
<span className="cdp-trip-badge">Trip {t.tNum}</span>
<span className="cdp-trip-meta">
{t.delivered}/{t.count} delivered
</span>
</div>
<div className="cdp-trip-stats">
<span title="Distance">
<MdStraighten />
{t.actualKm.toFixed(1)}km
</span>
</div>
</div>
))}
</div>
</section>
)}
{/* Focused-step deep-dive — appears only when a step is selected. */}
{focused && (() => {
const color = stepColor(focused.sequenceStep - 1);
const timeDeltaCls =
focused.timeDeltaMin != null
? focused.timeDeltaMin > 10
? 'is-over'
: focused.timeDeltaMin < -2
? 'is-under'
: ''
: '';
const statusStyle = getStatusStyle(focused.orderstatus);
return (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon"><MdSwapHoriz /></span>
<span className="cdp-section-title">
Step {focused.sequenceStep} details
</span>
<button
type="button"
className="cdp-section-clear"
onClick={() => setFocusedCompareStep(null)}
title="Clear step focus"
>
Show all
</button>
</div>
<div className={`compare-delta${focused.anomaly ? ' is-anomaly' : ''}`}>
<div className="compare-delta-title">
<span
className="compare-delta-step-badge"
style={{ background: color }}
>
{focused.sequenceStep}
</span>
<div className="compare-delta-title-text">
<div className="compare-delta-title-main">
{focused.deliverycustomer || `Step ${focused.sequenceStep}`}
</div>
<div className="compare-delta-title-sub">
{focused.pickupcustomer ? `from ${focused.pickupcustomer} · ` : ''}
Order #{focused.orderid}
</div>
</div>
{focused.orderstatus && (
<span
className="compare-delta-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
</div>
<div className="compare-delta-grid">
<div className={`compare-delta-cell${focused.anomaly ? ' is-anomaly' : ''}`}>
<span className="compare-delta-cell-label">Distance</span>
<span className="compare-delta-cell-val">
{focused.actualKm.toFixed(2)}{' '}
<span className="compare-delta-cell-unit">km</span>
</span>
<span className="compare-delta-cell-sub">actual</span>
</div>
<div className="compare-delta-cell">
<span className="compare-delta-cell-label">Time</span>
<span className={`compare-delta-cell-val ${timeDeltaCls}`}>
{focused.timeDeltaMin != null
? `${focused.timeDeltaMin > 0 ? '+' : ''}${focused.timeDeltaMin} min`
: '—'}
</span>
<span className="compare-delta-cell-sub">
{focused.actualTs && focused.expectedTs
? `${focused.actualTs.format('HH:mm')} vs ${focused.expectedTs.format('HH:mm')}`
: focused.actualTs
? `delivered ${focused.actualTs.format('HH:mm')}`
: 'in flight'}
</span>
</div>
</div>
</div>
</section>
);
})()}
{/* Deviations list — anomaly-only steps. */}
{deviations.length > 0 && (
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon cdp-icon-warn">
<MdErrorOutline />
</span>
<span className="cdp-section-title">
Deviations ({deviations.length})
</span>
</div>
<ul className="cdp-dev-list">
{deviations.map((d) => {
const color = stepColor(d.sequenceStep - 1);
const kmSign = d.kmDelta >= 0 ? '+' : '';
return (
<li
key={`dev-${d.sequenceStep}`}
className={`cdp-dev-item${focusedCompareStep === d.sequenceStep ? ' is-focused' : ''}`}
onClick={() => focusStep(d.sequenceStep)}
>
<span className="cdp-dev-num" style={{ background: color }}>
{d.sequenceStep}
</span>
<div className="cdp-dev-body">
<div className="cdp-dev-title">
{d.deliverycustomer || `Step ${d.sequenceStep}`}
</div>
<div className="cdp-dev-meta">
{d.kmDeltaPct != null && (
<span className="cdp-dev-chip is-over">
{kmSign}{d.kmDeltaPct.toFixed(0)}% route
</span>
)}
{d.timeDeltaMin != null && d.timeDeltaMin > 10 && (
<span className="cdp-dev-chip is-over">
+{d.timeDeltaMin}m late
</span>
)}
</div>
</div>
</li>
);
})}
</ul>
</section>
)}
{/* Full step list. */}
<section className="cdp-section">
<div className="cdp-section-head">
<span className="cdp-section-icon">
<MdFormatListBulleted />
</span>
<span className="cdp-section-title">
Steps ({compareDeltas.length})
</span>
<span className="cdp-section-sub">
{delivered}/{compareDeltas.length} delivered
</span>
</div>
<ul className="cdp-step-list">
{compareDeltas.map((d) => {
const color = stepColor(d.sequenceStep - 1);
const statusLow = String(d.orderstatus || '').toLowerCase();
const isDelivered = FINAL_STATUSES.has(statusLow);
const isSkipped = SKIPPED_STATUSES.has(statusLow);
const isCorrect = isDelivered && !d.anomaly;
const isFocused = focusedCompareStep === d.sequenceStep;
const statusStyle = getStatusStyle(d.orderstatus);
const timeCls =
d.timeDeltaMin != null
? d.timeDeltaMin > 10
? 'is-over'
: d.timeDeltaMin < -2
? 'is-under'
: ''
: '';
const stepCls = [
'cdp-step',
isFocused ? 'is-focused' : '',
d.anomaly ? 'is-anomaly' : '',
isCorrect ? 'is-correct' : '',
isSkipped ? 'is-skipped' : '',
d.isLoading ? 'is-loading' : ''
].filter(Boolean).join(' ');
return (
<li
key={`step-${d.sequenceStep}`}
className={stepCls}
onClick={() => focusStep(d.sequenceStep)}
>
<span className="cdp-step-num" style={{ background: color }}>
{d.sequenceStep}
{isCorrect && (
<span className="cdp-step-check">
<MdCheckCircle />
</span>
)}
{d.anomaly && (
<span className="cdp-step-flag">
<MdErrorOutline />
</span>
)}
</span>
<div className="cdp-step-body">
<div className="cdp-step-title-row">
<span className="cdp-step-title">
{d.deliverycustomer || `Step ${d.sequenceStep}`}
</span>
{d.orderstatus && (
<span
className="cdp-step-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
</div>
<div className="cdp-step-sub">
{d.pickupcustomer ? `from ${d.pickupcustomer} · ` : ''}
Order #{d.orderid}
</div>
<div className="cdp-step-deltas">
<span className="cdp-step-delta" title="Distance">
<MdStraighten />
{d.actualKm.toFixed(1)}km
</span>
<span className={`cdp-step-delta ${timeCls}`} title="Delivery time">
<MdAccessTime />
{d.actualTs ? d.actualTs.format('HH:mm') : '—'}
{d.timeDeltaMin != null && (
<small>
{' '}{d.timeDeltaMin > 0 ? '+' : ''}{d.timeDeltaMin}m
</small>
)}
</span>
</div>
</div>
</li>
);
})}
</ul>
</section>
</div>
</aside>
);
}
export default CompareDataPanel;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,806 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Autocomplete,
Backdrop,
Box,
Button,
Card,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Stack,
Tab,
Tabs,
TextField,
Tooltip,
Typography
} from '@mui/material';
import { useMutation, useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { HiOutlineArrowLeft } from 'react-icons/hi';
import { IoReload } from 'react-icons/io5';
import { MdTwoWheeler, MdSwapHoriz } from 'react-icons/md';
import {
createAutomationDeliveries,
createOptimisationDeliveries,
fetchRidersList,
finalCreatedeliveries,
notifyRider,
reconcileSteps
} from '../api/api';
import { OpenToast } from 'components/nearle_components/OpenToast';
import CSVExport from 'components/third-party/ReactTable';
import CircularLoader from 'components/nearle_components/CircularLoader';
import Dispatch from './Dispatch';
import { stepColor } from './dispatchShared';
const tuningTypes = [
{ tuneid: 1, type: 'Balanced', value: 'balanced' },
{ tuneid: 2, type: 'Aggressive Speed', value: 'aggressive_speed' },
{ tuneid: 3, type: 'Fuel Saver', value: 'fuel_saver' },
{ tuneid: 4, type: 'Zone Strict', value: 'zone_strict' }
];
// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for
// the Reconcile tab UI and the reconcile-API payload.
const extractRiders = (previewData) => {
if (!previewData) return [];
const map = new Map();
// De-dupe by orderid across the whole tree. A rider can legitimately appear
// in multiple zones (one per delivery suburb), so the same rider_id is
// visited more than once. Without this guard, any stale copy left behind
// by applyReconcileResponse gets concatenated into the rider's orders and
// the same orderid is sent twice to /deliveries/createdeliveries.
const seenOrderIds = new Set();
const push = (riderId, riderName, orders) => {
if (riderId == null) return;
const key = String(riderId);
if (!map.has(key)) {
map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] });
}
const entry = map.get(key);
(orders || []).forEach((o) => {
const oid = o?.orderid != null ? String(o.orderid) : null;
if (oid) {
if (seenOrderIds.has(oid)) return;
seenOrderIds.add(oid);
}
entry.orders.push(o);
});
if (!entry.rider_name && riderName) entry.rider_name = riderName;
};
if (Array.isArray(previewData.zones) && previewData.zones.length) {
previewData.zones.forEach((z) => {
(z.riders || []).forEach((r) => {
const id = r.rider_id ?? r.userid;
const name = r.rider_name || r.username || `Rider ${id}`;
push(id, name, r.orders);
});
});
} else if (Array.isArray(previewData.details)) {
previewData.details.forEach((o) => {
const id = o.rider_id ?? o.userid;
const name = o.rider_name || o.ridername || `Rider ${id}`;
push(id, name, [o]);
});
}
return Array.from(map.values());
};
// Reverse of extractRiders — flatten rider-grouped list into a details-style
// array (used as the Assign Orders payload).
const flattenRiders = (riders) => {
const out = [];
riders.forEach((r) => {
// Go backend types Deliveries.userid as int — coerce here so any
// upstream string (AI response, riders API, change-rider edit) gets
// normalised before the JSON body is built.
const ridNum = Number(r.rider_id);
const rid = Number.isFinite(ridNum) ? ridNum : r.rider_id;
(r.orders || []).forEach((o) => {
out.push({
...o,
rider_id: rid,
userid: rid,
rider_name: r.rider_name,
rider: r.rider_name
});
});
});
return out;
};
// Move one order from oldRiderId -> newRiderId inside dispatchPreviewData.
// Mutates both the zones[].riders[].orders[] tree (so the Dispatch tab
// renders the change) AND the flat details[] list (so Assign Orders picks
// it up). Returns a NEW preview object (immutable update).
const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName }) => {
if (!preview) return preview;
const next = JSON.parse(JSON.stringify(preview));
// 1) Update flat details list
if (Array.isArray(next.details)) {
next.details = next.details.map((o) =>
String(o.orderid) === String(orderId)
? { ...o, rider_id: newRiderId, userid: newRiderId, rider_name: newRiderName, rider: newRiderName }
: o
);
}
// 2) Move within zones[].riders[].orders[]
if (Array.isArray(next.zones)) {
let movedOrder = null;
let homeZoneIdx = -1;
for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) {
const zone = next.zones[zi];
if (!Array.isArray(zone.riders)) continue;
for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) {
const r = zone.riders[ri];
if (!Array.isArray(r.orders)) continue;
const oi = r.orders.findIndex((o) => String(o.orderid) === String(orderId));
if (oi !== -1) {
movedOrder = r.orders[oi];
r.orders.splice(oi, 1);
homeZoneIdx = zi;
}
}
}
if (movedOrder) {
const updated = {
...movedOrder,
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
rider: newRiderName
};
let placed = false;
for (const zone of next.zones) {
if (!Array.isArray(zone.riders)) continue;
const target = zone.riders.find(
(r) => String(r.rider_id ?? r.userid) === String(newRiderId)
);
if (target) {
target.orders = target.orders || [];
target.orders.push(updated);
placed = true;
break;
}
}
if (!placed && homeZoneIdx >= 0) {
next.zones[homeZoneIdx].riders.push({
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
orders: [updated]
});
}
}
}
return next;
};
// Merge a reconcile-API response { riders:[{rider_id, orders}] } back into
// dispatchPreviewData. Replaces each rider's orders[] in zones (preserving
// zone containment), then rebuilds the flat details list from the new tree.
const applyReconcileResponse = (preview, response) => {
if (!preview || !Array.isArray(response?.riders)) return preview;
const next = JSON.parse(JSON.stringify(preview));
const newOrdersByRider = new Map(
response.riders.map((r) => [String(r.rider_id), r.orders || []])
);
if (Array.isArray(next.zones) && next.zones.length) {
// Pass 1: wipe every existing copy of a responding rider's orders across
// ALL zones. The server's reconciled list is the single source of truth,
// and a rider can be present in multiple zones (one per delivery suburb).
// The previous "update first match, delete from map" loop left stale
// copies in the other zones, which extractRiders then concatenated into
// duplicate orderids — surfacing as duplicate deliveries on Assign.
next.zones.forEach((zone) => {
if (!Array.isArray(zone.riders)) return;
zone.riders.forEach((r) => {
const key = String(r.rider_id ?? r.userid);
if (newOrdersByRider.has(key)) r.orders = [];
});
});
// Pass 2: drop the reconciled orders onto the first zone that already
// lists the rider. If the rider isn't anywhere in the tree, append a
// fresh rider entry to zone[0].
newOrdersByRider.forEach((orders, riderKey) => {
let placed = false;
for (const zone of next.zones) {
if (!Array.isArray(zone.riders)) continue;
const target = zone.riders.find(
(r) => String(r.rider_id ?? r.userid) === riderKey
);
if (target) {
target.orders = orders;
placed = true;
break;
}
}
if (!placed) {
const target = next.zones[0];
target.riders = target.riders || [];
target.riders.push({
rider_id: Number(riderKey) || riderKey,
rider_name: orders[0]?.rider_name || `Rider ${riderKey}`,
orders
});
}
});
} else {
next.zones = [
{
zone_name: 'Reconciled',
riders: response.riders.map((r) => ({
rider_id: r.rider_id,
rider_name: r.rider_name || `Rider ${r.rider_id}`,
orders: r.orders || []
}))
}
];
}
// Rebuild flat details from the updated zones->riders->orders tree.
const flatDetails = [];
next.zones.forEach((zone) => {
(zone.riders || []).forEach((r) => {
(r.orders || []).forEach((o) => {
flatDetails.push({
...o,
rider_id: r.rider_id,
userid: r.rider_id,
rider_name: r.rider_name,
rider: r.rider_name
});
});
});
});
next.details = flatDetails;
return next;
};
const Preview = () => {
const navigate = useNavigate();
const location = useLocation();
const stateData = location.state || {};
// SINGLE SOURCE OF TRUTH: every Change Rider / Reconcile / Re-Assign goes
// through this state. The Dispatch tab renders from it, the Reconcile tab
// derives its rider list from it, and Assign Orders sends a flattened copy
// of it to the API.
const [dispatchPreviewData, setDispatchPreviewData] = useState(stateData.dispatchPreviewData || null);
// The AI response arrives via location.state, which the browser stores in
// history.state and persists across reloads. That means a reload of
// /dispatch/preview would re-hydrate the stale snapshot — including any
// pending edits the user thought they discarded. Bounce to /orders when
// there's no fresh response, and wipe the history snapshot once consumed
// so a later reload / back-forward also bounces instead of re-using it.
useEffect(() => {
if (!stateData.dispatchPreviewData) {
navigate('/nearle/orders', { replace: true });
return;
}
if (typeof window !== 'undefined' && window.history?.state) {
window.history.replaceState({ ...window.history.state, usr: null }, '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [csvExportData, setCsvExportData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [tabValue, setTabValue] = useState(0);
const [reconcileLoading, setReconcileLoading] = useState(false);
const [hasReconciled, setHasReconciled] = useState(false);
// Tracks riders whose orders have been edited since the last AI response
// or successful reconcile. Only these are sent to the reconcile API — the
// server-side step re-ordering only needs to see what actually changed.
const [dirtyRiderIds, setDirtyRiderIds] = useState(() => new Set());
// Change-rider dialog state
const [changeDialogOpen, setChangeDialogOpen] = useState(false);
const [selectedOrder, setSelectedOrder] = useState(null);
const [selectedOldRiderId, setSelectedOldRiderId] = useState(null);
const [selectedNewRider, setSelectedNewRider] = useState(null);
const aiMode = stateData.aiMode ?? 1;
const selectedMode = stateData.selectedMode || null;
const deliveryData = stateData.deliveryData || [];
const autoRiders = stateData.autoRiders || [];
const absentRidersPayload = stateData.absentRidersPayload || [];
const rider = stateData.rider || null;
const appId = useMemo(() => {
if (stateData.appId) return stateData.appId;
if (typeof window !== 'undefined') {
const v = localStorage.getItem('applocationid');
return v ? Number(v) : 0;
}
return 0;
}, [stateData.appId]);
const { data: ridersList } = useQuery({
queryKey: ['ridersList', appId],
queryFn: fetchRidersList,
enabled: !!appId,
staleTime: 5 * 60 * 1000
});
// Derived: rider list for the Reconcile tab. Recomputes whenever the cache
// (dispatchPreviewData) changes — so Change Rider / Reconcile both reflect
// here without a separate state.
const reconcileRiders = useMemo(() => extractRiders(dispatchPreviewData), [dispatchPreviewData]);
// Derived: flat orders list used for the Assign Orders payload + CSV export.
// Always reflects the latest cache state.
const finaldeliveryList = useMemo(() => {
const flat = flattenRiders(reconcileRiders);
if (flat.length) return computeDeliveryAmounts(flat);
if (Array.isArray(dispatchPreviewData?.details)) {
return computeDeliveryAmounts(dispatchPreviewData.details);
}
return [];
}, [reconcileRiders, dispatchPreviewData]);
useEffect(() => {
const filtered = finaldeliveryList.map((item) => ({
zone_name: item.zone_name,
ordernotes: item.ordernotes,
rider: item.rider,
step: item.step,
ordertype: item.ordertype,
orderamount: item.orderamount,
riderkms: item.riderkms,
cumulativekms: item.cumulativekms,
baseprice: item.baseprice,
minkm: item.minkm,
priceperkm: item.priceperkm,
kms: item.kms,
actualkms: item.actualkms,
rider_charge: item.rider_charge,
deliveryamt: item.deliveryamt,
deliverycharges: item.deliverycharges,
profit: item.profit
}));
setCsvExportData(filtered);
}, [finaldeliveryList]);
const notifyRiderMutation = useMutation({
mutationFn: notifyRider,
onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000),
onError: (error) => OpenToast(error.message, 'error', 2000)
});
const createDeliveryMutation = useMutation({
mutationFn: aiMode == 0 ? createOptimisationDeliveries : createAutomationDeliveries,
onSuccess: (data) => {
OpenToast('Orders Optimised Successfully', 'success', 2000);
// Brand new response = brand new source of truth.
setDispatchPreviewData(data);
setHasReconciled(false);
setDirtyRiderIds(new Set());
setIsLoading(false);
},
onError: (error) => {
OpenToast(error.message, 'error', 4000);
setIsLoading(false);
},
onSettled: () => setIsLoading(false)
});
const createFinalDeliveryMutation = useMutation({
mutationFn: finalCreatedeliveries,
onSuccess: () => {
OpenToast('Delivery Created Successfully', 'success', 2000);
setIsLoading(false);
if (rider?.userfcmtoken) notifyRiderMutation.mutate(rider.userfcmtoken);
navigate('/nearle/deliveries');
},
onError: (error) => {
OpenToast(error.message, 'error', 4000);
setIsLoading(false);
},
onSettled: () => setIsLoading(false)
});
const reconcileMutation = useMutation({
mutationFn: reconcileSteps,
onMutate: () => setReconcileLoading(true),
onSuccess: (data) => {
if (Array.isArray(data?.riders)) {
// Merge: applyReconcileResponse replaces orders for riders present
// in the response and leaves the rest of the cache untouched.
setDispatchPreviewData((prev) => applyReconcileResponse(prev, data));
setHasReconciled(true);
// Clear only the riders we just reconciled from the dirty set, so
// any unrelated edits made meanwhile are preserved.
setDirtyRiderIds((prev) => {
const next = new Set(prev);
data.riders.forEach((r) => next.delete(String(r.rider_id)));
return next;
});
OpenToast('Steps reconciled — preview updated', 'success', 2000);
} else {
OpenToast('Reconcile returned no rider data', 'warning', 3000);
}
},
onError: (error) => {
OpenToast(error.message || 'Reconcile failed', 'error', 4000);
},
onSettled: () => setReconcileLoading(false)
});
const handleCreateDelivery = (tune) => {
setIsLoading(true);
if (aiMode == 0) {
createDeliveryMutation.mutate({ deliveries: deliveryData });
} else if (selectedMode && selectedMode?.value == 1) {
createDeliveryMutation.mutate({
deliveries: deliveryData,
hypertuning_params: tune || null,
selectedMode,
absent_riders: absentRidersPayload
});
} else {
createDeliveryMutation.mutate({
data: {
orders: deliveryData,
riders: autoRiders,
config: { pay_type: 'hourly', base_pay: 300.0, strategy: 'multi_trip' },
absent_riders: absentRidersPayload
},
selectedMode
});
}
};
const handleFinalCreateDelivery = () => {
if (!finaldeliveryList?.length) {
OpenToast('No deliveries to assign', 'error', 3000);
return;
}
setIsLoading(true);
createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList });
};
const handleReconcile = () => {
if (!reconcileRiders.length) {
OpenToast('No riders to reconcile', 'warning', 3000);
return;
}
// Only send riders that were edited since the last AI response / reconcile.
// Their step ordering is the only thing that can be stale — untouched
// riders are skipped to keep the payload small.
const dirty = reconcileRiders.filter((r) =>
dirtyRiderIds.has(String(r.rider_id))
);
if (!dirty.length) {
OpenToast('No edits to reconcile', 'info', 2500);
return;
}
reconcileMutation.mutate({
riders: dirty.map((r) => ({
rider_id: r.rider_id,
orders: r.orders
}))
});
};
const openChangeRider = (oldRider, order) => {
const oldId =
oldRider?.rider_id ?? oldRider?.id ?? order?.rider_id ?? order?.userid ?? null;
setSelectedOldRiderId(oldId);
setSelectedOrder(order);
setSelectedNewRider(null);
setChangeDialogOpen(true);
};
const confirmChangeRider = () => {
if (!selectedNewRider || !selectedOrder) return;
// Backend expects an int — coerce at the boundary so a string from the
// riders API doesn't propagate into the Assign Orders payload.
const newRiderId = Number(selectedNewRider.userid);
const newRiderName =
selectedNewRider.label ||
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
`Rider ${newRiderId}`;
setDispatchPreviewData((prev) =>
moveOrderInPreviewData(prev, {
orderId: selectedOrder.orderid,
oldRiderId: selectedOldRiderId,
newRiderId,
newRiderName
})
);
// Both riders' step sequences are now potentially stale: the old rider
// lost a stop, the new rider gained one. Mark both as dirty so the next
// Reconcile sends exactly these two.
setDirtyRiderIds((prev) => {
const next = new Set(prev);
if (selectedOldRiderId != null) next.add(String(selectedOldRiderId));
if (newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId));
return next;
});
setHasReconciled(false);
setChangeDialogOpen(false);
OpenToast('Rider changed — click Reconcile to verify steps', 'info', 2500);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', position: 'relative' }}>
<Backdrop
sx={{ position: 'absolute', color: '#fff', zIndex: (theme) => theme.zIndex.modal + 1 }}
open={isLoading}
>
<CircularLoader color="inherit" />
</Backdrop>
<Box sx={{ py: 1.25, px: 2, borderBottom: '1px solid #eef2f6' }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Stack direction="row" alignItems="center" spacing={1}>
<Tooltip title="Back to orders" placement="top">
<IconButton
onClick={() => navigate('/nearle/orders')}
sx={{ bgcolor: 'action.hover', '&:hover': { bgcolor: 'action.selected' } }}
>
<HiOutlineArrowLeft size={20} />
</IconButton>
</Tooltip>
<Typography variant="h3" fontWeight={600}>
Assign Orders
</Typography>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<Autocomplete
options={tuningTypes || []}
getOptionLabel={(option) => option.type}
sx={{ minWidth: 250, maxWidth: 600, flex: 1 }}
renderInput={(params) => <TextField {...params} label="Hyper Tuning" />}
onChange={(e, val, reason) => {
if (reason === 'clear') handleCreateDelivery(null);
else handleCreateDelivery(val.value);
}}
/>
<Button
variant="contained"
color="primary"
startIcon={<IoReload />}
onClick={() => {
setIsLoading(true);
handleCreateDelivery('reshuffle');
}}
>
Re-Assign
</Button>
<CSVExport
data={csvExportData}
filename={`Orders_Detail_${dayjs().format('YYYY-MM-DD_HHmmss')}.csv`}
label=" CSV"
style={{ m: 1 }}
/>
</Stack>
</Stack>
</Box>
<Box sx={{ px: 2, borderBottom: '1px solid #eef2f6' }}>
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ minHeight: 40 }}>
<Tab label="Dispatch" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} />
<Tab label="Reconcile" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} />
</Tabs>
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{tabValue === 0 && dispatchPreviewData && (
<Dispatch
// The key forces a full re-mount when the cache reference changes
// (after Change Rider / Reconcile / Re-Assign) so Dispatch's
// internal state (focused rider, view mode, etc.) recomputes
// against the new orders. Without this, internal memos can stick
// to the previous data shape.
key={dispatchPreviewData?.__cacheKey || JSON.stringify(reconcileRiders.length)}
data={dispatchPreviewData}
embedded
onChangeRider={(order, focusedRider) => openChangeRider(focusedRider, order)}
/>
)}
{tabValue === 1 && (
<Box sx={{ flex: 1, overflow: 'auto', p: 2, bgcolor: '#f8fafc' }}>
{reconcileRiders.length === 0 ? (
<Typography sx={{ color: '#94a3b8', textAlign: 'center', mt: 4 }}>
No rider data available to reconcile.
</Typography>
) : (
<Stack spacing={1.75}>
<Box
sx={{
bgcolor: hasReconciled ? '#ecfdf5' : '#fffbeb',
border: `1px solid ${hasReconciled ? '#a7f3d0' : '#fde68a'}`,
color: hasReconciled ? '#065f46' : '#92400e',
borderRadius: '10px',
px: 1.5,
py: 1,
fontSize: 13
}}
>
{hasReconciled
? 'Steps have been reconciled. The Dispatch tab and Assign payload are updated.'
: 'Click a numbered step to change its rider. Hit Reconcile to verify the corrected steps with the server.'}
</Box>
{reconcileRiders.map((r) => {
const totalKms = r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
return (
<Card key={r.rider_id} sx={{ p: 2, borderRadius: '12px', boxShadow: '0 1px 3px rgba(15,23,42,0.06)' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.25 }}>
<Stack direction="row" alignItems="center" gap={1.25}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: '8px',
bgcolor: '#eef2ff',
color: '#4f46e5',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<MdTwoWheeler size={18} />
</Box>
<Box>
<Typography sx={{ fontWeight: 700, fontSize: 14, color: '#1e293b' }}>
{r.rider_name}
</Typography>
<Typography sx={{ fontSize: 11.5, color: '#64748b' }}>
ID: {r.rider_id}
</Typography>
</Box>
</Stack>
<Stack direction="row" gap={1}>
<Chip size="small" label={`${r.orders.length} stops`} sx={{ fontWeight: 600 }} />
<Chip size="small" label={`${totalKms.toFixed(1)} km`} variant="outlined" />
</Stack>
</Stack>
<Stack direction="row" gap={1.25} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
{r.orders.map((o, idx) => {
const stepNum = o.step ?? idx + 1;
const color = stepColor(Number(stepNum) - 1);
return (
<Tooltip
key={`${o.orderid}-${idx}`}
title={
<Box>
<div>Order #{o.orderid}</div>
<div>{o.deliveryaddress || o.deliverysuburb || ''}</div>
<div style={{ marginTop: 4, opacity: 0.8 }}>Click to change rider</div>
</Box>
}
>
<Box
onClick={() => openChangeRider(r, o)}
sx={{
width: 36,
height: 36,
borderRadius: '50%',
bgcolor: color,
color: '#fff',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 800,
fontSize: 14,
cursor: 'pointer',
boxShadow:
'0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)',
transition: 'transform 0.15s',
'&:hover': { transform: 'scale(1.08)' }
}}
>
{stepNum}
</Box>
</Tooltip>
);
})}
</Stack>
</Card>
);
})}
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 2 }}>
<Button
variant="contained"
color="primary"
size="large"
startIcon={<MdSwapHoriz />}
onClick={handleReconcile}
disabled={reconcileLoading || dirtyRiderIds.size === 0}
sx={{ minWidth: 220, borderRadius: '10px', textTransform: 'none', fontWeight: 700 }}
>
{reconcileLoading
? 'Reconciling...'
: dirtyRiderIds.size === 0
? 'Reconcile'
: `Reconcile (${dirtyRiderIds.size})`}
</Button>
</Box>
</Stack>
)}
</Box>
)}
</Box>
<Box sx={{ px: 2, py: 1.25, borderTop: '1px solid #eef2f6' }}>
<Stack direction="row" gap={2} alignItems="center" justifyContent="end">
<Button
variant="contained"
color="secondary"
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
>
Back
</Button>
<Button variant="contained" onClick={handleFinalCreateDelivery}>
Assign Orders
</Button>
</Stack>
</Box>
<Dialog open={changeDialogOpen} onClose={() => setChangeDialogOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>Change Rider</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 2, fontSize: 13, color: 'text.secondary' }}>
Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to:
</Typography>
<Autocomplete
options={ridersList || []}
getOptionLabel={(o) =>
o?.label || `${o?.firstname || ''} ${o?.lastname || ''}`.trim() || ''
}
value={selectedNewRider}
onChange={(e, val) => setSelectedNewRider(val)}
renderInput={(params) => <TextField {...params} label="New rider" placeholder="Pick a rider" />}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setChangeDialogOpen(false)}>Cancel</Button>
<Button variant="contained" disabled={!selectedNewRider} onClick={confirmChangeRider}>
Change Rider
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// Mirrors the orders.js deliveryamt recalc — applied at render-time so the
// Assign payload always reflects the current cache without a useEffect.
function computeDeliveryAmounts(list) {
return list.map((item) => {
const cumulativeKms = Number(item.cumulativekms || 0);
const minKm = Number(item.minkm || 0);
const basePrice = Number(item.baseprice || 0);
const pricePerKm = Number(item.priceperkm || 0);
if (cumulativeKms <= minKm) return { ...item, deliveryamt: basePrice };
return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice };
});
}
export default Preview;

View File

@@ -0,0 +1,87 @@
// Shared constants and pure helpers for the Dispatch page and its
// extracted sub-components (CompareDataPanel, etc.). Lives outside
// Dispatch.js so we don't create a circular import between the host
// component and the child views.
// Status palette — single source of truth for the status pill colors
// rendered on rider cards, order rows, step lists, and tooltips.
export const STATUS_STYLES = {
created: { label: 'Created', bg: '#3b82f6', fg: '#fff' },
pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' },
accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' },
arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' },
picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' },
active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' },
delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' },
skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' },
cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' }
};
export const getStatusStyle = (status) =>
STATUS_STYLES[String(status || '').toLowerCase()] || {
label: status || 'Unknown',
bg: '#64748b',
fg: '#fff'
};
// Order-status sets used for completion / skipped decisions across the
// rider list, the planned-route renderer, and the compare data panel.
export const FINAL_STATUSES = new Set(['delivered']);
export const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
// An order is "active" (currently in progress) when it's neither completed
// (delivered) nor skipped/cancelled. The Active view uses this to collapse a
// rider down to the single delivery they're working on right now.
export const isActiveDelivery = (o) => {
const s = String(o?.orderstatus || '').toLowerCase();
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
};
// A rider's single in-progress delivery: the first non-final, non-skipped
// stop in (trip, step) order. Returns null when the rider has nothing active
// (everything delivered/cancelled, or GPS-only with no orders).
export const getActiveOrder = (orders) => {
if (!Array.isArray(orders) || !orders.length) return null;
const sorted = [...orders].sort((a, b) => {
const tA = a.trip_number || 1;
const tB = b.trip_number || 1;
if (tA !== tB) return tA - tB;
return (a.step || 0) - (b.step || 0);
});
return sorted.find(isActiveDelivery) || null;
};
// Per-step palette — wider and more deliberately spaced than the rider
// palette so a 10-stop day reads as 10 distinct colors on the compare
// map's polylines + pins.
export const STEP_PALETTE = [
'#2563eb', // blue-600
'#dc2626', // red-600
'#16a34a', // green-600
'#ea580c', // orange-600
'#9333ea', // purple-600
'#0891b2', // cyan-600
'#ca8a04', // yellow-600
'#db2777', // pink-600
'#0f766e', // teal-700
'#7c3aed', // violet-600
'#65a30d', // lime-600
'#0284c7', // sky-600
'#b91c1c', // red-700
'#15803d', // green-700
'#a16207', // yellow-700
'#86198f' // fuchsia-800
];
export const stepColor = (i) =>
STEP_PALETTE[((i % STEP_PALETTE.length) + STEP_PALETTE.length) % STEP_PALETTE.length];
// Pure helper — converts 1, 2, 3, 21 → "1st", "2nd", "3rd", "21st". Used
// by the compare data panel for the route-sequence diff list ("Visited
// 4th · planned 2nd").
export const ordinal = (n) => {
if (n == null) return '';
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
};

File diff suppressed because it is too large Load Diff

View File

@@ -108,7 +108,7 @@ const InvoicePreview = () => {
<IconButton
color="primary"
onClick={() => {
navigate('/invoice');
navigate('/nearle/invoice');
}}
>
<FaArrowLeft size={'large'} />

View File

@@ -1,43 +1,55 @@
import React, { useState, useEffect, Fragment, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Drawer,
IconButton,
Toolbar,
Typography,
AppBar,
useMediaQuery,
Divider,
List,
ListItem,
ListItemText,
useTheme,
ListItemAvatar,
Avatar,
Tooltip,
TableCell,
Chip,
Stack,
TableRow,
TableBody,
TableHead,
Table,
TableContainer,
Tabs,
Tab,
CircularProgress
CircularProgress,
InputBase,
Paper,
Avatar,
ButtonBase
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import SearchBar from 'components/nearle_components/SearchBar';
import {
MdMenu,
MdSearch,
MdClear,
MdPlace,
MdStorefront,
MdMyLocation,
MdAccessTime,
MdLocalShipping,
MdHourglassEmpty,
MdCheckCircle,
MdCancel,
MdReceiptLong
} from 'react-icons/md';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { fetchOrders1, gettenantlocations } from '../api/api';
import Loader from 'components/Loader';
import CircularLoader from 'components/nearle_components/CircularLoader';
import { Empty, Skeleton } from 'antd';
import MainCard from 'components/MainCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import { CancelOutlined, CheckCircleOutline } from '@mui/icons-material';
import {
DT,
BRAND,
BRAND_LIGHT,
tint,
soft,
ring,
edge,
StatusBadge,
AccentAvatar
} from '../_shared/ordersDesign';
import axios from 'axios';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
@@ -45,6 +57,15 @@ dayjs.extend(utc);
const drawerWidth = 300;
// Status filter tabs — colors aligned with STATUS_META in the shared design system
// (blue=created, amber=pending, green=delivered, red=cancelled).
const STATUS_TABS = [
{ label: 'Created', value: 'created', color: '#3b82f6', icon: MdLocalShipping },
{ label: 'Pending', value: 'pending', color: '#f59e0b', icon: MdHourglassEmpty },
{ label: 'Delivered', value: 'delivered', color: '#10b981', icon: MdCheckCircle },
{ label: 'Cancelled', value: 'cancelled', color: '#ef4444', icon: MdCancel }
];
const ResponsiveLocationDrawer = () => {
const loadMoreRef = useRef();
const containerRef = useRef();
@@ -71,6 +92,14 @@ const ResponsiveLocationDrawer = () => {
const [searchword, setSearchword] = useState('');
const [debouncedSearchword, setDebouncedSearchword] = useState('');
// Per-status counts keyed by tab value, so the filter pills can show a badge.
const statusCounts = {
created: createdLenght,
pending: pendingLenght,
delivered: deliveredlenght,
cancelled: cancelledLenght
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchLocation(searchLocation);
@@ -87,38 +116,11 @@ const ResponsiveLocationDrawer = () => {
return () => clearTimeout(handler);
}, [searchword]);
const statusMap = [
{
label: 'Created',
value: 'created',
count: createdLenght,
icon: <AccessTimeIcon color="primary" fontSize="small" />
},
{
label: 'Pending',
value: 'pending',
count: pendingLenght,
icon: <LocalShippingOutlinedIcon color="primary" fontSize="small" />
},
{
label: 'Delivered',
value: 'delivered',
count: deliveredlenght,
icon: <CheckCircleOutline color="primary" fontSize="small" />
},
{
label: 'Cancelled',
value: 'cancelled',
count: cancelledLenght,
icon: <CancelOutlined color="primary" fontSize="small" />
}
];
const handleChangetab = (e, i) => {
const handleChangetab = (i) => {
setSearchword('');
setRowsPerPage(10);
setTabvalue(i);
setCurrentStatus(statusMap[i].value);
setCurrentStatus(STATUS_TABS[i].value);
setPage(0);
};
@@ -131,8 +133,15 @@ const ResponsiveLocationDrawer = () => {
queryKey: ['locations', debouncedSearchLocation],
queryFn: gettenantlocations
});
// Auto-pick a sensible default whenever the locations list changes:
// • Nothing selected yet → pick the first item.
// • Current selection has been filtered out → also pick the first item
// (otherwise the orders panel queries a locationid that's no longer
// in the visible list, returning nothing and confusing the operator).
useEffect(() => {
if (!searchLocation) locations?.length > 0 ? setSelectedLocation(locations[0]) : null;
if (!Array.isArray(locations) || locations.length === 0) return;
const stillVisible = selectedLocation && locations.some((l) => l.locationid === selectedLocation.locationid);
if (!stillVisible) setSelectedLocation(locations[0]);
}, [locations]);
const {
@@ -168,7 +177,7 @@ const ResponsiveLocationDrawer = () => {
}
},
{
root: document.querySelector('.MuiTableContainer-root'), // 👈 or explicitly TableContainer
root: document.querySelector('.MuiTableContainer-root'),
rootMargin: '0px',
threshold: 1.0
}
@@ -231,6 +240,139 @@ const ResponsiveLocationDrawer = () => {
errMessage && console.log(errMessage);
}, [errMessage]);
// Brand-styled scrollbar reused on the sidebar + table.
const scrollbarSx = {
'&::-webkit-scrollbar': { width: 8, height: 8 },
'&::-webkit-scrollbar-thumb': {
backgroundColor: edge(BRAND),
borderRadius: 8,
'&:hover': { backgroundColor: BRAND }
},
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
};
// --------------------------------------------------------------------------
// Sidebar — searchable location list. Shared between the desktop persistent
// drawer and the mobile temporary drawer.
// --------------------------------------------------------------------------
const sidebarContent = (
<Stack sx={{ height: '100%', bgcolor: '#fff' }}>
{/* Sidebar header */}
<Box sx={{ px: 1.5, pt: 1.5, pb: 1 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.25 }}>
<Avatar
variant="rounded"
sx={{ width: 30, height: 30, bgcolor: BRAND, color: '#fff', borderRadius: 1.5, boxShadow: `0 4px 12px ${ring(BRAND)}` }}
>
<MdStorefront size={16} />
</Avatar>
<Stack spacing={0}>
<Typography sx={{ fontSize: 13.5, fontWeight: 800, color: DT.textPrimary, lineHeight: 1.1 }}>Locations</Typography>
<Typography sx={{ fontSize: 10.5, fontWeight: 600, color: DT.textMuted }}>
{Array.isArray(locations) ? `${locations.length} active` : '—'}
</Typography>
</Stack>
</Stack>
{/* Search pill */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.75,
borderRadius: 999,
bgcolor: tint(BRAND),
border: `1.5px solid ${edge(BRAND)}`,
transition: 'all 0.18s',
'&:focus-within': { borderColor: BRAND, boxShadow: `0 0 0 3px ${ring(BRAND)}` }
}}
>
<MdSearch size={16} style={{ color: BRAND, flexShrink: 0 }} />
<InputBase
placeholder="Search location"
value={searchLocation}
onChange={(e) => setSearchLocation(e.target.value)}
autoComplete="off"
sx={{
flex: 1,
fontSize: 13,
fontWeight: 600,
color: DT.textPrimary,
'& input::placeholder': { color: DT.textMuted, opacity: 1 }
}}
/>
{searchLocation && (
<IconButton size="small" onClick={() => setSearchLocation('')} sx={{ p: 0.25, color: BRAND }}>
<MdClear size={14} />
</IconButton>
)}
</Box>
</Box>
{/* Location list */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 1, pb: 1, ...scrollbarSx }}>
{locationIsLoading &&
Array.from({ length: 8 }).map((_, i) => (
<Box key={i} sx={{ px: 1, py: 1 }}>
<Skeleton avatar active paragraph={{ rows: 1 }} title={false} />
</Box>
))}
{!locationIsLoading && Array.isArray(locations) && locations.length === 0 && (
<Box sx={{ py: 5 }}>
<Empty description="No locations" />
</Box>
)}
<Stack spacing={0.5}>
{locations?.map((row, index) => {
const isSelected = row.locationid === selectedLocation?.locationid;
return (
<ButtonBase
key={index}
onClick={() => setSelectedLocation(row)}
sx={{
width: '100%',
justifyContent: 'flex-start',
textAlign: 'left',
gap: 1,
px: 1,
py: 0.875,
borderRadius: 2,
position: 'relative',
transition: 'background-color 0.14s, box-shadow 0.14s',
bgcolor: isSelected ? tint(BRAND) : 'transparent',
boxShadow: isSelected ? `inset 3px 0 0 ${BRAND}` : 'none',
'&:hover': { bgcolor: isSelected ? tint(BRAND) : DT.surfaceAlt }
}}
>
<AccentAvatar color={BRAND} selected={isSelected} size={36}>
{row.locationname?.[0]?.toUpperCase() || '?'}
</AccentAvatar>
<Stack spacing={0} sx={{ minWidth: 0, flex: 1 }}>
<Typography
sx={{ fontSize: 13, fontWeight: 700, color: isSelected ? BRAND : DT.textPrimary, lineHeight: 1.2 }}
noWrap
>
{row.locationname}
</Typography>
<Stack direction="row" alignItems="center" spacing={0.375}>
<MdPlace size={11} style={{ color: DT.textMuted, flexShrink: 0 }} />
<Typography sx={{ fontSize: 11, fontWeight: 600, color: DT.textSecondary }} noWrap>
{row.suburb || '—'}
</Typography>
</Stack>
</Stack>
</ButtonBase>
);
})}
</Stack>
</Box>
</Stack>
);
return (
<React.Fragment>
{locationIsLoading && (
@@ -239,9 +381,8 @@ const ResponsiveLocationDrawer = () => {
</>
)}
<Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative' }}>
{/* ---------------- LOCAL DRAWER ---------------- */}
<Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative', bgcolor: DT.surfaceAlt }}>
{/* ---------------- LOCATION SIDEBAR ---------------- */}
<Drawer
variant={isDesktop ? 'persistent' : 'temporary'}
open={open}
@@ -255,246 +396,252 @@ const ResponsiveLocationDrawer = () => {
left: 0,
top: 0,
height: '100%',
overflowY: 'auto',
overflow: 'hidden',
borderRight: `1px solid ${DT.borderSubtle}`,
transition: 'transform 0.35s ease-in-out',
zIndex: 10,
/* vertical scrollbar */
'&::-webkit-scrollbar:vertical': {
width: '7px',
opacity: 0,
transition: 'opacity 0.3s'
},
/* horizontal scrollbar */
'&::-webkit-scrollbar:horizontal': {
height: '6px', // thinner horizontal bar
opacity: 0,
transition: 'opacity 0.3s'
},
/* show scrollbar when hovering drawer */
'&:hover::-webkit-scrollbar': {
opacity: 1
},
/* thumb styling */
'&::-webkit-scrollbar-thumb': {
backgroundColor: theme.palette.primary.main,
borderRadius: '8px'
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: theme.palette.primary.dark
},
/* track styling */
'&::-webkit-scrollbar-track': {
backgroundColor: theme.palette.primary.lighter
}
zIndex: 10
}
}}
>
<Box sx={{ position: 'sticky', top: 0, zIndex: 11, border: 'none' }}>
<SearchBar
value={searchLocation}
placeholder="Search Location"
onChange={(e) => setSearchLocation(e.target.value)}
sx={{
width: 'auto',
height: 60,
bgcolor: 'white',
'& .MuiOutlinedInput-notchedOutline': {
border: 'none',
borderBottom: '1px solid',
borderColor: theme.palette.secondary.light
}
}}
/>
</Box>
<List sx={{ border: 'none', mt: -1 }}>
{locations?.map((row, index) => (
<React.Fragment key={index}>
<ListItem
sx={{
cursor: 'pointer',
bgcolor: row.locationid == selectedLocation?.locationid ? theme.palette.secondary[200] : 'none',
'&:hover': {
bgcolor: theme.palette.secondary.lighter
}
}}
onClick={() => {
setSelectedLocation(row);
}}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: 'primary.main', // background color
color: 'white' // text color
}}
>
{row.locationname[0].toUpperCase()}
</Avatar>{' '}
</ListItemAvatar>
<ListItemText primary={row.locationname} secondary={row.suburb} />
</ListItem>
<Divider />
</React.Fragment>
))}
</List>
{sidebarContent}
</Drawer>
{/* -------------- LOCAL PAGE APPBAR -------------- */}
<AppBar
elevation={0}
position="absolute"
sx={{
top: 0,
left: open && isDesktop ? `${drawerWidth}px` : 0,
width: open && isDesktop ? `calc(100% - ${drawerWidth}px)` : '100%',
transition: 'left 0.3s ease, width 0.3s ease',
zIndex: 1100, // BELOW drawer, ABOVE content
backgroundColor: 'white',
borderBottom: '1px solid',
borderColor: theme.palette.secondary.light
}}
>
<Toolbar>
<Stack
sx={{ width: '100%', borderBottom: '1px soild red' }}
display={'flex'}
flexDirection={'row'}
alignItems={'center'}
justifyContent={'space-between'}
flexWrap={'wrap'}
>
<Stack display={'flex'} flexDirection={'row'} alignItems={'center'}>
<IconButton color="primary" onClick={toggleDrawer} sx={{ mr: 1 }}>
<MenuIcon />
</IconButton>
<Typography variant="h5" color={'primary'} sx={{ whiteSpace: 'nowrap', ml: 2 }}>
{selectedLocation?.locationname}
</Typography>
</Stack>
<Stack>
<SearchBar
value={searchword}
placeholder={'Search Order Details'}
onChange={(e) => setSearchword(e.target.value)}
sx={{
width: 'auto',
height: 40,
bgcolor: 'white',
maxWidth: 800,
borderRadius: 1
// '& .MuiOutlinedInput-notchedOutline': {
// border: 'none'
// }
}}
/>
</Stack>
</Stack>
</Toolbar>
</AppBar>
{/* ---------------- PAGE SCROLLABLE CONTENT ---------------- */}
{/* ---------------- MAIN PANEL ---------------- */}
<Box
sx={{
flexGrow: 1,
overflow: 'auto',
pt: '64px', // Height of AppBar
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
pl: isDesktop && open ? `${drawerWidth}px` : 0,
transition: 'padding-left 0.3s ease',
mt: -1
transition: 'padding-left 0.3s ease'
}}
>
<Stack
display={'flex'}
flexDirection={'row'}
justifyContent={'space-between'}
alignItems={'center'}
flexWrap={'wrap-reverse'}
gap={2}
{/* ---------------- GRADIENT HEADER ---------------- */}
<Paper
elevation={0}
sx={{
border: '1px solid ',
borderBottom: 0,
borderColor: 'bg.main',
p: 1.5
flexShrink: 0,
px: { xs: 1.25, sm: 1.75 },
py: { xs: 1, sm: 1.25 },
borderRadius: 0,
borderBottom: `1px solid ${DT.borderSubtle}`,
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`
}}
>
{/* Tabs Wrapper */}
<Stack
direction={{ xs: 'column', md: 'row' }}
alignItems={{ xs: 'stretch', md: 'center' }}
justifyContent="space-between"
spacing={{ xs: 1, md: 1.5 }}
>
<Stack direction="row" alignItems="center" spacing={1.25}>
<Tooltip title={open ? 'Hide locations' : 'Show locations'} arrow>
<IconButton
onClick={toggleDrawer}
sx={{
width: 34,
height: 34,
borderRadius: 1.5,
bgcolor: '#fff',
border: `1px solid ${DT.borderSubtle}`,
color: BRAND,
'&:hover': { bgcolor: tint(BRAND), borderColor: BRAND }
}}
>
<MdMenu size={18} />
</IconButton>
</Tooltip>
<Tabs value={tabvalue} onChange={handleChangetab} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile>
{statusMap.map((item, index) => (
<Tab
key={index}
label={
<Stack direction="row" alignItems="center" spacing={1}>
{item.icon}
<span>{item.label}</span>
<Chip label={item.count} color="primary" variant="light" size="small" />
</Stack>
}
<Avatar
variant="rounded"
sx={{ width: 36, height: 36, bgcolor: BRAND, color: '#fff', borderRadius: 1.5, boxShadow: `0 4px 12px ${ring(BRAND)}` }}
>
<MdMyLocation size={19} />
</Avatar>
<Stack spacing={0.125}>
<Typography
variant="h3"
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.05rem', sm: '1.2rem', md: '1.3rem' }
}}
noWrap
>
{selectedLocation?.locationname || 'Select a location'}
</Typography>
<Stack direction="row" alignItems="center" spacing={0.75}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: '#10b981', boxShadow: '0 0 0 3px rgba(16,185,129,0.18)' }} />
<Typography sx={{ fontSize: 11.5, color: DT.textSecondary, fontWeight: 600 }} noWrap>
{selectedLocation?.suburb ? `${selectedLocation.suburb} · ` : ''}Live · {dayjs(startdate).format('DD MMM YYYY')}
</Typography>
</Stack>
</Stack>
</Stack>
{/* Order search pill */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.75,
borderRadius: 999,
bgcolor: '#fff',
border: `1.5px solid ${edge(BRAND)}`,
minWidth: { xs: '100%', md: 280 },
maxWidth: { md: 360 },
transition: 'all 0.18s',
'&:focus-within': { borderColor: BRAND, boxShadow: `0 0 0 3px ${ring(BRAND)}` }
}}
>
<MdSearch size={16} style={{ color: BRAND, flexShrink: 0 }} />
<InputBase
placeholder="Search order details"
value={searchword}
onChange={(e) => setSearchword(e.target.value)}
autoComplete="off"
sx={{
flex: 1,
fontSize: 13,
fontWeight: 600,
color: DT.textPrimary,
'& input::placeholder': { color: DT.textMuted, opacity: 1 }
}}
/>
))}
</Tabs>
</Stack>
<MainCard
content={false}
{searchword && (
<IconButton size="small" onClick={() => setSearchword('')} sx={{ p: 0.25, color: BRAND }}>
<MdClear size={14} />
</IconButton>
)}
</Box>
</Stack>
</Paper>
{/* ---------------- STATUS FILTER PILLS ---------------- */}
<Box
sx={{
overflow: 'hidden',
height: 'calc(100vh - 200px)', // adjust as needed
flexShrink: 0,
px: { xs: 1, sm: 1.5 },
py: 1,
bgcolor: '#fff',
borderBottom: `1px solid ${DT.borderSubtle}`,
display: 'flex',
flexDirection: 'column'
gap: 0.75,
overflowX: 'auto',
...scrollbarSx
}}
>
<Fragment>
{/* Scrollable table container */}
{STATUS_TABS.map((item, index) => {
const isActive = tabvalue === index;
const Icon = item.icon;
const count = statusCounts[item.value];
return (
<ButtonBase
key={index}
onClick={() => handleChangetab(index)}
sx={{
flexShrink: 0,
gap: 0.75,
px: 1.25,
py: 0.625,
borderRadius: 999,
fontWeight: 700,
transition: 'all 0.15s',
border: `1.5px solid ${isActive ? item.color : DT.borderSubtle}`,
bgcolor: isActive ? item.color : '#fff',
color: isActive ? '#fff' : DT.textSecondary,
boxShadow: isActive ? `0 4px 12px ${ring(item.color)}` : 'none',
'&:hover': {
borderColor: item.color,
color: isActive ? '#fff' : item.color,
bgcolor: isActive ? item.color : tint(item.color)
}
}}
>
<Icon size={14} />
<Typography sx={{ fontSize: 12.5, fontWeight: 700, lineHeight: 1 }}>{item.label}</Typography>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 20,
height: 18,
px: 0.625,
borderRadius: 999,
fontSize: 10.5,
fontWeight: 800,
bgcolor: isActive ? 'rgba(255,255,255,0.25)' : soft(item.color),
color: isActive ? '#fff' : item.color
}}
>
{count ?? 0}
</Box>
</ButtonBase>
);
})}
</Box>
{/* ---------------- ORDERS TABLE ---------------- */}
<Box sx={{ flex: 1, overflow: 'hidden', p: { xs: 1, sm: 1.5 } }}>
<Paper
elevation={0}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRadius: 2,
border: `1px solid ${DT.borderSubtle}`,
overflow: 'hidden',
background: '#fff',
boxShadow: DT.shadowSoft
}}
>
<TableContainer
onScroll={handleScroll}
ref={containerRef}
sx={{
width: '100%',
flex: 1,
overflow: 'auto',
borderBottom: 1,
maxHeight: 'calc(100vh - 225px)',
borderColor: 'divider',
'&::-webkit-scrollbar': { width: '12px' },
'&::-webkit-scrollbar-thumb': {
backgroundColor: theme.palette.primary.main,
borderRadius: '8px'
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: theme.palette.primary.dark
},
'&::-webkit-scrollbar-track': {
backgroundColor: theme.palette.primary.lighter
}
}}
sx={{ flex: 1, overflow: 'auto', ...scrollbarSx }}
>
<Table stickyHeader>
{/* HEADER */}
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>S.No</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Orders</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Pickup</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Drop</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Notes</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Status</TableCell>
<TableRow
sx={{
'& th': {
backgroundColor: DT.surfaceAlt,
color: DT.textSecondary,
fontSize: 10.5,
fontWeight: 800,
letterSpacing: 0.5,
textTransform: 'uppercase',
whiteSpace: 'nowrap',
borderBottom: `1px solid ${DT.borderSubtle}`,
py: 0.75,
px: 1
}
}}
>
<TableCell sx={{ width: 36 }}>#</TableCell>
<TableCell sx={{ minWidth: 150 }}>Order</TableCell>
<TableCell sx={{ minWidth: 150 }}>Pickup</TableCell>
<TableCell sx={{ minWidth: 150 }}>Drop</TableCell>
<TableCell sx={{ minWidth: 140 }}>Notes</TableCell>
<TableCell sx={{ width: 120 }}>Status</TableCell>
</TableRow>
</TableHead>
{/* BODY */}
<TableBody>
{/* LOADING STATE */}
{loading &&
[...Array(10)].map((_, index) => (
Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index}>
{[...Array(6)].map((__, i) => (
<TableCell key={i}>
<Skeleton animation="wave" />
{Array.from({ length: 6 }).map((__, i) => (
<TableCell key={i} sx={{ borderBottom: `1px solid ${DT.divider}`, py: 0.625, px: 1 }}>
<Skeleton.Input active size="small" style={{ width: '100%', height: 18 }} />
</TableCell>
))}
</TableRow>
@@ -503,8 +650,16 @@ const ResponsiveLocationDrawer = () => {
{/* EMPTY STATE */}
{!loading && rows?.length === 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ minWidth: '100%', height: 500 }} align="center">
<Empty description={'No Orders'} />
<TableCell colSpan={6} sx={{ py: 7, borderBottom: 'none' }}>
<Stack alignItems="center" spacing={1.25}>
<Avatar variant="rounded" sx={{ width: 56, height: 56, bgcolor: soft('#94a3b8'), color: DT.textMuted, borderRadius: 2 }}>
<MdReceiptLong size={26} />
</Avatar>
<Typography sx={{ fontWeight: 700, color: DT.textPrimary, fontSize: 14 }}>No orders found</Typography>
<Typography sx={{ color: DT.textSecondary, fontSize: 12 }}>
{searchword ? 'Try a different keyword or clear the search.' : 'No orders in this status for the selected location.'}
</Typography>
</Stack>
</TableCell>
</TableRow>
)}
@@ -512,83 +667,101 @@ const ResponsiveLocationDrawer = () => {
{/* DATA ROWS */}
{!loading &&
rows?.map((row, index) => (
<TableRow key={index} sx={{ cursor: 'pointer' }}>
<TableCell>{page * rowsPerPage + index + 1}</TableCell>
<TableRow
key={index}
sx={{
cursor: 'pointer',
transition: 'background-color 0.12s, box-shadow 0.12s',
'& td': { borderBottom: `1px solid ${DT.divider}`, py: 0.75, px: 1, verticalAlign: 'top' },
'&:hover': { backgroundColor: tint(BRAND), boxShadow: `inset 3px 0 0 ${BRAND}` }
}}
>
<TableCell>
<Typography sx={{ fontWeight: 700, fontSize: 12, color: DT.textMuted }}>{page * rowsPerPage + index + 1}</Typography>
</TableCell>
{/* Order Info */}
<TableCell>
<Typography variant="body2" noWrap>
<Typography sx={{ fontSize: 12.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.25 }} noWrap>
{row.orderid}
</Typography>
<Typography variant="caption" noWrap>
{dayjs(row.deliverydate).utc().format('DD/MM/YYYY')}
</Typography>
<Typography variant="caption" noWrap>
{dayjs(row.deliverydate).utc().format('hh:mm A')}
</Typography>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.125 }}>
<MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} />
<Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap>
{dayjs(row.deliverydate).utc().format('hh:mm A')}
</Typography>
<Typography sx={{ fontSize: 10.5, color: DT.textMuted, fontWeight: 600 }} noWrap>
· {dayjs(row.deliverydate).utc().format('DD MMM YY')}
</Typography>
</Stack>
</TableCell>
{/* Pickup */}
<TableCell>
<Stack direction="row" spacing={1}>
<Avatar sx={{ width: 25, height: 25 }} />
<Stack>
<Typography variant="caption">{row.pickupcustomer}</Typography>
<Typography variant="caption">{row.pickupcontactno}</Typography>
<Tooltip title={row.pickupaddress}>
<Typography variant="caption">{row.pickupsuburb || row.pickupaddress.slice(0, 20)}</Typography>
</Tooltip>
</Stack>
<Stack spacing={0.125}>
<Typography sx={{ fontSize: 12.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.25 }} noWrap>
{row.pickupcustomer || '—'}
</Typography>
<Typography sx={{ fontSize: 11, color: DT.textSecondary, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.pickupcontactno}
</Typography>
<Tooltip title={row.pickupaddress || ''}>
<Typography sx={{ fontSize: 10.5, color: DT.textMuted, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.pickupsuburb || (row.pickupaddress ? `${row.pickupaddress.slice(0, 20)}` : '—')}
</Typography>
</Tooltip>
</Stack>
</TableCell>
{/* Drop */}
<TableCell>
<Stack direction="row" spacing={1}>
<Avatar sx={{ width: 25, height: 25 }} />
<Stack>
<Typography variant="caption">{row.deliverycustomer}</Typography>
<Typography variant="caption">{row.deliverycontactno}</Typography>
<Tooltip title={row.deliveryaddress}>
<Typography variant="caption">{row.deliverysuburb || row.deliveryaddress.slice(0, 20)}</Typography>
</Tooltip>
</Stack>
<Stack spacing={0.125}>
<Typography sx={{ fontSize: 12.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.25 }} noWrap>
{row.deliverycustomer || '—'}
</Typography>
<Typography sx={{ fontSize: 11, color: DT.textSecondary, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.deliverycontactno}
</Typography>
<Tooltip title={row.deliveryaddress || ''}>
<Typography sx={{ fontSize: 10.5, color: DT.textMuted, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.deliverysuburb || (row.deliveryaddress ? `${row.deliveryaddress.slice(0, 20)}` : '—')}
</Typography>
</Tooltip>
</Stack>
</TableCell>
{/* Notes */}
<TableCell>{row.ordernotes}</TableCell>
<TableCell>
<Typography sx={{ fontSize: 11.5, color: DT.textSecondary, fontWeight: 600, lineHeight: 1.35 }}>
{row.ordernotes || '—'}
</Typography>
</TableCell>
{/* Status */}
<TableCell>
<Stack direction="row" spacing={1}>
{row.orderstatus === 'pending' && <Chip label="Pending" color="warning" size="small" />}
{row.orderstatus === 'confirmed' && <Chip label="Confirmed" color="success" size="small" />}
{row.orderstatus === 'cancelled' && <Chip label="Cancelled" color="error" size="small" />}
{row.orderstatus === 'delivered' && <Chip label="Completed" color="primary" size="small" />}
{row.orderstatus === 'processing' && <Chip label="Processing" color="primary" size="small" />}
{row.orderstatus === 'ready' && <Chip label="Accepted" color="info" size="small" />}
{row.orderstatus === 'active' && <Chip label="Picked" color="info" size="small" />}
{row.orderstatus === 'closed' && <Chip label="Closed" color="info" size="small" />}
{row.orderstatus === 'created' && <Chip label="Created" color="secondary" size="small" />}
</Stack>
<StatusBadge status={row.orderstatus} />
</TableCell>
</TableRow>
))}
{rows?.length != 0 && (
<TableRow>
<TableCell colSpan={6} rowSpan={3}>
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{isFetchingNextPage ? <CircularProgress /> : hasNextPage ? <CircularProgress /> : 'No More Orders'}
</div>
<TableCell colSpan={6} sx={{ borderBottom: 'none' }}>
<Stack ref={loadMoreRef} alignItems="center" justifyContent="center" sx={{ height: 40 }}>
{isFetchingNextPage || hasNextPage ? (
<CircularProgress size={20} sx={{ color: BRAND }} />
) : (
<Typography sx={{ fontSize: 11.5, fontWeight: 700, color: DT.textMuted }}>No more orders</Typography>
)}
</Stack>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Fragment>
</MainCard>
</Paper>
</Box>
</Box>
</Box>
</React.Fragment>

View File

@@ -42,7 +42,7 @@ const Login = () => {
useEffect(() => {
if (localStorage.getItem('authname')) {
navigate('/nearle/orders');
navigate('/nearle/dispatch');
}
}, []);
@@ -79,7 +79,7 @@ const Login = () => {
setSubmitting(false);
setLoading(false);
navigate('/nearle/orders');
navigate('/nearle/dispatch');
} else {
OpenToast(res.data.message, 'warning', 2000);
setLoading(false);

View File

@@ -0,0 +1,288 @@
/* OrdersRedesign.css — premium aesthetics + micro-animations
Ported from xpressconsole; trimmed to only the classes the
redesigned multipleOrders.js (and its peers) consume here. */
/* ---------- Page background ---------- */
.orders-workspace-bg {
background: linear-gradient(135deg, #f8fafc 0%, #edf2f7 100%) !important;
}
/* ---------- Cards ---------- */
.orders-card {
background: #ffffff !important;
border: 1px solid #eef2f6 !important;
border-radius: 12px !important;
box-shadow: 0 10px 25px -5px rgba(62, 73, 84, 0.04),
0 4px 12px -2px rgba(62, 73, 84, 0.02) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
overflow: hidden;
}
.orders-card:hover {
box-shadow: 0 16px 35px -8px rgba(62, 73, 84, 0.08),
0 6px 16px -3px rgba(62, 73, 84, 0.03) !important;
}
.orders-card .MuiOutlinedInput-root {
font-size: 13px !important;
border-radius: 10px !important;
}
.orders-card .MuiOutlinedInput-input {
font-size: 13px !important;
padding-top: 9px !important;
padding-bottom: 9px !important;
}
.orders-card .MuiInputLabel-root {
font-size: 13px !important;
}
.orders-card .MuiInputLabel-root.MuiInputLabel-shrink {
font-size: 11.5px !important;
}
/* ---------- Section title bar (gradient strip + h-title) ---------- */
.section-title-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.section-title-bar::before {
content: '';
width: 4px;
height: 22px;
border-radius: 4px;
background: linear-gradient(180deg, #1890ff, #096dd9);
flex-shrink: 0;
}
.section-title-bar--accent::before {
background: linear-gradient(180deg, #a855f7, #65387a);
}
.section-title-bar .MuiTypography-root {
margin-bottom: 0 !important;
line-height: 1.2;
}
/* ---------- Delivery preferences card ---------- */
.delivery-prefs-card {
background: linear-gradient(135deg, #ffffff 0%, #fbfcff 100%) !important;
border: 1px solid #eef2f6 !important;
border-radius: 14px !important;
}
.delivery-prefs-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.delivery-prefs-title {
font-size: 14px !important;
font-weight: 700 !important;
color: #1e293b !important;
letter-spacing: -0.01em;
line-height: 1.2;
}
.delivery-prefs-sub {
font-size: 10.5px !important;
font-weight: 500 !important;
color: #94a3b8 !important;
text-transform: uppercase;
letter-spacing: 0.55px;
text-align: right;
line-height: 1.2;
}
.delivery-prefs-row {
display: flex;
flex-direction: column;
gap: 10px;
}
.delivery-prefs-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.delivery-prefs-label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.55px;
text-transform: uppercase;
color: #64748b;
}
/* ---------- Pricing summary card ---------- */
.pricing-summary-card {
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%) !important;
border: 1px solid #eef2f6 !important;
border-radius: 14px !important;
padding: 14px 16px !important;
}
.pricing-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.pricing-title {
font-size: 14px !important;
font-weight: 700 !important;
color: #1e293b !important;
letter-spacing: -0.01em;
}
.pricing-subtitle {
font-size: 11px !important;
font-weight: 500 !important;
color: #94a3b8 !important;
text-transform: uppercase;
letter-spacing: 0.6px;
}
/* ---------- Total charge badge ---------- */
.total-charge-badge {
background: linear-gradient(135deg, rgba(24, 144, 255, 0.08) 0%, rgba(101, 56, 122, 0.10) 100%);
border: 1px solid rgba(101, 56, 122, 0.18);
border-radius: 10px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
}
.total-charge-left {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.total-charge-icon {
font-size: 13px;
color: #65387A;
flex-shrink: 0;
}
.total-charge-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: #65387A;
}
.total-charge-val {
font-size: 20px;
font-weight: 800;
color: #65387A;
line-height: 1.1;
letter-spacing: -0.01em;
white-space: nowrap;
}
/* ---------- Gradient action button ---------- */
.gradient-btn-create {
background: linear-gradient(135deg, #1890ff 0%, #65387a 100%) !important;
color: #ffffff !important;
font-weight: 600 !important;
font-size: 13px !important;
letter-spacing: 0.01em !important;
text-transform: none !important;
border-radius: 10px !important;
padding: 8px 18px !important;
min-height: 38px !important;
box-shadow: 0 4px 12px -3px rgba(24, 144, 255, 0.30),
0 2px 4px rgba(101, 56, 122, 0.10) !important;
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1) !important;
border: none !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
}
.gradient-btn-create .MuiButton-startIcon,
.gradient-btn-create .MuiButton-endIcon {
margin: 0 !important;
}
.gradient-btn-create:hover {
filter: brightness(1.04);
box-shadow: 0 8px 18px -4px rgba(24, 144, 255, 0.40),
0 3px 8px rgba(101, 56, 122, 0.18) !important;
}
.gradient-btn-create:active { filter: brightness(0.98); }
.gradient-btn-create.Mui-disabled,
.gradient-btn-create:disabled {
background: #e2e8f0 !important;
color: #94a3b8 !important;
box-shadow: none !important;
cursor: not-allowed !important;
}
/* ---------- Compact header inputs (Location/Client/Business) ---------- */
.header-compact-tf .MuiOutlinedInput-root {
border-radius: 10px !important;
height: 40px !important;
padding-left: 10px !important;
font-size: 12.5px !important;
background: #ffffff;
}
.header-compact-tf .MuiOutlinedInput-input {
padding-top: 6px !important;
padding-bottom: 6px !important;
font-size: 12.5px !important;
}
.header-compact-tf .MuiInputLabel-root {
font-size: 11.5px !important;
letter-spacing: 0.02em;
font-weight: 600;
color: #64748b !important;
}
.header-compact-tf .MuiInputLabel-shrink {
transform: translate(12px, -7px) scale(0.82) !important;
background: #ffffff;
padding: 0 4px;
}
.header-compact-tf .MuiOutlinedInput-notchedOutline { border-color: #e2e8f0; }
.header-compact-tf:hover .MuiOutlinedInput-notchedOutline { border-color: #cbd5e1; }
.header-compact-tf .Mui-focused .MuiOutlinedInput-notchedOutline { border-width: 1.5px !important; }
.header-compact-input .MuiAutocomplete-endAdornment {
top: 50%;
transform: translateY(-50%);
right: 8px;
display: inline-flex;
align-items: center;
height: auto;
gap: 2px;
}
.header-compact-input .MuiAutocomplete-endAdornment .MuiSvgIcon-root {
font-size: 16px;
display: block;
}
.header-compact-input .MuiAutocomplete-clearIndicator,
.header-compact-input .MuiAutocomplete-popupIndicator {
padding: 3px !important;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8 !important;
}
.header-compact-input .MuiAutocomplete-clearIndicator:hover,
.header-compact-input .MuiAutocomplete-popupIndicator:hover {
background: rgba(148, 163, 184, 0.12) !important;
color: #475569 !important;
}
.header-compact-input .MuiAutocomplete-popupIndicator { margin-right: 0; }
.header-compact-input .MuiOutlinedInput-root {
padding-top: 0 !important;
padding-bottom: 0 !important;
padding-right: 60px !important;
}
.header-compact-input .MuiAutocomplete-input {
padding: 4px 4px 4px 0 !important;
height: auto !important;
}

File diff suppressed because it is too large Load Diff

View File

@@ -97,7 +97,91 @@ import {
// DeleteTwoTone
} from '@ant-design/icons';
import { enqueueSnackbar } from 'notistack';
import {
MdLocalShipping,
MdHourglassEmpty,
MdCheckCircle,
MdCancel,
MdAccessTime,
MdHistoryToggleOff,
MdAssignmentTurnedIn,
MdEdit,
MdArrowBack,
MdReceiptLong
} from 'react-icons/md';
import { Paper, Box } from '@mui/material';
// ============================================================================
// Design tokens — shared with the rest of the redesigned operator pages.
// ============================================================================
const DT = {
radiusPill: 999,
radiusCard: 16,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
const dtA = (c, suffix) => `${c}${suffix}`;
const tint = (c) => dtA(c, '08');
const soft = (c) => dtA(c, '18');
const ring = (c) => dtA(c, '26');
const edge = (c) => dtA(c, '55');
const BRAND = '#662582';
const BRAND_LIGHT = '#9255AB';
// Semantic per-status palette — also drives StatusBadge.
const STATUS_META = {
pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
assigned: { label: 'Assigned', color: '#0ea5e9', icon: MdAssignmentTurnedIn },
confirmed: { label: 'Confirmed', color: '#10b981', icon: MdCheckCircle },
modified: { label: 'Modified', color: '#06b6d4', icon: MdHistoryToggleOff },
processing: { label: 'Processing', color: BRAND, icon: MdAccessTime },
active: { label: 'Active', color: '#8b5cf6', icon: MdLocalShipping },
closed: { label: 'Closed', color: '#06b6d4', icon: MdCheckCircle },
completed: { label: 'Completed', color: '#10b981', icon: MdCheckCircle },
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel }
};
const StatusBadge = ({ status, size = 'md' }) => {
if (!status) return null;
const meta = STATUS_META[String(status).toLowerCase()] || {
label: status,
color: DT.textMuted,
icon: MdHistoryToggleOff
};
const Icon = meta.icon;
const px = size === 'lg' ? 1.25 : 1;
const py = size === 'lg' ? 0.5 : 0.375;
const fs = size === 'lg' ? 12 : 11;
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px,
py,
borderRadius: 999,
bgcolor: tint(meta.color),
border: `1px solid ${edge(meta.color)}`,
color: meta.color,
fontSize: fs,
fontWeight: 800,
whiteSpace: 'nowrap'
}}
>
<Icon size={12} /> {meta.label}
</Box>
);
};
const Details = () => {
// const [searchParams] = useSearchParams();
@@ -854,59 +938,105 @@ const Details = () => {
open={open}
onClose={() => handleClose(false)}
maxWidth="xs"
PaperProps={{ sx: { borderRadius: 3 } }}
>
<DialogContent sx={{ mt: 2, my: 1 }}>
<Stack alignItems="center" spacing={3.5}>
<Avatar color="error" sx={{ width: 72, height: 72, fontSize: '1.75rem' }}>
<Box
sx={{
p: 2.5,
background: `linear-gradient(135deg, ${tint('#ef4444')} 0%, ${tint('#f59e0b')} 100%)`,
borderBottom: `1px solid ${DT.borderSubtle}`
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar sx={{ bgcolor: '#ef4444', color: '#fff', width: 40, height: 40 }}>
<DeleteFilled />
</Avatar>
<Grid >
<Chip label={orderid.slice(4)} variant="combined" color='warning' size='small' />
</Grid>
<Stack spacing={2}>
{/* <Typography variant="h4" align="center">
Are you sure you want to cancel this order?
</Typography> */}
{(invoiceeligible) &&
<Alert color="warning" variant="border" icon={<WarningFilled />}>
<AlertTitle>Order is within 24Hrs time frame. The order will be invoiced with standard pricing as agreed.</AlertTitle>
{/* <Typography variant="h6"> This is an warning alert.</Typography> */}
<Link href='https://thelegendarystaff.com/' target='_blank' >Terms & Condition link</Link>
</Alert>
}
<Typography variant="h4" align="center">
Please type in the order number to confirm.
<Stack>
<Typography variant="h5" sx={{ fontWeight: 800, color: DT.textPrimary }}>
Cancel Order
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Confirm to permanently cancel this order
</Typography>
<TextField
type='text'
onChange={(e) => {
console.log(e.target.value)
setDeletepassword(e.target.value)
}}
error={deletepassword !== orderid.slice(4)}
// error={true}
value={deletepassword}
/>
</Stack>
</Stack>
</Box>
<DialogContent sx={{ pt: 3 }}>
<Stack alignItems="center" spacing={2.5}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: tint('#f59e0b'),
border: `1px solid ${edge('#f59e0b')}`,
color: '#f59e0b',
fontSize: 12,
fontWeight: 800
}}
>
<MdReceiptLong size={12} /> {orderid.slice(4)}
</Box>
<Stack direction="row" spacing={2} sx={{ width: 1 }}>
<Button fullWidth color="error" variant="contained" onClick={() => {
if (deletepassword === orderid.slice(4)) {
cancelorder();
handleClose(true);
}
{(invoiceeligible) &&
<Alert color="warning" variant="border" icon={<WarningFilled />}>
<AlertTitle>Order is within 24Hrs time frame. The order will be invoiced with standard pricing as agreed.</AlertTitle>
<Link href='https://thelegendarystaff.com/' target='_blank' >Terms & Condition link</Link>
</Alert>
}
}} autoFocus>
Yes, Cancel
</Button>
<Button fullWidth onClick={() => handleClose(false)} color="secondary" variant="outlined">
<Typography variant="body1" align="center" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Please type in the order number to confirm.
</Typography>
<TextField
type="text"
fullWidth
onChange={(e) => setDeletepassword(e.target.value)}
error={deletepassword !== orderid.slice(4)}
value={deletepassword}
placeholder={orderid.slice(4)}
/>
<Stack direction="row" spacing={1.5} sx={{ width: 1 }}>
<Button
fullWidth
onClick={() => handleClose(false)}
variant="outlined"
sx={{
borderRadius: 999,
py: 1,
borderColor: DT.borderSubtle,
color: DT.textSecondary,
fontWeight: 700,
'&:hover': { borderColor: DT.textSecondary, bgcolor: DT.surfaceAlt }
}}
>
No
</Button>
<Button
fullWidth
variant="contained"
autoFocus
onClick={() => {
if (deletepassword === orderid.slice(4)) {
cancelorder();
handleClose(true);
}
}}
sx={{
borderRadius: 999,
py: 1,
bgcolor: '#ef4444',
fontWeight: 700,
boxShadow: `0 6px 18px ${ring('#ef4444')}`,
'&:hover': { bgcolor: '#dc2626' }
}}
>
Yes, Cancel
</Button>
</Stack>
</Stack>
</DialogContent>
@@ -925,40 +1055,116 @@ const Details = () => {
// fullScreen
TransitionComponent={PopupTransition}>
<DialogTitle>
<Stack direction={'row'} justifyContent={'space-between'}>
<Stack direction={{ sm: 'row', xs: 'column' }} spacing={2} alignItems={'center'}>
<Typography variant='h3'>Assign Roles</Typography>
<Chip label={clientname} variant="light" color="primary" />
<DialogTitle
sx={{
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
borderBottom: `1px solid ${DT.borderSubtle}`,
p: { xs: 2, sm: 2.5 }
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }} spacing={1.5}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar sx={{ bgcolor: BRAND, color: '#fff', width: 40, height: 40, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
<MdAssignmentTurnedIn size={20} />
</Avatar>
<Stack>
<Typography variant="h4" sx={{ fontWeight: 800, color: DT.textPrimary, lineHeight: 1.1 }}>
Assign Roles
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
{clientname} · {currentrole}
</Typography>
</Stack>
</Stack>
<Chip label={orderid} variant="combined" color='warning' size={'large'} />
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: tint('#f59e0b'),
border: `1px solid ${edge('#f59e0b')}`,
color: '#f59e0b',
fontSize: 12,
fontWeight: 800
}}
>
<MdReceiptLong size={12} /> {orderid}
</Box>
</Stack>
<Grid container sx={{ p: 1 }} spacing={2}>
<Grid container sx={{ pt: 2 }} spacing={1.5}>
<Grid item sm={6} xs={12}>
{/* <Chip label={currentrole} variant="combined" color="primary" size='normal' /> */}
<Tabs
value={tabstatus}
// onChange={handleChangetab}
onChange={() => setTabstatus((e) => (e === 0) ? 1 : 0)}
variant="scrollable" scrollButtons="auto" >
{/* <Tab label="All" /> */}
<Tab label={currentrole} />
</Tabs>
<Box
onClick={() => setTabstatus((e) => (e === 0 ? 1 : 0))}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.625,
borderRadius: 999,
cursor: 'pointer',
bgcolor: BRAND,
color: '#fff',
fontWeight: 800,
fontSize: 12,
boxShadow: `0 6px 18px ${ring(BRAND)}`
}}
>
<Avatar sx={{ width: 22, height: 22, bgcolor: 'rgba(255,255,255,0.22)', color: '#fff' }}>
<MdAssignmentTurnedIn size={12} />
</Avatar>
{currentrole || 'Role'}
</Box>
</Grid>
<Grid item sm={6} xs={12}>
<Stack direction={'row'} justifyContent={{ xs: 'flex-start', sm: 'flex-end' }}
// alignItems={{xs:'flex-end',sm:'center'}}
sx={{ height: '100%' }} spacing={2}>
<Chip sx={{ width: '130px' }} label={`Required:${currentshiftobj.shifts}`} variant="combined" color='primary' size='normal' />
<Chip sx={{ width: '130px' }} label={`Assigned: ${currentshiftobj.assigned}`} variant="combined" color='success' size='normal' />
<Chip sx={{ width: '130px' }} label={`Remaining: ${currentshiftobj.remaining}`} variant="combined" color='error' size='normal' />
<Stack direction="row" justifyContent={{ xs: 'flex-start', sm: 'flex-end' }} spacing={1} flexWrap="wrap" useFlexGap>
{[
{ label: 'Required', value: currentshiftobj.shifts, color: BRAND },
{ label: 'Assigned', value: currentshiftobj.assigned, color: '#10b981' },
{ label: 'Remaining', value: currentshiftobj.remaining, color: '#ef4444' }
].map((c) => (
<Box
key={c.label}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.625,
px: 1,
py: 0.5,
borderRadius: 999,
bgcolor: tint(c.color),
border: `1px solid ${edge(c.color)}`,
color: c.color,
fontSize: 11.5,
fontWeight: 800
}}
>
{c.label}
<Box
sx={{
minWidth: 22,
height: 18,
px: 0.5,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
fontSize: 11,
fontWeight: 800,
bgcolor: '#fff',
color: c.color,
border: `1px solid ${edge(c.color)}`
}}
>
{c.value ?? 0}
</Box>
</Box>
))}
</Stack>
</Grid>
</Grid>
</DialogTitle>
@@ -1256,215 +1462,194 @@ const Details = () => {
<Typography variant="h3">Details</Typography>
</Grid> */}
<CardActions
<Paper
elevation={0}
sx={{
position: 'sticky',
top: '60px',
// top:0,
bgcolor: theme.palette.background.default,
zIndex: 1,
// borderBottom: `1px solid ${theme.palette.divider}`,
width: '100%'
zIndex: 5,
mb: 2,
p: { xs: 1.5, sm: 2, md: 2.5 },
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
boxShadow: DT.shadowMd
}}
>
<Grid item xs={12} >
<Stack direction={{ md: 'row', xs: 'column' }} justifyContent="space-between" alignItems="flex-end"
sx={{ width: '100%', p: 1 }}
>
<Stack direction='row' spacing={2} alignItems='center'
justifyContent='flex-start'
sx={{ width: { xs: '100%', md: '0' } }}
<Stack
direction={{ xs: 'column', md: 'row' }}
alignItems={{ xs: 'flex-start', md: 'center' }}
justifyContent="space-between"
spacing={{ xs: 1.5, md: 2 }}
>
<Stack direction="row" alignItems="center" spacing={{ xs: 1.25, sm: 1.75 }}>
<IconButton
onClick={() => history.back()}
sx={{
bgcolor: '#fff',
border: `1px solid ${DT.borderSubtle}`,
borderRadius: 999,
color: DT.textPrimary,
'&:hover': { bgcolor: tint(BRAND), borderColor: edge(BRAND), color: BRAND }
}}
>
<IconButton
onClick={() => history.back()}
// onClick={()=>}
<MdArrowBack size={18} />
</IconButton>
<Avatar
sx={{
width: { xs: 40, sm: 48 },
height: { xs: 40, sm: 48 },
bgcolor: BRAND,
color: '#fff',
boxShadow: `0 6px 18px ${ring(BRAND)}`
}}
>
<MdReceiptLong size={22} />
</Avatar>
<Stack>
<Typography
variant="h3"
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.25rem', sm: '1.5rem', md: '1.75rem' }
}}
>
<ArrowBackIcon />
</IconButton>
{/* <Link to="/dashboard">Test me</Link> */}
<Stack direction='column' alignItems='flex-start'>
<Typography variant="h3">Details</Typography>
<Stack direction="row" spacing={1}>
{/* <Typography noWrap color="secondary"></Typography> */}
<Chip label={(orderid === '') ? <Skeleton sx={{ width: '80px', bgcolor: '#fff9c4' }} animation="wave" /> : orderid} variant="combined" color='warning' size='small' />
{/* <Typography variant="subtitle1">Date</Typography> */}
{/* <Typography color="secondary">{orderdate}</Typography> */}
<Chip label={(orderdate === '') ? <Skeleton sx={{ width: '80px', bgcolor: '#b3e5fc' }} animation="wave" /> : orderdate} variant="combined" color="primary" size='small' />
{(orderstatus === 'pending') &&
<Chip label="Pending" color="error" size="small" />
}
{(orderstatus === 'cancelled') &&
<Chip label="Cancelled" color="secondary" size="small" />
}
{(orderstatus === 'completed') &&
<Chip label="Completed" color="primary" size="small" />
}
{(orderstatus === 'processing') &&
<Chip label="Processing" color="primary" size="small" />
}
{(orderstatus === 'assigned') &&
<Chip label="Assigned" color="warning" size="small" />
}
{(orderstatus === 'confirmed') &&
<Chip label="Confirmed" color="success" size="small" />
}
{(orderstatus === 'active') &&
<Chip label="Active" color="info" size="small" />
}
{(orderstatus === 'closed') &&
<Chip label="Closed" color="info" size="small" />
}
{(orderstatus === 'modified') &&
<Chip label="Modified"
color='secondary' size="small" variant='contained' />
}
</Stack>
</Stack>
</Stack>
<Stack direction="row" spacing={2}
sx={{ mt: { md: 0, xs: 2 } }}
>
{/* <Typography>{dayjs(startdate).$d.toString()}</Typography> */}
{/* <Typography>{startdate}</Typography> */}
{/* <Typography> {dayjs().$d.toString()}</Typography> */}
{(((orderstatus === 'pending')
|| (orderstatus === 'assigned')
|| (orderstatus === 'confirmed')
|| (orderstatus === 'modified'))
// && (dayjs(startdate).$d > dayjs().$d)
) &&
<Tooltip title='Edit'>
<Button1
variant="outlined"
color="info"
sx={{ borderRadius: '40px' }}
startIcon={<BorderColorIcon color='info' />}
onClick={(e) => {
e.stopPropagation();
// if (dayjs(startdate).$d > dayjs().$d) {
if (dayjs(dayjs().format('MM-DD-YYYY')).isBefore(dayjs(dayjs(startdate).format('MM-DD-YYYY')))) {
navigate(`/editorder`
, {
state: {
orderheaderid: orderheaderid,
tenantid: tenantid
}
}
)
} else {
enqueueSnackbar('Order cannot be edited.\n Order date is not valid at this time',
{
variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 4000,
style: { whiteSpace: "pre-line" }
})
}
}}
>
Edit Order
</Button1>
</Tooltip>
}
{/* {(((orderstatus === 'pending')
|| (orderstatus === 'modified'))
&& assignedpendingcount === 0) &&
<>
<Button1
variant="outlined"
color="primary"
sx={{ borderRadius: '40px' }}
startIcon={<SendIcon />}
onClick={() => {
fetchassignedstaffs();
}}
>
Notify Staff
</Button1>
</>
} */}
{(orderstatus !== 'cancelled' && orderstatus !== '' && orderstatus !== 'completed' && orderstatus !== 'closed') &&
<>
<Button1
variant="outlined"
color="error"
onClick={() => {
console.log(dayjs(startdate).diff(dayjs(), 'm') / 60)
if ((dayjs(startdate).diff(dayjs(), 'm') / 60) > 24) {
setInvoiceeligible(false)
setOpen(true)
} else {
setInvoiceeligible(true)
setOpen(true)
}
}}
sx={{ borderRadius: '40px', mt: { xs: 2, sm: 0 } }}
startIcon={<CancelOutlinedIcon />}
>
Cancel Order
</Button1>
</>
}
{(orderstatus === 'cancelled') &&
<>
<Chip label={`Order Cancelled on ${cancelleddate}`} variant="combined" color='error' />
</>
}
{/* {(orderstatus === 'completed') &&
<Button
variant="outlined"
color="error"
onClick={() => {
navigate(`/invoice/create`, {
state: {
orderheaderid: orderheaderid,
tenantid: tenantid
}
})
}
}
sx={{ borderRadius: '40px', mt: { xs: 2, sm: 0 } }}
Order Details
</Typography>
<Stack direction="row" alignItems="center" spacing={0.75} flexWrap="wrap" useFlexGap sx={{ mt: 0.75 }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: tint('#f59e0b'),
border: `1px solid ${edge('#f59e0b')}`,
color: '#f59e0b',
fontSize: 11,
fontWeight: 800
}}
>
Raise Invoice
</Button>
} */}
<MdReceiptLong size={11} />
{orderid === '' ? <Skeleton sx={{ width: 80 }} animation="wave" /> : orderid}
</Box>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: tint(BRAND),
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontSize: 11,
fontWeight: 800
}}
>
<MdAccessTime size={11} />
{orderdate === '' ? <Skeleton sx={{ width: 80 }} animation="wave" /> : orderdate}
</Box>
<StatusBadge status={orderstatus} />
</Stack>
</Stack>
</Stack>
</Grid>
</CardActions>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
{((orderstatus === 'pending') ||
(orderstatus === 'assigned') ||
(orderstatus === 'confirmed') ||
(orderstatus === 'modified')) && (
<Tooltip title="Edit">
<Button1
variant="outlined"
sx={{
borderRadius: 999,
borderColor: edge(BRAND),
color: BRAND,
fontWeight: 700,
px: 2,
'&:hover': { borderColor: BRAND, bgcolor: tint(BRAND), boxShadow: `0 0 0 3px ${ring(BRAND)}` }
}}
startIcon={<MdEdit size={16} />}
onClick={(e) => {
e.stopPropagation();
if (dayjs(dayjs().format('MM-DD-YYYY')).isBefore(dayjs(dayjs(startdate).format('MM-DD-YYYY')))) {
navigate(`/editorder`, {
state: {
orderheaderid: orderheaderid,
tenantid: tenantid
}
});
} else {
enqueueSnackbar('Order cannot be edited.\n Order date is not valid at this time', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 4000,
style: { whiteSpace: 'pre-line' }
});
}
}}
>
Edit Order
</Button1>
</Tooltip>
)}
{orderstatus !== 'cancelled' && orderstatus !== '' && orderstatus !== 'completed' && orderstatus !== 'closed' && (
<Button1
variant="outlined"
sx={{
borderRadius: 999,
borderColor: edge('#ef4444'),
color: '#ef4444',
fontWeight: 700,
px: 2,
'&:hover': { borderColor: '#ef4444', bgcolor: tint('#ef4444'), boxShadow: `0 0 0 3px ${ring('#ef4444')}` }
}}
onClick={() => {
if ((dayjs(startdate).diff(dayjs(), 'm') / 60) > 24) {
setInvoiceeligible(false);
setOpen(true);
} else {
setInvoiceeligible(true);
setOpen(true);
}
}}
startIcon={<CancelOutlinedIcon />}
>
Cancel Order
</Button1>
)}
{orderstatus === 'cancelled' && (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: tint('#ef4444'),
border: `1px solid ${edge('#ef4444')}`,
color: '#ef4444',
fontSize: 12,
fontWeight: 800
}}
>
<MdCancel size={12} /> Cancelled on {cancelleddate}
</Box>
)}
</Stack>
</Stack>
</Paper>
{/* Dialog window */}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,898 @@
import React from 'react';
import Loader from 'components/Loader';
import { useEffect, useState, Fragment } from 'react';
import { useTheme } from '@mui/material/styles';
import MainCard from 'components/MainCard';
import axios from 'axios';
import ClearIcon from '@mui/icons-material/Clear';
import { SearchOutlined, CloseOutlined } from '@ant-design/icons';
import { Empty } from 'antd';
import MyLocationIcon from '@mui/icons-material/MyLocation';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
import { enqueueSnackbar } from 'notistack';
import { useNavigate } from 'react-router';
import { GoogleMap, LoadScript, Marker } from '@react-google-maps/api';
import {
FormControl,
InputAdornment,
Grid,
Typography,
Stack,
Button,
TextField,
Autocomplete,
Divider,
Dialog,
DialogTitle,
DialogContent,
Checkbox,
DialogActions,
CircularProgress,
IconButton,
OutlinedInput,
FormGroup,
FormControlLabel,
Table,
TableContainer,
TableCell,
TableBody,
TableRow,
Paper,
TableHead,
Box
} from '@mui/material';
import CircularLoader from 'components/nearle_components/CircularLoader';
// import RidersPinPointOSM from './RidersPinPointOSM';
import RidersPinPoint from './ridersPinPoint';
const MultipleOrders = () => {
const navigate = useNavigate();
const theme = useTheme();
const [loading, setLoading] = useState(false);
const [btnLoading, setBtnLoading] = useState(false);
const [appId, setAppId] = useState(0);
const [tenantLocations, setTenantlocations] = useState([]);
const userid = localStorage.getItem('userid');
const tenId = localStorage.getItem('tenantid');
const [tid, setTid] = useState(0);
const [isLocation, setIsLocation] = useState(false);
const [basePrice, setBasePrice] = useState(0);
const [pricePerKm, setPricePerKm] = useState(0);
const [minKm, setMinKm] = useState(0);
const [pickCust, setPickCust] = useState(null);
const [dropCust, setDropCust] = useState([]);
const [isCustomerOpen, setIsCustomerOpen] = useState(false);
const [searchCustList, setSearchCustList] = useState('');
const [customerlist, setCustomerlist] = useState([]);
const [startdate, setStartdate] = useState(dayjs().format('MM-DD-YYYY'));
const [timeslotarr, setTimeslotarr] = useState([]);
const [starttime, setStatrttime] = useState();
const [endtime, setEndtime] = useState();
const [alertmessage, setAlertmessage] = useState('');
const [otherinstructions, setOtherinstructions] = useState('');
const [admintoken, setAdmintoken] = useState();
const [totaldist, settotaldist] = useState(0);
const [totalAmt, settotalAmt] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showMap, setShowMap] = useState(false);
useEffect(() => {
dropCust && console.log('dropCust', dropCust);
}, [dropCust]);
// =============================================== || opentoast || ===============================================
const opentoast = (message, variant, time) => {
enqueueSnackbar(message, {
variant: variant,
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: time ? time : 1500
});
console.log(alertmessage);
};
// ==============================|| fetchAppLocations ||============================== //
const fetchAppLocations = async () => {
try {
const locationRes = await axios.get(`${process.env.REACT_APP_URL}/partners/getlocations/?userid=${userid}`);
console.log('fetchAppLocations', locationRes.data.details);
} catch (err) {
console.log('locationRes', err);
}
};
useEffect(() => {
fetchAppLocations();
}, []);
// ============================================= || fetchTenantPricing || =============================================
const fetchTenantPricing = async (id) => {
try {
const pricingResponse = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantpricing/?tenantid=${tenId}`);
console.log('pricingResponse', pricingResponse.data.details);
setBasePrice(pricingResponse.data.details.baseprice);
setPricePerKm(pricingResponse.data.details.priceperkm);
setMinKm(pricingResponse.data.details.minkm);
} catch (error) {
console.log('fetchTenantPricing error', error);
}
};
useEffect(() => {
fetchTenantPricing();
}, []);
// ============================================= || gettenantlocations (branches) || =============================================
const gettenantlocations = async (id) => {
try {
const res = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantlocations/?tenantid=${id}`);
console.log('gettenantlocations', res.data.details);
if (res.data.details.length == 1) {
setIsLocation(true);
setTenantlocations(res.data.details);
setPickCust(res.data.details[0]);
} else {
setTenantlocations(res.data.details);
}
} catch (err) {
console.log('gettenantlocations', err);
}
};
useEffect(() => {
gettenantlocations(tenId);
}, []);
// ========================================================= || clientdetails || =========================================================
const clientdetails = async () => {
try {
let url =
searchCustList == ''
? `${process.env.REACT_APP_URL}/customers/gettenantcustomers/?tenantid=${tenId}&pageno=1&pagesize=10`
: `${process.env.REACT_APP_URL}/customers/search/?tenantid=${tenId}&keyword=${searchCustList}`;
await axios
.get(url)
.then((res) => {
if (res.data.status) {
console.log('clientdetails', res.data.details);
setCustomerlist(res.data.details);
let arr = [];
res.data.details.map((val) => {
arr.push({
label: `${val.firstname} | ${val.contactno}`,
...val
});
});
}
})
.catch((err) => {
console.log(err);
opentoast('server error', 'warning');
});
} catch (err) {
console.log(err);
}
};
useEffect(() => {
if (tenId) {
clientdetails();
}
}, [searchCustList.length > 3, searchCustList == '', tenId]);
// ========================================================= || calculateTotal(dist , charge) || =========================================================
const calculateTotal = () => {
let a1 = 0;
let a2 = 0;
dropCust?.map((customer) => {
a1 += customer.distance;
a2 += customer.totalcharge;
});
settotaldist(a1);
settotalAmt(a2);
};
useEffect(() => {
dropCust && calculateTotal();
}, [dropCust]);
// ========================================================= || handleCheckboxChange || =========================================================
const handleCheckboxChange = async (event, customer) => {
setIsLoading(true);
console.log('event', event.target.checked);
console.log('customer', customer);
if (event.target.checked) {
// If the checkbox is checked, calculate the distance and add the customer
try {
const obj = await calculateDistance(customer);
console.log('return of calculateDistance', obj);
const { roundedDistance, totalcharge } = obj;
// Create a new customer object with the distance property
const updatedCustomer = {
...customer,
distance: roundedDistance,
totalcharge: totalcharge
};
// Add the updated customer object to dropCust
setDropCust((prevDropCust) => [...prevDropCust, updatedCustomer]);
// Log the rounded distance
console.log(`Rounded Distance: ${roundedDistance} km`);
} catch (error) {
console.error('Failed to calculate distance:', error);
}
setIsLoading(false);
} else {
// If the checkbox is unchecked, remove the customer from dropCust
setDropCust((prevDropCust) => {
return prevDropCust.filter((cust) => cust.customerid !== customer.customerid);
});
setIsLoading(false);
}
};
// ========================================================= || calculateDistance || =========================================================
const calculateDistance = async (customer) => {
console.log('Distance calculation starts');
const service = new google.maps.DistanceMatrixService();
// Helper function to get the distance matrix
const getDistanceMatrix = async (origins, destinations) => {
console.log('origins', origins);
console.log('destinations', destinations);
return new Promise((resolve, reject) => {
console.log('calculation starts');
service.getDistanceMatrix(
{
origins: [new google.maps.LatLng(origins.latitude, origins.longitude)],
destinations: [new google.maps.LatLng(destinations.latitude, destinations.longitude)],
travelMode: 'DRIVING',
unitSystem: google.maps.UnitSystem.METRIC // Distances in metric units (km)
},
(response, status) => {
console.log('cal response', response);
console.log('cal status', status);
if (status === 'OK') {
console.log('calcualtion resolved');
resolve(response);
} else {
console.log('calcualtion rejected');
reject(new Error(`Error calculating distance: ${status}`));
}
}
);
});
};
try {
// Call getDistanceMatrix and wait for the response
const response = await getDistanceMatrix(pickCust, customer);
// Extract distance from the first result
const distanceInMeters = response.rows[0].elements[0].distance.value;
// Convert distance from meters to kilometers
const distanceInKilometers = distanceInMeters / 1000;
// Round the distance to the nearest integer
const roundedDistance = Math.round(distanceInKilometers);
let totalcharge;
if (roundedDistance < minKm) {
console.log('minKm', minKm);
console.log('pricePerKm', pricePerKm);
console.log('basePrice', basePrice);
totalcharge = basePrice;
} else {
console.log('minKm', minKm);
console.log('pricePerKm', pricePerKm);
console.log('basePrice', basePrice);
totalcharge = (roundedDistance - minKm) * pricePerKm + basePrice;
console.log('totalcharge', totalcharge);
}
// Return the rounded distance
return { roundedDistance, totalcharge };
} catch (error) {
console.error('Error calculating distance:', error);
throw error; // Rethrow the error to be handled by the caller
}
};
// ==================================================== || fetchTiming || ====================================================
const fetchTiming = async () => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/utils/getapplocations/?applocationid=${appId}`)
.then((res) => {
console.log('fetchTiming', res);
const { opentime, closetime, latitude, longitude, radius } = res.data.details[0];
if (res.data.status) {
setStatrttime(`${dayjs().format('MM-DD-YYYY')} ${opentime}`);
setEndtime(`${dayjs().format('MM-DD-YYYY')} ${closetime}`);
console.log('starttime', `${dayjs().format('MM-DD-YYYY')} ${opentime}`);
console.log('endtime', `${dayjs().format('MM-DD-YYYY')} ${closetime} `);
let arr = [];
for (
let i = `${dayjs().format('MM-DD-YYYY')} ${opentime}`, j = 0;
dayjs(`${dayjs().format('MM-DD-YYYY')} ${closetime} `).diff(i, 'm') >= 0;
j++, i = dayjs(i).add(30, 'm')
) {
arr.push(i);
}
console.log('setTimeslotarr', arr);
setTimeslotarr(arr);
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
useEffect(() => {
if (appId) {
fetchTiming();
}
}, [starttime, endtime, appId]);
const fetchAppAdminTokens = async () => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/utils/getapplocationconfig/?applocationid=${appId}`)
.then((res) => {
const userfcmtokemArray = res.data.details.applocationadmins.map((admin) => admin.userfcmtokem); // fcm => firebase cloud messaging
console.log('fetchAppAdminTokens', res);
console.log('userfcmtokemArray', userfcmtokemArray);
if (res.data.status) {
setAdmintoken(userfcmtokemArray);
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
useEffect(() => {
if (starttime && endtime) {
fetchAppAdminTokens();
}
}, [starttime, endtime]);
useEffect(() => {
console.log('pickCust', pickCust);
}, [pickCust]);
// ==================================================== || fetchtenantinfo || ====================================================
const fetchtenantinfo = async () => {
setLoading(true);
console.log('tid', tid);
await axios
.get(`${process.env.REACT_APP_URL}/tenants/gettenantinfo/?tenantid=${tid}`)
.then((res) => {
console.log('fetchtenantinfo', res);
if (res.data.status) {
fetchAppAdminTokens();
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
useEffect(() => {
if (tid) {
fetchtenantinfo();
}
}, [tid]);
// ================================================== || sendnotifications || ==================================================
const sendnotifications = async () => {
setLoading(true);
await axios
.post(`${process.env.REACT_APP_URL}/utils/sendnotifications`, {
priority: 'high',
registration_ids: admintoken,
data: {
accessid: process.env.REACT_APP_RIDER_ACCESS_ID
},
notification: {
title: 'Nearle Merchant',
body: 'An Order has been placed successfully,kindly process the same',
sound: 'ring'
}
})
.then((res) => {
console.log(res);
if (res.data.message == 'Success') {
enqueueSnackbar('Notification sent Successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 1000
});
}
setLoading(false);
})
.catch((err) => {
console.log(err);
enqueueSnackbar(err.message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 1000
});
setLoading(false);
});
};
// =============================================== || creategrouporders || ===============================================
const creategrouporders = async () => {
const arr = dropCust?.map((customer) => ({
applocationid: pickCust.applocationid,
cancellled: '',
// categoryid: +tenant.categoryid,
configid: 9,
customerid: customer.customerid,
deliveryaddress: customer.address || '',
deliverycharge: +customer.totalcharge || 0,
deliverycity: customer.city || '',
deliverycontactno: customer.contactno || '',
deliverycustomer: customer.firstname || '',
deliveryid: +customer.customerid,
deliverylandmark: customer.landmark || '',
deliverylat: customer.latitude,
deliverylocation: customer.suburb || '',
deliverylocationid: customer.deliverylocationid || 0,
deliverylong: customer.longitude,
// deliverytime: `${dayjs(startdate).format('YYYY-MM-DD HH:mm:ss')} `,
deliverytime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
deliverytype: 'B',
delivered: '',
itemcount: 1,
kms: customer.distance.toString() || 0,
locationid: +pickCust.locationid,
moduleid: +pickCust.moduleid,
orderamount: +customer.totalcharge || 0,
ordercharges: 0.0,
orderdate: dayjs().format('YYYY-MM-DD HH:mm:ss'),
orderheaderid: 0,
orderid: '', //
ordernotes: otherinstructions,
orderstatus: 'created',
ordervalue: +customer.totalcharge || 0,
partnerid: pickCust.partnerid,
partneruserid: +userid,
paymentstatus: 1,
paymenttype: 42,
pending: '',
pickupaddress: pickCust.address || '',
pickupcity: pickCust.locationcity || '',
pickupcontactno: pickCust.contactno || '',
pickupcustomer: pickCust.locationname || '',
pickuplandmark: pickCust.landmark || '',
pickuplat: pickCust.latitude,
pickuplocation: pickCust.suburb || '',
pickuplocationid: pickCust.locationid || 0,
pickuplong: pickCust.longitude,
processing: '',
ready: '',
remarks: '',
taxamount: 0.0,
tenantid: pickCust.tenantid,
tenantuserid: 0
}));
console.log('arr', arr);
if (!tenId) {
opentoast('Choose Client ', 'warning');
} else {
setLoading(true);
await axios
.post(`${process.env.REACT_APP_URL}/orders/createorders`, arr)
.then((res) => {
if (res.data.status) {
enqueueSnackbar('Order Created Successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 1000
});
if (admintoken) {
// notifyadmin(admintoken);
sendnotifications();
}
navigate('/nearle/orders');
} else {
opentoast(res.data.message, 'warning');
}
setLoading(false);
console.log(res);
})
.catch((err) => {
console.log(err);
// opentoast(err.data.message, 'warning');
setLoading(false);
});
}
console.log(arr);
};
return (
<>
{loading && <Loader />}
{/* <RidersPinPointOSM /> */}
<Grid container sx={{ mb: 2 }}>
<Grid item xs={12} sm={3} md={6}>
<Stack>
<Typography variant="h3" whiteSpace="nowrap">
Multiple Orders
</Typography>
</Stack>
</Grid>
<Grid item xs={12} sm={9} md={6}>
<Stack
sx={{}}
width={'100%'}
direction="row"
alignItems="center"
spacing={2}
justifyContent={'flex-end'}
flexWrap={{ xs: 'wrap', custom550: 'nowrap' }}
gap={2}
>
{/* Business Location */}
<Stack sx={{ width: '100%' }}>
{tenantLocations?.length === 1 ? (
<TextField
label="Business Location"
fullWidth
focused
value={tenantLocations[0]?.locationname}
InputProps={{
style: { color: theme.palette.primary.main },
startAdornment: (
<InputAdornment position="start">
<MyLocationIcon color="primary" />
</InputAdornment>
)
}}
/>
) : (
<Autocomplete
fullWidth
options={tenantLocations || []}
getOptionLabel={(option) => `${option.locationname} (${option.suburb})`}
onChange={(event, value, reason) => {
if (value) {
setTid(value.tenantid);
setIsLocation(true);
setPickCust(value);
}
if (reason === 'clear') setIsLocation(false);
}}
renderInput={(params) => <TextField {...params} label="Select Business Location" color="primary" fullWidth />}
/>
)}
</Stack>
{/* Date Picker */}
<Stack sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
format="DD-MM-YYYY"
disablePast
value={dayjs(startdate)}
sx={{ width: 150 }}
onChange={(e) => {
let diff = dayjs().diff(dayjs(dayjs(e).format('YYYY-MM-DD')), 'd');
if (diff <= 0) {
setStartdate(e);
let arr = [];
timeslotarr.forEach((val) => {
if (dayjs().diff(dayjs(`${dayjs(e).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0) {
arr.push(val);
}
});
if (arr[0]) {
setOrderarr([
{
sno: 1,
address: '',
customerid: '',
deliverytime: dayjs(arr[0]),
deliverylocationid: '',
clientname: '',
contactno: '',
latitude: '',
longitude: ''
}
]);
} else {
setOrderarr([]);
}
} else {
opentoast('choose Upcoming Date', 'warning');
setStartdate(NaN);
}
}}
/>
</LocalizationProvider>
</Stack>
</Stack>
</Grid>
</Grid>
{/* ===================================================== || Pickup || ===================================================== */}
{pickCust && (
<TableContainer component={Paper} sx={{ mb: 2 }}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Pickup Location</TableCell>
<TableCell>Address</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>{pickCust?.locationname}</TableCell>
<TableCell>{pickCust?.address}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
)}
{/* ===================================================== || Drop || ===================================================== */}
<MainCard
sx={{ height: '100%' }}
title={`Drop (${dropCust?.length || 0})`}
secondary={
<Button
variant="outlined"
size="small"
sx={{
'&:hover': {
bgcolor: theme.palette.primary.main,
color: 'white'
}
}}
onClick={() => {
if (!isLocation) {
opentoast('Select Business Location', 'warning');
} else {
setIsCustomerOpen(true);
setSearchCustList('');
}
}}
>
Select Customers
</Button>
}
>
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>S.No</TableCell>
<TableCell>Customer</TableCell>
<TableCell>Address</TableCell>
<TableCell>Kms</TableCell>
<TableCell align="right">Charge</TableCell>
<TableCell>Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!dropCust && (
<TableRow>
<TableCell colSpan={6}>
<Empty description={' Drop Customers Not Selected'} />
</TableCell>
</TableRow>
)}
{dropCust?.map((customer, index) => (
<TableRow key={index}>
<TableCell>{index + 1}</TableCell>
<TableCell>{customer.firstname}</TableCell>
<TableCell>{customer.address}</TableCell>
<TableCell>{customer.distance}</TableCell>
<TableCell align="right">{`${customer.totalcharge}.00`}</TableCell>
<TableCell align="center">
{
<CloseOutlined
style={{ cursor: 'pointer', color: 'red' }}
onClick={(event) => handleCheckboxChange(event, customer)}
/>
}
</TableCell>
</TableRow>
))}
{dropCust?.length != 0 && (
<TableRow>
<TableCell>
<Typography variant="h5">Total</Typography>
</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell>
<Typography variant="h5">{`${totaldist} `}</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="h5"> {`${totalAmt}.00`}</Typography>
</TableCell>
<TableCell></TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</MainCard>
{/* ================================================= || Riders Map || ================================================= */}
{/* {showMap && dropCust.length >= 1 && <RidersPinPoint pickCust={pickCust} dropCust={dropCust} />} */}
{/* ================================================= || Notes || ================================================= */}
{dropCust && (
<MainCard sx={{ mt: 2 }} title={'Notes'}>
<Grid container>
<Grid item xs={12}>
<TextField
focused
id="outlined-multiline-static"
sx={{ width: '100%', height: '100%', mb: 2 }}
multiline
rows={1}
placeholder="Notes"
value={otherinstructions}
onChange={(e) => setOtherinstructions(e.target.value)}
/>
</Grid>
<Stack direction="row" justifyContent={'end'} sx={{ mt: 2, width: '100%' }}>
<Button
disabled={dropCust?.length == 0}
size="medium"
variant="outlined"
onClick={() => {
setLoading(true);
setBtnLoading(true);
creategrouporders();
setTimeout(() => {
setLoading(false);
setBtnLoading(false);
}, 2000);
}}
sx={{
'&:hover': {
transform: 'scale(1.05)',
transition: 'transform 0.3s ease'
}
}}
>
{btnLoading ? <CircularProgress color="primary" size={20} thickness={10} /> : 'Create'}
</Button>
</Stack>
</Grid>
</MainCard>
)}
{/* ============================================= || saved address Dialog || ============================================= */}
<Dialog
open={isCustomerOpen}
onClose={() => {
setIsCustomerOpen(false);
}}
fullWidth
sx={{ minWidth: 'lg' }}
>
{isLoading && <CircularLoader />}
<DialogTitle sx={{ bgcolor: theme.palette.primary.main, color: 'white' }}>
<Stack>
<Typography variant="h4"> {`Select Drop Customers (${dropCust?.length || 0})`}</Typography>
<FormControl
sx={{
width: '100%',
mt: 1
}}
>
<Stack spacing={2} sx={{ py: 0.2 }}>
<OutlinedInput
fullWidth
id="input-search-header"
placeholder="Search"
value={searchCustList}
onChange={(e) => setSearchCustList(e.target.value)}
sx={{
'& .MuiOutlinedInput-input': {
p: '10.5px 0px 12px'
},
bgcolor: 'white'
}}
startAdornment={
<InputAdornment position="start">
<SearchOutlined style={{ fontSize: 'small' }} />
</InputAdornment>
}
endAdornment={
<IconButton
sx={{ visibility: searchCustList ? 'visible' : 'hidden' }}
onClick={() => {
setSearchCustList('');
}}
>
<ClearIcon />
</IconButton>
}
autoComplete="off"
/>
</Stack>
</FormControl>
</Stack>
</DialogTitle>
<Divider />
<DialogContent sx={{ p: 2.5 }}>
{customerlist.length == 0 ? (
<Stack spacing={2} direction={'row'} alignItems={'center'} justifyContent={'center'} sx={{ minHeight: 600, maxHeight: 600 }}>
<Empty />
</Stack>
) : (
<Stack spacing={2} sx={{ minHeight: 600, maxHeight: 600 }}>
{customerlist &&
customerlist.map((customer, index) => (
<FormGroup key={index}>
<FormControlLabel
control={
<Checkbox
checked={dropCust?.some((cust) => cust.customerid === customer.customerid)} // Set the checked state of the checkbox based on whether the customer is in `dropCust`
onChange={(event) => handleCheckboxChange(event, customer)}
/>
}
label={
<div style={{ width: '100%' }}>
<Typography variant="subtitle1" sx={{ textAlign: 'left' }}>
{`${customer.firstname} (${customer.contactno})`}
</Typography>
<Typography variant="body2" color="secondary" sx={{ textAlign: 'left' }}>
{customer.address}
</Typography>
</div>
}
/>
</FormGroup>
))}
</Stack>
)}
</DialogContent>
<Divider />
<DialogActions sx={{ p: 2.5 }}>
<Button
color={dropCust?.length !== 0 ? 'primary' : 'error'}
variant="outlined"
sx={{
'&:hover': {
bgcolor: dropCust?.length !== 0 ? theme.palette.primary.main : theme.palette.error.main,
color: 'white'
}
}}
onClick={() => {
setIsCustomerOpen(false);
{
dropCust?.length !== 0 && setShowMap(true);
}
}}
>
{dropCust?.length !== 0 ? 'Continue' : 'Close'}
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default MultipleOrders;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
import { Button } from '@mui/material';
import { LoadScriptNext, GoogleMap, Marker, OverlayView } from '@react-google-maps/api';
const containerStyle = {
width: '100%',
height: 'calc(100vh - 150px)'
};
export default function RiderLocationMap({ riderLocations }) {
console.log('riderLocations', riderLocations);
const center = {
lat: Number(riderLocations?.[0]?.latitude || 11.0056),
lng: Number(riderLocations?.[0]?.longitude || 76.9661)
};
const GreenIcon = {
url: 'https://cdn.rawgit.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
scaledSize: new window.google.maps.Size(25, 41),
anchor: new window.google.maps.Point(12, 41)
};
const RedIcon = {
url: 'https://cdn.rawgit.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
scaledSize: new window.google.maps.Size(25, 41),
anchor: new window.google.maps.Point(12, 41)
};
return (
<LoadScriptNext googleMapsApiKey={process.env.REACT_APP_GOOGLE_MAPS_API_KEY}>
<GoogleMap mapContainerStyle={containerStyle} zoom={12} center={center}>
{riderLocations &&
riderLocations?.map((r, index) => {
const lat = Number(r.latitude);
const lng = Number(r.longitude);
return (
<div key={index}>
{/* Marker */}
<Marker
position={{ lat, lng }}
icon={r.status == 'active' ? GreenIcon : RedIcon}
label={{
fontSize: '14px',
fontWeight: 'bold'
}}
/>
<OverlayView position={{ lat, lng }} mapPaneName={OverlayView.OVERLAY_LAYER}>
<div
style={{
background: 'none',
color: 'green',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 600,
whiteSpace: 'nowrap',
transform: 'translate(-50%, -140%)',
pointerEvents: 'none',
ml: 20
}}
>
<Button variant="contained" color="primary" size="small">
{` ${r.username} `}
{/* <br /> */}
{/* {`${r.contactno || '##### ##### '} `} */}
<br />
{`(${r.orderid || ''}) `}
</Button>
</div>
</OverlayView>
</div>
);
})}
</GoogleMap>
</LoadScriptNext>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,209 +1,703 @@
import * as React from 'react';
import { useState } from 'react';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { useQuery } from '@tanstack/react-query';
// material-ui
import React, { useState, useEffect, useRef, Fragment } from 'react';
import {
Avatar,
AppBar,
Backdrop,
Box,
Button,
Checkbox,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
FormControl,
OutlinedInput,
InputAdornment,
Chip,
Stack
Drawer,
IconButton,
InputBase,
List,
ListItem,
Paper,
Skeleton,
Stack,
Toolbar,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
import { SearchOutlined, CloseOutlined } from '@ant-design/icons';
import Loader from 'components/Loader';
// project imports
import MainCard from 'components/MainCard';
import { Empty } from 'antd';
import TitleCard from '../titleCard';
import { fetchAppLocations, fetchRidersLogs } from '../api/api';
function formatDate(dateString) {
const date = dayjs(dateString);
const formattedDate = date.format('DD-MM-YYYY ');
return formattedDate;
}
import {
MdMenu,
MdSearch,
MdClear,
MdRefresh,
MdLocalShipping,
MdCheckCircle,
MdHighlightOff,
MdGroups,
MdAccessTime,
MdLocationOn,
MdMyLocation
} from 'react-icons/md';
const formatTime = (timeString) =>
new Date('2024-01-01T' + timeString + 'Z').toLocaleTimeString('en-US', {
timeZone: 'UTC',
hour12: true,
hour: '2-digit',
minute: '2-digit'
});
import RiderLocationMap from './RiderLocationMap';
import { fetchRidersLogs } from '../api/api';
import CircularLoader from 'components/nearle_components/CircularLoader';
import error500 from 'assets/images/maintenance/Error500.png';
// ==============================|| RidersLogs ||============================== //
// ============================================================================
// Design tokens — shared with the rest of the redesigned operator pages.
// ============================================================================
const DT = {
radiusPill: 999,
radiusCard: 16,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
const dtA = (c, suffix) => `${c}${suffix}`;
const tint = (c) => dtA(c, '08');
const soft = (c) => dtA(c, '18');
const ring = (c) => dtA(c, '26');
const edge = (c) => dtA(c, '55');
export default function RidersLogs() {
const tenantid = localStorage.getItem('tenantid');
const [rowsPerPage, setRowsPerPage] = useState(10);
const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD'));
const [searchword, setSearchword] = useState('');
const [showClose, SetShowClose] = useState(false);
const BRAND = '#662582';
const BRAND_LIGHT = '#9255AB';
const C_ACTIVE = '#10b981';
const C_INACTIVE = '#ef4444';
const drawerWidth = 360;
// Soft pill used for status / count chips throughout the page.
const SoftPill = ({ color, icon, children, sx = {} }) => (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 0.875,
py: 0.25,
borderRadius: 999,
bgcolor: tint(color),
border: `1px solid ${edge(color)}`,
color,
fontSize: 11,
fontWeight: 800,
whiteSpace: 'nowrap',
...sx
}}
>
{icon}
{children}
</Box>
);
const RidersLogs = () => {
const theme = useTheme();
const isDesktop = useMediaQuery('(min-width:900px)');
const [open, setOpen] = useState(false);
const [selectedRiders, setSelectedRiders] = useState([]);
const [riderSearch, setRiderSearch] = useState('');
const searchRef = useRef(null);
const appId = 1;
/* ============================================= || fetchRidersLogs| ============================================= */
const {
data: rows = [], // Default to empty array
isLoading: IsRiderLogsLoading,
isError: IsRiderLogsError,
error: RiderLogsError
data: riders,
isLoading: ridersIsLoading,
isFetching: riderIsFetching,
refetch: riderLogsRefetch,
error: riderLogsError
} = useQuery({
queryKey: [tenantid, startdate], // Meaningful query key
queryKey: [appId, dayjs().format('YYYY-MM-DD'), riderSearch],
queryFn: fetchRidersLogs,
enabled: !!tenantid && !!startdate, // Fetch only if appId & startdate exist
refetchInterval: 300000 // Auto-fetch every 5 minutes
refetchInterval: 5 * 60 * 1000
});
React.useEffect(() => {
setRowsPerPage(rows?.length + 1);
}, [rows]);
{
IsRiderLogsError && console.log('RiderLogsError', RiderLogsError);
}
// Only active riders are surfaced on this page — inactive riders are filtered
// out entirely before they reach the list, selection, counts, or map.
const activeRiders = React.useMemo(
() => (riders || []).filter((r) => r.status === 'active'),
[riders]
);
useEffect(() => {
setSelectedRiders(activeRiders);
}, [activeRiders]);
useEffect(() => {
setOpen(isDesktop);
}, [isDesktop]);
// Ctrl/Cmd+K focuses the rider search.
useEffect(() => {
const handleKeyPress = (event) => {
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
searchRef.current && searchRef.current.focus();
}
if (event.key === 'Escape' && document.activeElement === searchRef.current) {
searchRef.current.blur();
}
};
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress);
}, []);
// Counts shown in the drawer header (active riders only).
const totalRiders = activeRiders.length;
const activeCount = totalRiders;
const isAllSelected = totalRiders > 0 && selectedRiders?.length === totalRiders;
return (
<>
{IsRiderLogsLoading && <Loader />}
<TitleCard title="Riders Logs" />
<MainCard
content={false}
title={
<Stack display={'flex'} flexDirection={'row'} alignItems={'center'} justifyContent={'space-between'} flexWrap={'wrap'} gap={1}>
<Stack>
<FormControl sx={{ width: 250 }}>
<OutlinedInput
sx={{ background: 'white' }}
size="medium"
id="header-search"
startAdornment={
<InputAdornment position="start" sx={{ mr: -0.5 }}>
<SearchOutlined />
</InputAdornment>
<Fragment>
<Backdrop
sx={{
color: '#fff',
zIndex: (t) => t.zIndex.drawer + 1
}}
open={ridersIsLoading || riderIsFetching}
>
<CircularLoader color="inherit" />
</Backdrop>
<Paper
elevation={0}
sx={{
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
overflow: 'hidden',
background: '#fff'
}}
>
<Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative' }}>
{/* ============================================= || Drawer || ============================================= */}
<Drawer
variant={isDesktop ? 'persistent' : 'temporary'}
open={open}
onClose={() => !isDesktop && setOpen(false)}
ModalProps={{ keepMounted: true }}
sx={{
'& .MuiDrawer-paper': {
width: drawerWidth,
position: 'absolute',
left: 0,
top: 0,
height: '100%',
overflowY: 'auto',
transition: 'transform 0.35s ease-in-out',
zIndex: 13,
borderRight: `1px solid ${DT.borderSubtle}`,
backgroundColor: '#fff'
}
}}
>
{/* ===== Drawer header — gradient strip with title + count pills ===== */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 5,
// Solid white base UNDER the translucent brand gradient so the
// scrolling rider list never shows through the sticky header.
backgroundColor: '#fff',
backgroundImage: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
borderBottom: `1px solid ${DT.borderSubtle}`,
p: 1.75
}}
>
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1.25 }}>
<Avatar
sx={{
width: 38,
height: 38,
bgcolor: BRAND,
color: '#fff',
boxShadow: `0 6px 18px ${ring(BRAND)}`
}}
>
<MdGroups size={20} />
</Avatar>
<Stack sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{
fontWeight: 800,
color: DT.textPrimary,
fontSize: { xs: '1rem', sm: '1.1rem' },
lineHeight: 1.1
}}
noWrap
>
Riders
</Typography>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.25 }}>
<Box
sx={{
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: C_ACTIVE,
boxShadow: `0 0 0 3px ${ring(C_ACTIVE)}`
}}
/>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Updated · {dayjs().format('hh:mm A')}
</Typography>
</Stack>
</Stack>
</Stack>
{/* Count pills */}
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap sx={{ mb: 1.25 }}>
<SoftPill color={BRAND} icon={<MdGroups size={11} />}>
Total · {totalRiders}
</SoftPill>
<SoftPill color={C_ACTIVE} icon={<MdCheckCircle size={11} />}>
Active · {activeCount}
</SoftPill>
</Stack>
{/* Pill search */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: '#fff',
border: `1.5px solid ${edge(BRAND)}`,
transition: 'all 0.18s',
'&:focus-within': {
borderColor: BRAND,
boxShadow: `0 0 0 3px ${ring(BRAND)}`
}
endAdornment={
showClose && (
<InputAdornment position="end" sx={{ mr: -0.5 }}>
<CloseOutlined
onClick={() => {
setSearchword('');
SetShowClose(false);
}}
>
<MdSearch size={16} style={{ color: BRAND, flexShrink: 0 }} />
<InputBase
inputRef={searchRef}
placeholder="Search rider (ctrl+k)"
value={riderSearch}
onChange={(e) => setRiderSearch(e.target.value)}
autoComplete="off"
sx={{
flex: 1,
fontSize: 13,
fontWeight: 600,
color: DT.textPrimary,
'& input::placeholder': { color: DT.textMuted, opacity: 1 }
}}
/>
{riderSearch && (
<IconButton size="small" onClick={() => setRiderSearch('')} sx={{ p: 0.25, color: BRAND }}>
<MdClear size={14} />
</IconButton>
)}
</Box>
</Box>
{/* ===== "All" selection pill ===== */}
<Box sx={{ p: 1.25, pb: 0.5 }}>
<Box
onClick={() => {
if (!isAllSelected) {
setSelectedRiders(activeRiders);
}
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1,
py: 0.75,
borderRadius: 999,
cursor: 'pointer',
border: `1.5px solid ${isAllSelected ? BRAND : edge(BRAND)}`,
bgcolor: isAllSelected ? BRAND : tint(BRAND),
color: isAllSelected ? '#fff' : BRAND,
transition: 'all 0.18s',
boxShadow: isAllSelected ? `0 6px 18px ${ring(BRAND)}` : 'none',
'&:hover': {
borderColor: BRAND,
boxShadow: `0 0 0 3px ${ring(BRAND)}`
}
}}
>
<Checkbox
size="small"
checked={isAllSelected}
onChange={(e) => {
if (e.target.checked) {
setSelectedRiders(activeRiders);
}
}}
sx={{
p: 0,
color: isAllSelected ? '#fff' : BRAND,
'&.Mui-checked': { color: '#fff' }
}}
/>
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: 12.5 }}>
Show All Riders
</Typography>
<Box
sx={{
ml: 'auto',
minWidth: 24,
height: 20,
px: 0.625,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
fontSize: 11,
fontWeight: 800,
bgcolor: isAllSelected ? 'rgba(255,255,255,0.22)' : '#fff',
color: isAllSelected ? '#fff' : BRAND,
border: isAllSelected ? 'none' : `1px solid ${edge(BRAND)}`
}}
>
{totalRiders}
</Box>
</Box>
</Box>
{/* ===== Rider list ===== */}
<List sx={{ px: 1, py: 0.5 }}>
{ridersIsLoading || riderIsFetching
? Array.from({ length: 8 }).map((_, index) => (
<ListItem key={index} sx={{ px: 1, py: 1 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ width: '100%' }}>
<Skeleton variant="circular" width={32} height={32} />
<Stack sx={{ flex: 1 }}>
<Skeleton variant="text" width="60%" height={18} />
<Skeleton variant="text" width="40%" height={14} />
</Stack>
<Stack spacing={0.5}>
<Skeleton variant="text" width={60} height={16} />
<Skeleton variant="text" width={80} height={12} />
</Stack>
</Stack>
</ListItem>
))
: activeRiders.map((row) => {
const isSelected = selectedRiders?.length === 1 && selectedRiders[0]?.userid === row?.userid;
const statusColor = row.status === 'active' ? C_ACTIVE : C_INACTIVE;
const initial = (row.firstname || row.username || '?').charAt(0).toUpperCase();
const name =
row.username && row.username.length > 0
? `${row.username.slice(0, 25)}${row.username.length > 25 ? '…' : ''}`
: `${row.firstname || ''}${row.lastname ? ` ${row.lastname}` : ''}`.trim() || 'Rider';
return (
<Box
key={row.userid}
onClick={() => {
// Tapping the card selects this rider in isolation.
setSelectedRiders([row]);
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
mb: 0.625,
borderRadius: 2,
cursor: 'pointer',
border: `1px solid ${isSelected ? edge(BRAND) : DT.divider}`,
bgcolor: isSelected ? tint(BRAND) : '#fff',
transition: 'all 0.15s',
'&:hover': {
borderColor: edge(BRAND),
bgcolor: isSelected ? soft(BRAND) : DT.surfaceAlt,
boxShadow: DT.shadowSoft
}
}}
>
<Checkbox
size="small"
checked={isSelected}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
if (e.target.checked) {
setSelectedRiders([row]);
} else {
setSelectedRiders(activeRiders);
}
}}
sx={{
p: 0.5,
color: edge(BRAND),
'&.Mui-checked': { color: BRAND }
}}
/>
</InputAdornment>
)
}
aria-describedby="header-search-text"
inputProps={{
'aria-label': 'weight'
}}
placeholder="Search"
value={searchword}
onChange={(e) => {
setSearchword(e.target.value);
if (e.target.value == '') {
SetShowClose(false);
} else {
SetShowClose(true);
}
}}
autoComplete="off"
/>
</FormControl>{' '}
</Stack>
<Stack flexDirection="row" alignItems="center" gap={2}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
label="Choose Date"
value={dayjs(startdate)}
format="DD-MM-YYYY"
onChange={(e) => {
if (e) {
setStartdate(dayjs(e.$d).format('YYYY-MM-DD'));
}
}}
/>
</LocalizationProvider>
</Stack>
</Stack>
}
>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>S.No</TableCell>
<TableCell>ID</TableCell>
<TableCell>Rider</TableCell>
<TableCell>LogDate</TableCell>
<TableCell>Shift(HRS)</TableCell>
<TableCell>Login</TableCell>
<TableCell>Logout</TableCell>
<TableCell>WRK(HRS)</TableCell>
<TableCell>Shift(HRS)</TableCell>
<TableCell>BRK(HRS)</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.length == 0 ? (
<TableRow>
<TableCell colSpan={11}>
<Empty />
</TableCell>
</TableRow>
) : (
rows.map((row, index) => (
<TableRow key={index + 1}>
<TableCell align="left">{index + 1}</TableCell>
<TableCell align="left">{row.userid}</TableCell>
<TableCell align="left">{row.username}</TableCell>
<TableCell align="left">
{' '}
<Chip label={formatDate(row.logdate)} color="warning" variant="outlined" size="small" sx={{ bgcolor: '#fffde7' }} />
</TableCell>
<TableCell align="left">{row.shifthours}</TableCell>
<TableCell align="left">
{row.login != '' && (
<Chip label={formatTime(row.login)} color="info" variant="outlined" size="small" sx={{ bgcolor: '#e0f7fa' }} />
)}
</TableCell>
<TableCell align="left">
{row.logout != '' && (
<Chip label={formatTime(row.logout)} color="info" variant="outlined" size="small" sx={{ bgcolor: '#e0f7fa' }} />
)}
</TableCell>
<Avatar
sx={{
width: 36,
height: 36,
bgcolor: soft(statusColor),
color: statusColor,
fontWeight: 800,
fontSize: 14,
position: 'relative'
}}
>
{initial}
<Box
sx={{
position: 'absolute',
bottom: -1,
right: -1,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: statusColor,
border: '2px solid #fff'
}}
/>
</Avatar>
<TableCell align="left">{row.workhours}</TableCell>
<TableCell align="left">{row.shorthours}</TableCell>
<TableCell align="left">{row.breakhours}</TableCell>
<TableCell align="left">
{row.logstatus == 0 ? (
<Chip label="Active" color="success" variant="outlined" size="small" sx={{ bgcolor: '#e8f5e9' }} />
) : (
<Chip label="Inactive" color="error" variant="outlined" size="small" sx={{ bgcolor: '#fce4ec' }} />
)}
</TableCell>
</TableRow>
))
<Stack sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{
fontWeight: 700,
color: DT.textPrimary,
fontSize: 13,
lineHeight: 1.15
}}
noWrap
>
{name}
</Typography>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.25 }}>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontSize: 11 }} noWrap>
{row.contactno || '##########'}
</Typography>
</Stack>
</Stack>
<Stack alignItems="flex-end" spacing={0.5} sx={{ minWidth: 0 }}>
<SoftPill
color={statusColor}
icon={row.status === 'active' ? <MdCheckCircle size={10} /> : <MdHighlightOff size={10} />}
sx={{ fontSize: 10 }}
>
{row.status === 'active' ? 'Active' : 'Inactive'}
</SoftPill>
<Stack direction="row" alignItems="center" spacing={0.375}>
<MdAccessTime size={10} style={{ color: DT.textMuted }} />
<Typography sx={{ fontSize: 10, color: DT.textMuted, fontWeight: 700 }} noWrap>
{row.logdate ? dayjs(row.logdate).format('DD/MM · hh:mm A') : '—'}
</Typography>
</Stack>
</Stack>
</Box>
);
})}
{!ridersIsLoading && !riderIsFetching && totalRiders === 0 && (
<Stack alignItems="center" spacing={1.25} sx={{ py: 6, px: 2 }}>
<Avatar sx={{ width: 56, height: 56, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdGroups size={24} />
</Avatar>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
No riders to show
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, textAlign: 'center' }}>
{riderSearch ? 'Try a different rider name.' : 'Pull-to-refresh once you have rider activity.'}
</Typography>
</Stack>
)}
</TableBody>
</Table>
</TableContainer>
</MainCard>
</>
</List>
</Drawer>
{/* ============================================= || AppBar || ============================================= */}
<AppBar
elevation={0}
position="absolute"
sx={{
top: 0,
left: open && isDesktop ? `${drawerWidth}px` : 0,
width: open && isDesktop ? `calc(100% - ${drawerWidth}px)` : '100%',
transition: 'left 0.3s ease, width 0.3s ease',
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
borderBottom: `1px solid ${DT.borderSubtle}`,
color: DT.textPrimary
}}
>
<Toolbar sx={{ minHeight: { xs: 60, md: 68 } }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: '100%' }}>
<Stack direction="row" alignItems="center" spacing={1.25}>
<IconButton
onClick={() => setOpen(!open)}
sx={{
bgcolor: '#fff',
border: `1px solid ${DT.borderSubtle}`,
borderRadius: 999,
color: BRAND,
'&:hover': { bgcolor: tint(BRAND), borderColor: edge(BRAND) }
}}
>
<MdMenu size={18} />
</IconButton>
<Avatar
sx={{
width: 36,
height: 36,
bgcolor: BRAND,
color: '#fff',
boxShadow: `0 6px 18px ${ring(BRAND)}`
}}
>
<MdLocationOn size={18} />
</Avatar>
<Stack>
<Typography
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '0.95rem', sm: '1.1rem', md: '1.25rem' }
}}
>
Riders Locations
</Typography>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.25 }}>
<Box
sx={{
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: C_ACTIVE,
boxShadow: `0 0 0 3px ${ring(C_ACTIVE)}`
}}
/>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Live · {selectedRiders?.length || 0} of {totalRiders} on map
</Typography>
</Stack>
</Stack>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<SoftPill color={BRAND} icon={<MdMyLocation size={11} />} sx={{ display: { xs: 'none', sm: 'inline-flex' } }}>
{dayjs().format('DD MMM YYYY')}
</SoftPill>
<Button
variant="contained"
onClick={() => riderLogsRefetch()}
startIcon={<MdRefresh size={16} />}
sx={{
borderRadius: 999,
px: 2,
bgcolor: BRAND,
fontWeight: 700,
boxShadow: `0 6px 18px ${ring(BRAND)}`,
textTransform: 'none',
'&:hover': { bgcolor: '#4D1C61' }
}}
>
Refresh
</Button>
</Stack>
</Stack>
</Toolbar>
</AppBar>
{/* ============================================= || Map area || ============================================= */}
<Box
sx={{
flexGrow: 1,
overflow: 'auto',
pt: { xs: '60px', md: '68px' },
pl: open && isDesktop ? `${drawerWidth}px` : 0,
transition: 'padding-left 0.3s ease',
minHeight: '80vh',
bgcolor: DT.surfaceAlt
}}
>
{(ridersIsLoading || riderIsFetching) && (
<Box position="relative" width="100%" height="80vh" display="grid" placeItems="center">
<Skeleton
variant="rectangular"
width="100%"
height="100%"
animation="wave"
sx={{
position: 'absolute',
top: 0,
left: 0,
borderRadius: 0,
zIndex: 1
}}
/>
</Box>
)}
{selectedRiders?.length > 0 && !riderLogsError && <RiderLocationMap riderLocations={selectedRiders} />}
{riderLogsError && (
<Stack alignItems="center" justifyContent="center" sx={{ p: 4, height: '70vh' }}>
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: edge(C_INACTIVE),
bgcolor: tint(C_INACTIVE),
maxWidth: 520,
textAlign: 'center'
}}
>
<Avatar sx={{ bgcolor: C_INACTIVE, color: '#fff', width: 56, height: 56, mx: 'auto', mb: 1.5 }}>
<MdHighlightOff size={26} />
</Avatar>
<Typography variant="h6" sx={{ fontWeight: 800, color: DT.textPrimary, mb: 0.5 }}>
Couldnt load rider logs
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
The map is unavailable right now. Try refreshing in a moment.
</Typography>
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
onClick={() => riderLogsRefetch()}
startIcon={<MdRefresh size={16} />}
sx={{
borderRadius: 999,
px: 3,
bgcolor: BRAND,
fontWeight: 700,
boxShadow: `0 6px 18px ${ring(BRAND)}`,
textTransform: 'none',
'&:hover': { bgcolor: '#4D1C61' }
}}
>
Retry
</Button>
</Box>
</Paper>
<Box
component="img"
src={error500}
alt="error"
sx={{ mt: 2, maxWidth: 240, opacity: 0.6, filter: 'grayscale(0.2)' }}
/>
</Stack>
)}
</Box>
</Box>
</Paper>
</Fragment>
);
}
};
export default RidersLogs;

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,9 @@ const RiderLogs = Loadable(lazy(() => import('pages/nearle/reports/riderLogs')))
const Invoice = Loadable(lazy(() => import('pages/nearle/invoice/invoice')));
const InvoicePreview = Loadable(lazy(() => import('../pages/nearle/invoice/invoicePreview')));
const Dispatch = Loadable(lazy(() => import('pages/nearle/dispatch/Dispatch')));
const DispatchPreview = Loadable(lazy(() => import('pages/nearle/dispatch/Preview')));
// ==============================|| MAIN ROUTING ||============================== //
const MainRoutes = {
@@ -123,6 +126,19 @@ const MainRoutes = {
{
path: 'locations',
element: <Locations />
},
{
path: 'dispatch',
children: [
{
path: '',
element: <Dispatch />
},
{
path: 'preview',
element: <DispatchPreview />
}
]
}
]
},

View File

@@ -9,6 +9,7 @@ const initialState = {
openItem: ['dashboard'],
openComponent: 'buttons',
selectedID: null,
selectedMenu: null,
drawerOpen: false,
componentDrawerOpen: true,
menu: {},
@@ -48,6 +49,10 @@ const menu = createSlice({
hasError(state, action) {
state.error = action.payload;
},
setSelectedMenu(state, action) {
state.selectedMenu = action.payload;
}
},
@@ -60,4 +65,4 @@ const menu = createSlice({
export default menu.reducer;
export const { activeItem, activeComponent, openDrawer, openComponentDrawer, activeID } = menu.actions;
export const { activeItem, activeComponent, openDrawer, openComponentDrawer, activeID, setSelectedMenu } = menu.actions;

View File

@@ -0,0 +1,154 @@
// Vendored from leaflet-polylineoffset@1.1.1 (MIT).
//
// Why this lives in-tree instead of being an npm dep:
// • The published package would require --legacy-peer-deps because of an
// unrelated React-17 peer-dep conflict elsewhere in the project, and we
// don't want a renderer plugin to force a global resolver flag.
// • It's frozen upstream (no meaningful updates since 2020), tiny, and
// has zero runtime deps besides leaflet (already in package.json).
//
// What it does:
// Monkey-patches L.Polyline so that any path passed with a numeric
// `offset` in pathOptions is rendered shifted perpendicular to its
// direction of travel by that many pixels (positive = right of travel,
// negative = left). Used by Dispatch.js's Compare → Combined view to
// render planned + actual as parallel rails when they share the same
// road geometry; without this they overlap and read as one polyline.
//
// Import once for the side effect:
// import '../../../utils/leafletPolylineOffset';
//
// Then add to any pathOptions:
// pathOptions={{ ..., offset: 5 }}
//
// Plays nicely with both SVG and Canvas renderers.
import L from 'leaflet';
L.PolylineOffset = {
translatePoint(pt, dist, radians) {
return L.point(pt.x + dist * Math.cos(radians), pt.y + dist * Math.sin(radians));
},
offsetPointLine(points, distance) {
const l = points.length;
if (l < 2) {
throw new Error('Line should be defined by at least 2 points');
}
let a = points[0];
let b;
const offsetAngle = Math.PI / 2;
const offsetSegments = [];
for (let i = 1; i < l; i++) {
b = points[i];
// Each segment's offset angle is perpendicular to its direction.
const segAngle = Math.atan2(b.y - a.y, b.x - a.x);
offsetSegments.push({
offsetAngle: segAngle - offsetAngle,
original: [a, b],
offset: [
this.translatePoint(a, distance, segAngle - offsetAngle),
this.translatePoint(b, distance, segAngle - offsetAngle)
]
});
a = b;
}
return offsetSegments;
},
// Find the intersection of two segments by extending them to infinity
// along their direction, then walking along segment 1 by parameter t.
// Returns null when the segments are parallel (no intersection).
intersection(l1a, l1b, l2a, l2b) {
const line1 = this.segmentAsVector(l1a, l1b);
const line2 = this.segmentAsVector(l2a, l2b);
const denom = -line2.x * line1.y + line1.x * line2.y;
if (denom === 0) return null;
const s = (-line1.y * (l1a.x - l2a.x) + line1.x * (l1a.y - l2a.y)) / denom;
const t = (line2.x * (l1a.y - l2a.y) - line2.y * (l1a.x - l2a.x)) / denom;
if (s >= 0 && s <= 1 && t >= 0 && t <= 1) {
return L.point(l1a.x + t * line1.x, l1a.y + t * line1.y);
}
return null;
},
segmentAsVector(a, b) {
return L.point(b.x - a.x, b.y - a.y);
},
// Walk the offset segments and join adjacent ones at their intersection
// points (mitered corners). When two consecutive segments don't intersect
// within their bounds (sharp turn, or co-linear), fall back to the offset
// endpoint so the polyline doesn't gap.
joinLineSegments(segments) {
const joined = [];
let last = segments[0].offset;
joined.push(last[0]);
for (let i = 1; i < segments.length; i++) {
const next = segments[i].offset;
const inter = this.intersection(last[0], last[1], next[0], next[1]);
if (inter) {
joined.push(inter);
} else {
joined.push(last[1]);
}
last = next;
}
joined.push(last[1]);
return joined;
},
offsetPoints(points, offset) {
if (!points || points.length < 2) return points;
const offsets = this.offsetPointLine(points, offset);
return this.joinLineSegments(offsets);
},
// Operates on a ring of LatLngs by projecting → offsetting → unprojecting,
// since leaflet polyline math is in screen pixels but our points are LatLng.
offsetLatLngs(map, latlngs, offset) {
const points = latlngs.map((ll) => map.latLngToLayerPoint(ll));
const offsetPts = this.offsetPoints(points, offset);
return offsetPts.map((p) => map.layerPointToLatLng(p));
}
};
// Patch Polyline._projectLatlngs (used by both SVG and Canvas renderers) so
// that when an offset is set, the projected ring is offset before clipping.
// We keep the original on _projectLatlngsOriginal so we can call through.
const originalProject = L.Polyline.prototype._projectLatlngs;
L.Polyline.prototype._projectLatlngs = function patchedProject(latlngs, result, projectedBounds) {
const offset = this.options.offset;
if (!offset || typeof offset !== 'number') {
return originalProject.call(this, latlngs, result, projectedBounds);
}
// Recurse for multi-ring polylines (shouldn't happen for simple lines,
// but the leaflet API allows it).
const flat = latlngs[0] instanceof L.LatLng;
if (!flat) {
for (let i = 0; i < latlngs.length; i++) {
this._projectLatlngs(latlngs[i], result, projectedBounds);
}
return undefined;
}
const projected = latlngs.map((ll) => this._map.latLngToLayerPoint(ll));
const offsetted = L.PolylineOffset.offsetPoints(projected, offset);
// Update projectedBounds with each offset point so the renderer's
// viewport-clipping check still works.
for (let i = 0; i < offsetted.length; i++) {
projectedBounds.extend(offsetted[i]);
}
result.push(offsetted);
return undefined;
};
export default L;

66
src/utils/logger.js Normal file
View File

@@ -0,0 +1,66 @@
const LOG_LEVELS = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
};
// Default log level based on environment
const currentEnv = process.env.NODE_ENV || 'development';
const isDev = currentEnv === 'development';
const GLOBAL_LOG_LEVEL = isDev ? LOG_LEVELS.DEBUG : LOG_LEVELS.WARN;
const style = (bg, color) => `background: ${bg}; color: ${color}; padding: 2px 5px; border-radius: 4px; font-weight: bold;`;
const PREFIX = '%c[NearlExpress]';
const PREFIX_STYLE = style('#2563eb', '#ffffff');
// Capture original console methods before any global overrides occur
const originalLog = console.log;
const originalWarn = console.warn || console.log;
const originalError = console.error || console.log;
const print = (levelName, args, labelStyle) => {
const levelValue = LOG_LEVELS[levelName];
if (levelValue < GLOBAL_LOG_LEVEL) return;
const [message, ...extra] = args;
const isMessageString = typeof message === 'string';
const formatPrefix = `${PREFIX}%c ${levelName}`;
const styles = [PREFIX_STYLE, labelStyle];
const consoleMethod =
levelName === 'ERROR' ? originalError :
levelName === 'WARN' ? originalWarn :
originalLog;
if (isMessageString) {
consoleMethod(
`${formatPrefix}%c ${message}`,
...styles,
'color: inherit;',
...extra
);
} else {
// If first argument is an object/array, preserve raw interactive log
consoleMethod(
`${formatPrefix}`,
...styles,
message,
...extra
);
}
};
const logger = {
debug: (...args) => print('DEBUG', args, style('#64748b', '#ffffff')),
info: (...args) => print('INFO', args, style('#10b981', '#ffffff')),
warn: (...args) => print('WARN', args, style('#f59e0b', '#ffffff')),
error: (...args) => {
print('ERROR', args, style('#ef4444', '#ffffff'));
// Future expansion hook: e.g., Sentry.captureException(args[0]);
},
};
export default logger;

View File

@@ -3054,6 +3054,11 @@ adjust-sourcemap-loader@^4.0.0:
loader-utils "^2.0.0"
regex-parser "^2.2.11"
adler-32@~1.3.0:
version "1.3.1"
resolved "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz"
integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==
agent-base@6:
version "6.0.2"
resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz"
@@ -3858,6 +3863,14 @@ case-sensitive-paths-webpack-plugin@^2.4.0:
resolved "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz"
integrity sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==
cfb@~1.2.1:
version "1.2.2"
resolved "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz"
integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==
dependencies:
adler-32 "~1.3.0"
crc-32 "~1.2.0"
chalk@^2.0.0, chalk@^2.4.1:
version "2.4.2"
resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz"
@@ -4007,6 +4020,11 @@ coa@^2.0.2:
chalk "^2.4.1"
q "^1.1.2"
codepage@~1.15.0:
version "1.15.0"
resolved "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz"
integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==
collect-v8-coverage@^1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz"
@@ -4228,6 +4246,11 @@ country-flag-icons@^1.5.4:
resolved "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.10.tgz"
integrity sha512-x3elaK+ZY23W1YtFsNQknRdURzkV7g3Z93AoA7SHZJUEXbVjRsNh4h9Uf09+OjWF/4u8tXeAt37gezGRdwR/2g==
crc-32@~1.2.0, crc-32@~1.2.1:
version "1.2.2"
resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz"
integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==
create-ecdh@^4.0.0:
version "4.0.4"
resolved "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz"
@@ -5709,6 +5732,11 @@ forwarded@0.2.0:
resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
frac@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz"
integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==
fraction.js@^4.2.0:
version "4.2.0"
resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz"
@@ -5767,11 +5795,6 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
fsevents@^2.3.2, fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
@@ -7922,6 +7945,11 @@ p-try@^2.0.0:
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
papaparse@^5.5.3:
version "5.5.3"
resolved "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz"
integrity sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==
param-case@^3.0.4:
version "3.0.4"
resolved "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz"
@@ -10161,6 +10189,13 @@ sprintf-js@~1.0.2:
resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
ssf@~0.11.2:
version "0.11.2"
resolved "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz"
integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==
dependencies:
frac "~1.1.2"
stable@^0.1.8:
version "0.1.8"
resolved "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz"
@@ -11144,11 +11179,21 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
wmf@~1.0.1:
version "1.0.2"
resolved "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz"
integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==
word-wrap@^1.2.3, word-wrap@~1.2.3:
version "1.2.3"
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
word@~0.3.0:
version "0.3.0"
resolved "https://registry.npmjs.org/word/-/word-0.3.0.tgz"
integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==
workbox-background-sync@6.5.4:
version "6.5.4"
resolved "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz"
@@ -11352,6 +11397,19 @@ ws@^8.4.2:
resolved "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz"
integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==
xlsx@^0.18.5:
version "0.18.5"
resolved "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz"
integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==
dependencies:
adler-32 "~1.3.0"
cfb "~1.2.1"
codepage "~1.15.0"
crc-32 "~1.2.1"
ssf "~0.11.2"
wmf "~1.0.1"
word "~0.3.0"
xml-name-validator@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz"