Initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
README.md
Normal file
8
README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
38
eslint.config.js
Normal file
38
eslint.config.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
ignores: ['dist'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: { react: { version: '18.3' } },
|
||||||
|
plugins: {
|
||||||
|
react,
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
...react.configs['jsx-runtime'].rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react/jsx-no-target-blank': 'off',
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
21
index.html
Normal file
21
index.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/preloader.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#C01227" />
|
||||||
|
<meta name="description" content="Doormile — Operations Hub Console" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800&family=Inter:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Doormile Hub Console</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5459
package-lock.json
generated
Normal file
5459
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "doormile-hub",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^9.1.1",
|
||||||
|
"@mui/material": "^9.1.1",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-router-dom": "^6.30.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.8.0",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"eslint": "^9.8.0",
|
||||||
|
"eslint-plugin-react": "^7.35.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.9",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/Doormile-logo.png
Normal file
BIN
public/Doormile-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
public/preloader.png
Normal file
BIN
public/preloader.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
49
src/App.jsx
Normal file
49
src/App.jsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Suspense, lazy } from 'react';
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Box, CircularProgress } from '@mui/material';
|
||||||
|
|
||||||
|
import MainLayout from '@/layout/MainLayout';
|
||||||
|
import MinimalLayout from '@/layout/MinimalLayout';
|
||||||
|
|
||||||
|
const load = (factory) => {
|
||||||
|
const C = lazy(factory);
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||||
|
<CircularProgress color="primary" />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<C />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Shell pages */}
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
|
||||||
|
<Route path="/inbound" element={load(() => import('@/pages/operations/Inbound'))} />
|
||||||
|
<Route path="/routing" element={load(() => import('@/pages/operations/Routing'))} />
|
||||||
|
<Route path="/dispatch" element={load(() => import('@/pages/operations/Dispatch'))} />
|
||||||
|
<Route path="/inventory" element={load(() => import('@/pages/operations/Inventory'))} />
|
||||||
|
<Route path="/tracking" element={load(() => import('@/pages/operations/TrackingMap'))} />
|
||||||
|
<Route path="/assignments" element={load(() => import('@/pages/operations/OrderAssignment'))} />
|
||||||
|
<Route path="/riders" element={load(() => import('@/pages/operations/Riders'))} />
|
||||||
|
<Route path="/rider-routes" element={load(() => import('@/pages/operations/RiderRoutes'))} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Full-bleed pages */}
|
||||||
|
<Route element={<MinimalLayout />}>
|
||||||
|
<Route path="/login" element={load(() => import('@/pages/auth/Login'))} />
|
||||||
|
<Route path="/signup" element={load(() => import('@/pages/auth/Signup'))} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
51
src/components/Logo.jsx
Normal file
51
src/components/Logo.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
// ==============================|| DOORMILE WORDMARK LOGO ||============================== //
|
||||||
|
// Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red
|
||||||
|
// surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the
|
||||||
|
// collapsed sidebar) renders just the square "D" badge, since the wordmark won't fit.
|
||||||
|
|
||||||
|
const LOGO_SRC = '/Doormile-logo.png';
|
||||||
|
|
||||||
|
export default function Logo({ onDark = false, compact = false, height = 26, sx }) {
|
||||||
|
if (compact) {
|
||||||
|
const mark = onDark ? '#FFFFFF' : '#C01227';
|
||||||
|
const markText = onDark ? '#C01227' : '#FFFFFF';
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: mark,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: onDark ? 'none' : '0 4px 10px rgba(192, 18, 39, 0.30)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ color: markText, fontWeight: 800, fontSize: '1.25rem', lineHeight: 1 }}>D</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={LOGO_SRC}
|
||||||
|
alt="Doormile"
|
||||||
|
sx={{
|
||||||
|
height,
|
||||||
|
width: 'auto',
|
||||||
|
display: 'block',
|
||||||
|
// The asset is white; on light surfaces recolour it to brand red #C01227.
|
||||||
|
filter: 'brightness(0) saturate(100%) invert(15%) sepia(88%) saturate(5900%) hue-rotate(352deg) brightness(86%) contrast(92%)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/PageHeader.jsx
Normal file
81
src/components/PageHeader.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Box, Stack, Typography, Avatar } from '@mui/material';
|
||||||
|
|
||||||
|
// ==============================|| SHARED PAGE HEADER ||============================== //
|
||||||
|
|
||||||
|
export default function PageHeader({ icon: Icon, title, subtitle, action }) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap={3}
|
||||||
|
sx={{ mb: { xs: 4, md: 4.5 } }}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={2.5} // Space between icon and title
|
||||||
|
sx={{ minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<Avatar
|
||||||
|
variant="rounded"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'primary.lighter',
|
||||||
|
color: 'primary.main',
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon sx={{ fontSize: 28 }} />
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
color: 'text.primary',
|
||||||
|
letterSpacing: '-0.5px',
|
||||||
|
lineHeight: 1.15,
|
||||||
|
fontSize: {
|
||||||
|
xs: '1.5rem',
|
||||||
|
sm: '1.8rem',
|
||||||
|
md: '2.1rem'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{subtitle && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
maxWidth: 700,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
fontSize: {
|
||||||
|
xs: '0.9rem',
|
||||||
|
sm: '0.95rem'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{action && (
|
||||||
|
<Box sx={{ flexShrink: 0 }}>
|
||||||
|
{action}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
516
src/layout/MainLayout/Header.jsx
Normal file
516
src/layout/MainLayout/Header.jsx
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
IconButton,
|
||||||
|
Box,
|
||||||
|
InputBase,
|
||||||
|
Badge,
|
||||||
|
Avatar,
|
||||||
|
Typography,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Divider,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Tooltip,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Grid,
|
||||||
|
alpha,
|
||||||
|
InputAdornment
|
||||||
|
} from '@mui/material';
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
||||||
|
import ChatIcon from '@mui/icons-material/Chat';
|
||||||
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
|
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
|
||||||
|
import AcUnitIcon from '@mui/icons-material/AcUnit';
|
||||||
|
import TwoWheelerIcon from '@mui/icons-material/TwoWheeler';
|
||||||
|
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
||||||
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
const RED = '#C01227';
|
||||||
|
|
||||||
|
const INITIAL_NOTIFICATIONS = [
|
||||||
|
{ id: 1, icon: LocalShippingOutlinedIcon, title: 'Linehaul MH-04-AX-8822 from Mumbai Hub arrived', time: '5 min ago', to: '/inbound', read: false },
|
||||||
|
{ id: 2, icon: QrCodeScannerIcon, title: 'Manifest #MNF-2940 sorted & sealed', time: '15 min ago', to: '/dispatch', read: false },
|
||||||
|
{ id: 3, icon: TwoWheelerIcon, title: 'Last-mile Miler Deepak went online', time: '30 min ago', to: '/dispatch', read: false },
|
||||||
|
{ id: 4, icon: AcUnitIcon, title: 'Cold Storage Zone C temperature stabilized at 4.2°C', time: '1 hr ago', to: '/inventory', read: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
const MESSAGES = [
|
||||||
|
{ id: 1, name: 'Devendra (Gate Supervisor)', text: 'Jaipur vehicle is backing into Bay 4 now.', time: '3 min ago', initials: 'DS' },
|
||||||
|
{ id: 2, name: 'Suresh (Sorter Zone A)', text: 'Completed sorting for South Delhi batch. Ready to verify.', time: '12 min ago', initials: 'SS' },
|
||||||
|
{ id: 3, name: 'Neha (Last-mile Dispatcher)', text: 'We need 2 more milers for South Delhi route.', time: '45 min ago', initials: 'ND' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const NOTIFICATION_DETAILS = {
|
||||||
|
1: {
|
||||||
|
title: 'Linehaul Arrival: Mumbai Hub',
|
||||||
|
desc: 'Linehaul vehicle MH-04-AX-8822 has checked in at gate terminal Bay 4. Manifest includes 480 mixed corporate shipments.',
|
||||||
|
stats: [
|
||||||
|
{ label: 'Carrier Plate', value: 'MH-04-AX-8822' },
|
||||||
|
{ label: 'Origin Node', value: 'Mumbai Hub (BOM-02)' },
|
||||||
|
{ label: 'Inbound Sorters', value: 'Allocated (Zone A & C)' },
|
||||||
|
{ label: 'Cold Chain items', value: '18 items binned' }
|
||||||
|
],
|
||||||
|
actionText: 'Go to Inbound Station',
|
||||||
|
to: '/inbound'
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
title: 'Sealed Outbound Manifest #MNF-2940',
|
||||||
|
desc: 'Sorting manifest #MNF-2940 has been closed, sealed, and audited by supervisor Suresh. Packages are loaded in transit cart.',
|
||||||
|
stats: [
|
||||||
|
{ label: 'Manifest ID', value: 'MNF-2940' },
|
||||||
|
{ label: 'Route Zone', value: 'West Delhi (Dwarka)' },
|
||||||
|
{ label: 'Packages Count', value: '42 items loaded' },
|
||||||
|
{ label: 'Seal ID Code', value: 'SEAL-DEL-98402' }
|
||||||
|
],
|
||||||
|
actionText: 'Go to Outbound Dispatch',
|
||||||
|
to: '/dispatch'
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
title: 'Miler Deepak Sharma Online',
|
||||||
|
desc: 'Last-mile EV miler Deepak Sharma logged into transit terminal app. Courier battery capacity 98% (Fully Charged).',
|
||||||
|
stats: [
|
||||||
|
{ label: 'Miler Name', value: 'Deepak Sharma' },
|
||||||
|
{ label: 'Vehicle Class', value: 'EV Two-wheeler' },
|
||||||
|
{ label: 'Capacity Limit', value: '35 kg' },
|
||||||
|
{ label: 'Assigned Route', value: 'South Delhi (Saket)' }
|
||||||
|
],
|
||||||
|
actionText: 'Go to Outbound Dispatch',
|
||||||
|
to: '/dispatch'
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
title: 'Cold Storage Compliance Logged',
|
||||||
|
desc: 'Sensor Zone C logged audit snapshot at 12:00 PM. Climate check parameters successfully verified at 4.2°C.',
|
||||||
|
stats: [
|
||||||
|
{ label: 'Chamber Area', value: 'Cold Zone Chamber C' },
|
||||||
|
{ label: 'Sensor Reading', value: '4.2°C (Target: 2-8°C)' },
|
||||||
|
{ label: 'Log ID', value: 'LOG-C-98248102' },
|
||||||
|
{ label: 'Status', value: 'Optimal Compliance' }
|
||||||
|
],
|
||||||
|
actionText: 'Go to Storage & Bins',
|
||||||
|
to: '/inventory'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_CHATS = {
|
||||||
|
1: {
|
||||||
|
name: 'Devendra (Gate Supervisor)',
|
||||||
|
initials: 'DS',
|
||||||
|
chat: [
|
||||||
|
{ sender: 'them', text: 'Jaipur vehicle is backing into Bay 4 now. Sorters are ready.', time: '11:20 AM' },
|
||||||
|
{ sender: 'me', text: 'Excellent. Please verify temperature logs for the cold container immediately upon opening.', time: '11:21 AM' },
|
||||||
|
{ sender: 'them', text: 'Got it, Rajesh. Handled by sorter Devendra.', time: '11:22 AM' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
name: 'Suresh (Sorter Zone A)',
|
||||||
|
initials: 'SS',
|
||||||
|
chat: [
|
||||||
|
{ sender: 'them', text: 'Completed sorting for South Delhi batch. Ready to verify.', time: '11:10 AM' },
|
||||||
|
{ sender: 'me', text: 'Have all exceptions been routed to Zone D?', time: '11:12 AM' },
|
||||||
|
{ sender: 'them', text: 'Yes, 3 damaged boxes are in Zone D. Rest are loaded in the manifest bags.', time: '11:15 AM' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
name: 'Neha (Last-mile Dispatcher)',
|
||||||
|
initials: 'ND',
|
||||||
|
chat: [
|
||||||
|
{ sender: 'them', text: 'We need 2 more milers for South Delhi route. Surge in fashion orders.', time: '10:40 AM' },
|
||||||
|
{ sender: 'me', text: 'Let me ping Deepak and Yashpal to see if they can start early shift.', time: '10:45 AM' },
|
||||||
|
{ sender: 'them', text: 'Deepak just logged online. Still need one more.', time: '11:05 AM' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Header({ onToggle }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [account, setAccount] = useState(null);
|
||||||
|
const [notifAnchor, setNotifAnchor] = useState(null);
|
||||||
|
const [msgAnchor, setMsgAnchor] = useState(null);
|
||||||
|
|
||||||
|
// Dialog State
|
||||||
|
const [selectedNotif, setSelectedNotif] = useState(null);
|
||||||
|
const [selectedChatId, setSelectedChatId] = useState(null);
|
||||||
|
const [chats, setChats] = useState(INITIAL_CHATS);
|
||||||
|
const [typedMessage, setTypedMessage] = useState('');
|
||||||
|
|
||||||
|
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const unread = notifications.filter((n) => !n.read).length;
|
||||||
|
|
||||||
|
const openNotif = (e) => setNotifAnchor(e.currentTarget);
|
||||||
|
const closeNotif = () => setNotifAnchor(null);
|
||||||
|
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
|
||||||
|
const onNotifClick = (n) => {
|
||||||
|
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
|
||||||
|
closeNotif();
|
||||||
|
if (NOTIFICATION_DETAILS[n.id]) {
|
||||||
|
setSelectedNotif(NOTIFICATION_DETAILS[n.id]);
|
||||||
|
} else {
|
||||||
|
navigate(n.to);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessageClick = (mId) => {
|
||||||
|
setMsgAnchor(null);
|
||||||
|
setSelectedChatId(mId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (!typedMessage.trim() || !selectedChatId) return;
|
||||||
|
const now = new Date();
|
||||||
|
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
setChats((prev) => {
|
||||||
|
const activeChat = prev[selectedChatId];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[selectedChatId]: {
|
||||||
|
...activeChat,
|
||||||
|
chat: [
|
||||||
|
...activeChat.chat,
|
||||||
|
{ sender: 'me', text: typedMessage, time: timeStr }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setTypedMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitSearch = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = search.trim();
|
||||||
|
if (q) navigate(`/routing?q=${encodeURIComponent(q)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#FFFFFF',
|
||||||
|
color: 'text.primary',
|
||||||
|
zIndex: (t) => t.zIndex.drawer + 1,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'grey.200'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}>
|
||||||
|
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{/* Brand wordmark — left side */}
|
||||||
|
<Box
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Logo height={22} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={submitSearch}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', sm: 'flex' },
|
||||||
|
alignItems: 'center',
|
||||||
|
bgcolor: 'grey.100',
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.5,
|
||||||
|
width: { sm: 240, md: 320 },
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
'&:hover': { bgcolor: 'grey.200' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'text.secondary' }} />
|
||||||
|
<InputBase
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Scan package, check destination…"
|
||||||
|
sx={{ fontSize: '0.875rem', flex: 1 }}
|
||||||
|
inputProps={{ 'aria-label': 'search' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Tooltip title="Messages">
|
||||||
|
<IconButton color="inherit" onClick={(e) => setMsgAnchor(e.currentTarget)}>
|
||||||
|
<Badge badgeContent={MESSAGES.length} color="error">
|
||||||
|
<ChatIcon />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Notifications">
|
||||||
|
<IconButton color="inherit" onClick={openNotif}>
|
||||||
|
<Badge badgeContent={unread} color="error">
|
||||||
|
<NotificationsNoneIcon />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
onClick={(e) => setAccount(e.currentTarget)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
ml: 0.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
py: 0.5,
|
||||||
|
px: 1,
|
||||||
|
borderRadius: 2,
|
||||||
|
'&:hover': { bgcolor: 'grey.100' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>RK</Avatar>
|
||||||
|
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
Rajesh Kumar
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
Delhi Hub Manager
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Notifications dropdown */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={notifAnchor}
|
||||||
|
open={Boolean(notifAnchor)}
|
||||||
|
onClose={closeNotif}
|
||||||
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
|
PaperProps={{ sx: { mt: 1, width: 360, maxWidth: '90vw' } }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.25 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
Notifications
|
||||||
|
</Typography>
|
||||||
|
<Button size="small" startIcon={<DoneAllIcon fontSize="small" />} onClick={markAllRead} disabled={unread === 0}>
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
{notifications.length === 0 && (
|
||||||
|
<MenuItem disabled>
|
||||||
|
<ListItemText primary="No notifications" />
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{notifications.map((n) => {
|
||||||
|
const Icon = n.icon;
|
||||||
|
return (
|
||||||
|
<MenuItem key={n.id} onClick={() => onNotifClick(n)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
||||||
|
<ListItemIcon sx={{ mt: 0.25 }}>
|
||||||
|
<Avatar sx={{ width: 34, height: 34, bgcolor: n.read ? 'grey.200' : alpha(RED, 0.12), color: RED }}>
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={n.title}
|
||||||
|
secondary={n.time}
|
||||||
|
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: n.read ? 500 : 700 }}
|
||||||
|
secondaryTypographyProps={{ fontSize: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
{!n.read && <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: RED, mt: 1, ml: 0.5 }} />}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* Messages dropdown */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={msgAnchor}
|
||||||
|
open={Boolean(msgAnchor)}
|
||||||
|
onClose={() => setMsgAnchor(null)}
|
||||||
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
|
PaperProps={{ sx: { mt: 1, width: 340, maxWidth: '90vw' } }}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, px: 2, py: 1.25 }}>
|
||||||
|
Messages
|
||||||
|
</Typography>
|
||||||
|
<Divider />
|
||||||
|
{MESSAGES.map((m) => (
|
||||||
|
<MenuItem key={m.id} onClick={() => onMessageClick(m.id)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
||||||
|
<ListItemIcon sx={{ mt: 0.25 }}>
|
||||||
|
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
|
||||||
|
{m.initials}
|
||||||
|
</Avatar>
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={m.name}
|
||||||
|
secondary={m.text}
|
||||||
|
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 700 }}
|
||||||
|
secondaryTypographyProps={{ fontSize: '0.8rem' }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, mt: 0.5, flexShrink: 0 }}>
|
||||||
|
{m.time}
|
||||||
|
</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* Account dropdown */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={account}
|
||||||
|
open={Boolean(account)}
|
||||||
|
onClose={() => setAccount(null)}
|
||||||
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
|
PaperProps={{ sx: { mt: 1, minWidth: 200 } }}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={() => { setAccount(null); navigate('/login'); }} sx={{ color: 'error.main' }}>
|
||||||
|
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
|
||||||
|
Logout
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{/* High Fidelity Notification Detail Dialog */}
|
||||||
|
<Dialog open={Boolean(selectedNotif)} onClose={() => setSelectedNotif(null)} fullWidth maxWidth="sm">
|
||||||
|
{selectedNotif && (
|
||||||
|
<>
|
||||||
|
<DialogTitle sx={{ fontWeight: 700, bgcolor: 'grey.50', py: 2 }}>
|
||||||
|
{selectedNotif.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<Divider />
|
||||||
|
<DialogContent sx={{ py: 3 }}>
|
||||||
|
<Typography variant="body1" sx={{ mb: 3 }}>
|
||||||
|
{selectedNotif.desc}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'text.secondary' }}>
|
||||||
|
Operational Details
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{selectedNotif.stats.map((stat, index) => (
|
||||||
|
<Grid size={{ xs: 6 }} key={index}>
|
||||||
|
<Box sx={{ p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'grey.200' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontWeight: 600 }}>
|
||||||
|
{stat.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mt: 0.5 }}>
|
||||||
|
{stat.value}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<Divider />
|
||||||
|
<DialogActions sx={{ p: 2 }}>
|
||||||
|
<Button onClick={() => setSelectedNotif(null)}>Dismiss</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
endIcon={<ArrowForwardIcon fontSize="small" />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedNotif(null);
|
||||||
|
navigate(selectedNotif.to);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedNotif.actionText}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* High Fidelity Chat Message Dialog */}
|
||||||
|
<Dialog open={Boolean(selectedChatId)} onClose={() => setSelectedChatId(null)} fullWidth maxWidth="xs">
|
||||||
|
{selectedChatId && chats[selectedChatId] && (
|
||||||
|
<>
|
||||||
|
<DialogTitle sx={{ fontWeight: 700, py: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Avatar sx={{ bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, width: 34, height: 34 }}>
|
||||||
|
{chats[selectedChatId].initials}
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||||
|
{chats[selectedChatId].name}
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
<Divider />
|
||||||
|
<DialogContent sx={{ p: 2, bgcolor: 'grey.50', height: 280, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||||
|
<Stack spacing={2} sx={{ overflowY: 'auto', pr: 0.5, flexGrow: 1, mb: 2 }}>
|
||||||
|
{chats[selectedChatId].chat.map((msg, idx) => (
|
||||||
|
<Box
|
||||||
|
key={idx}
|
||||||
|
sx={{
|
||||||
|
alignSelf: msg.sender === 'me' ? 'flex-end' : 'flex-start',
|
||||||
|
maxWidth: '80%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: msg.sender === 'me' ? RED : '#FFFFFF',
|
||||||
|
color: msg.sender === 'me' ? '#FFFFFF' : 'text.primary',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||||
|
border: msg.sender === 'me' ? 'none' : '1px solid',
|
||||||
|
borderColor: 'grey.200'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{msg.text}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
mt: 0.5,
|
||||||
|
textAlign: msg.sender === 'me' ? 'right' : 'left'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.time}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<Divider />
|
||||||
|
<DialogActions sx={{ p: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Type your message..."
|
||||||
|
value={typedMessage}
|
||||||
|
onChange={(e) => setTypedMessage(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={handleSendMessage} size="small" color="primary">
|
||||||
|
<SendIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogActions>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
src/layout/MainLayout/Sidebar.jsx
Normal file
310
src/layout/MainLayout/Sidebar.jsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Box,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
Collapse,
|
||||||
|
Tooltip,
|
||||||
|
Toolbar
|
||||||
|
} from '@mui/material';
|
||||||
|
import { alpha } from '@mui/material/styles';
|
||||||
|
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||||
|
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||||
|
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||||
|
|
||||||
|
import navItems from '@/menu/navItems';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
export const DRAWER_WIDTH = 240;
|
||||||
|
export const MINI_WIDTH = 72;
|
||||||
|
|
||||||
|
const BRAND_RED = '#C01227';
|
||||||
|
|
||||||
|
function NavLeaf({ item, open, active, depth = 0, onClick }) {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<ListItemButton
|
||||||
|
selected={active}
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{
|
||||||
|
minHeight: 44,
|
||||||
|
my: 0.25,
|
||||||
|
mx: 1.25,
|
||||||
|
px: open ? 1.5 : 0,
|
||||||
|
justifyContent: open ? 'flex-start' : 'center',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: active ? BRAND_RED : 'text.primary',
|
||||||
|
transition: (theme) => theme.transitions.create(['background-color', 'color', 'padding'], {
|
||||||
|
duration: theme.transitions.duration.shorter,
|
||||||
|
}),
|
||||||
|
'& .MuiListItemIcon-root': {
|
||||||
|
color: active ? BRAND_RED : 'text.secondary',
|
||||||
|
minWidth: open ? 32 : 0,
|
||||||
|
justifyContent: 'center'
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
color: 'text.primary',
|
||||||
|
'& .MuiListItemIcon-root': { color: 'text.primary' }
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: alpha(BRAND_RED, 0.08),
|
||||||
|
color: BRAND_RED,
|
||||||
|
'& .MuiListItemIcon-root': { color: BRAND_RED },
|
||||||
|
'&:hover': { bgcolor: alpha(BRAND_RED, 0.12) }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
{depth > 0 && !Icon ? (
|
||||||
|
<FiberManualRecordIcon sx={{ fontSize: 6 }} />
|
||||||
|
) : Icon ? (
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
) : null}
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<ListItemText
|
||||||
|
primary={item.title}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
noWrap: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={item.title} placement="right" arrow disableInteractive>
|
||||||
|
{button}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const expanded = open || isMobile;
|
||||||
|
|
||||||
|
const isActive = (url) => !!(url && location.pathname.startsWith(url));
|
||||||
|
|
||||||
|
// Memoize initial open state to prevent recalculation on every drawer toggle
|
||||||
|
const initialOpen = useMemo(() => {
|
||||||
|
return navItems
|
||||||
|
.flatMap((g) => g.items)
|
||||||
|
.filter((i) => i.children && i.children.some((c) => isActive(c.url)))
|
||||||
|
.map((i) => i.id);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const [collapse, setCollapse] = useState(initialOpen);
|
||||||
|
|
||||||
|
const handleToggleCollapse = (id) => {
|
||||||
|
setCollapse((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const go = (url) => {
|
||||||
|
navigate(url);
|
||||||
|
if (isMobile) onMobileClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarContent = (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRight: '1px solid',
|
||||||
|
borderColor: 'divider'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top Branding Section */}
|
||||||
|
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
|
||||||
|
<Logo compact={!expanded} />
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{/* Navigation Scroll Area */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
flexGrow: 1,
|
||||||
|
pb: 2,
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
'&::-webkit-scrollbar': { width: 5 },
|
||||||
|
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
'&:hover::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: 'action.focus',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{navItems.map((grp) => (
|
||||||
|
<Box key={grp.group} sx={{ mt: 2.5 }}>
|
||||||
|
{expanded && (
|
||||||
|
<Typography
|
||||||
|
variant="overline"
|
||||||
|
sx={{
|
||||||
|
px: 2.5,
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '0.6875rem',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{grp.group}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<List disablePadding sx={{ mt: 0.5 }}>
|
||||||
|
{grp.items.map((item) => {
|
||||||
|
if (item.children) {
|
||||||
|
const opened = collapse.includes(item.id);
|
||||||
|
const childActive = item.children.some((c) => isActive(c.url));
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
const headerButton = (
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() => expanded ? handleToggleCollapse(item.id) : go(item.children[0].url)}
|
||||||
|
sx={{
|
||||||
|
minHeight: 44,
|
||||||
|
my: 0.25,
|
||||||
|
mx: 1.25,
|
||||||
|
px: expanded ? 1.5 : 0,
|
||||||
|
justifyContent: expanded ? 'flex-start' : 'center',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'text.primary',
|
||||||
|
bgcolor: childActive && !opened ? alpha(BRAND_RED, 0.04) : 'transparent',
|
||||||
|
'& .MuiListItemIcon-root': {
|
||||||
|
color: childActive ? BRAND_RED : 'text.secondary',
|
||||||
|
minWidth: expanded ? 32 : 0,
|
||||||
|
justifyContent: 'center'
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
'& .MuiListItemIcon-root': { color: 'text.primary' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
{expanded && (
|
||||||
|
<>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.title}
|
||||||
|
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: childActive ? 600 : 500 }}
|
||||||
|
/>
|
||||||
|
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={item.id}>
|
||||||
|
{expanded ? headerButton : <Tooltip title={item.title} placement="right" arrow>{headerButton}</Tooltip>}
|
||||||
|
{expanded && (
|
||||||
|
<Collapse in={opened} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ mt: 0.25 }}>
|
||||||
|
{item.children.map((c) => (
|
||||||
|
<NavLeaf
|
||||||
|
key={c.id}
|
||||||
|
item={c}
|
||||||
|
open
|
||||||
|
depth={1}
|
||||||
|
active={isActive(c.url)}
|
||||||
|
onClick={() => go(c.url)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLeaf
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
open={expanded}
|
||||||
|
active={isActive(item.url)}
|
||||||
|
onClick={() => go(item.url)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom Footer Branding */}
|
||||||
|
{expanded && (
|
||||||
|
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider', bgcolor: 'background.default' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.primary', fontWeight: 600, display: 'block', lineHeight: 1.3 }}>
|
||||||
|
Hub Control Panel
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||||
|
Doormile Logistics · v1.0
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={onMobileClose}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
sx={{ '& .MuiDrawer-paper': { width: DRAWER_WIDTH, border: 'none' } }}
|
||||||
|
>
|
||||||
|
{sidebarContent}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||||
|
border: 'none',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
transition: (theme) => theme.transitions.create('width', {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.standard,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
|
{sidebarContent}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/layout/MainLayout/index.jsx
Normal file
56
src/layout/MainLayout/index.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Box, useMediaQuery } from '@mui/material';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
import Header from './Header';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
|
||||||
|
export default function MainLayout() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (isMobile) setMobileOpen((p) => !p);
|
||||||
|
else setOpen((p) => !p);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}>
|
||||||
|
<Header onToggle={toggle} />
|
||||||
|
<Sidebar
|
||||||
|
open={open}
|
||||||
|
isMobile={isMobile}
|
||||||
|
mobileOpen={mobileOpen}
|
||||||
|
onMobileClose={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transition: theme.transitions.create('width', { duration: theme.transitions.duration.standard })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ height: 64, flexShrink: 0 }} />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
px: { xs: 1.5, sm: 2.5, md: 3.5 },
|
||||||
|
py: { xs: 2, sm: 2.5, md: 3 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/layout/MinimalLayout.jsx
Normal file
11
src/layout/MinimalLayout.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
|
// Used by auth + maintenance pages — full-bleed, no shell.
|
||||||
|
export default function MinimalLayout() {
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/main.jsx
Normal file
18
src/main.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||||
|
|
||||||
|
import theme from '@/theme';
|
||||||
|
import App from '@/App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
56
src/menu/navItems.jsx
Normal file
56
src/menu/navItems.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import SpaceDashboardRoundedIcon from '@mui/icons-material/SpaceDashboardRounded';
|
||||||
|
import MapRoundedIcon from '@mui/icons-material/MapRounded';
|
||||||
|
import HailRoundedIcon from '@mui/icons-material/HailRounded';
|
||||||
|
import MoveToInboxRoundedIcon from '@mui/icons-material/MoveToInboxRounded';
|
||||||
|
import AltRouteRoundedIcon from '@mui/icons-material/AltRouteRounded';
|
||||||
|
import WarehouseRoundedIcon from '@mui/icons-material/WarehouseRounded';
|
||||||
|
import LocalShippingRoundedIcon from '@mui/icons-material/LocalShippingRounded';
|
||||||
|
import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded';
|
||||||
|
|
||||||
|
// ==============================|| DOORMILE HUB NAVIGATION ITEMS ||============================== //
|
||||||
|
// Menu follows the parcel's real journey in plain language so any hub staff member
|
||||||
|
// can follow it top-to-bottom: Pick up → Receive → Sort & Store → Send out.
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
group: 'Overview',
|
||||||
|
items: [
|
||||||
|
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: SpaceDashboardRoundedIcon },
|
||||||
|
{ id: 'tracking', title: 'Live Map', url: '/tracking', icon: MapRoundedIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: '1. Pick Up',
|
||||||
|
items: [
|
||||||
|
{ id: 'assignments', title: 'Pickup Requests', url: '/assignments', icon: HailRoundedIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: '2. Receive at Hub',
|
||||||
|
items: [
|
||||||
|
{ id: 'inbound', title: 'Receive Parcels', url: '/inbound', icon: MoveToInboxRoundedIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: '3. Sort & Store',
|
||||||
|
items: [
|
||||||
|
{ id: 'routing', title: 'Where Does It Go?', url: '/routing', icon: AltRouteRoundedIcon },
|
||||||
|
{ id: 'inventory', title: 'Storage Shelves', url: '/inventory', icon: WarehouseRoundedIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: '4. Send Out',
|
||||||
|
items: [
|
||||||
|
{ id: 'dispatch', title: 'Dispatch & Transfer', url: '/dispatch', icon: LocalShippingRoundedIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Team',
|
||||||
|
items: [
|
||||||
|
{ id: 'riders', title: 'Milers', url: '/riders', icon: DeliveryDiningRoundedIcon },
|
||||||
|
{ id: 'rider-routes', title: 'Rider Routes', url: '/rider-routes', icon: AltRouteRoundedIcon }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default navItems;
|
||||||
585
src/pages/Dashboard.jsx
Normal file
585
src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Chip,
|
||||||
|
LinearProgress,
|
||||||
|
IconButton,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Popover
|
||||||
|
} from '@mui/material';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { alpha } from '@mui/material/styles';
|
||||||
|
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
|
||||||
|
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||||
|
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import ElectricBoltIcon from '@mui/icons-material/ElectricBolt';
|
||||||
|
import DynamicFeedIcon from '@mui/icons-material/DynamicFeed';
|
||||||
|
import SpeedIcon from '@mui/icons-material/Speed';
|
||||||
|
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
|
||||||
|
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||||
|
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
|
||||||
|
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
||||||
|
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
||||||
|
|
||||||
|
const BRAND = '#C01227';
|
||||||
|
const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||||
|
|
||||||
|
// ── Doormile-themed range calendar ───────────────────────────────────────────────
|
||||||
|
// Click a day to set the start, click again to set the end (auto-swaps if reversed).
|
||||||
|
// In-range days get a soft red wash; the two endpoints are solid brand red.
|
||||||
|
function RangeCalendar({ from, to, maxDate, onSelect }) {
|
||||||
|
const [view, setView] = useState(dayjs(to || from || undefined).startOf('month'));
|
||||||
|
const [anchorDate, setAnchorDate] = useState(null); // first click while picking a new range
|
||||||
|
|
||||||
|
const start = from ? dayjs(from) : null;
|
||||||
|
const end = to ? dayjs(to) : null;
|
||||||
|
const max = maxDate ? dayjs(maxDate) : null;
|
||||||
|
|
||||||
|
const gridStart = view.startOf('month').subtract(view.startOf('month').day(), 'day');
|
||||||
|
const cells = Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day'));
|
||||||
|
|
||||||
|
const handleDay = (d) => {
|
||||||
|
if (!anchorDate) {
|
||||||
|
setAnchorDate(d);
|
||||||
|
onSelect(d.format(DATE_FMT), d.format(DATE_FMT));
|
||||||
|
} else {
|
||||||
|
const a = anchorDate;
|
||||||
|
const lo = d.isBefore(a) ? d : a;
|
||||||
|
const hi = d.isBefore(a) ? a : d;
|
||||||
|
onSelect(lo.format(DATE_FMT), hi.format(DATE_FMT));
|
||||||
|
setAnchorDate(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2, width: 320 }}>
|
||||||
|
{/* Month header */}
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1.5 }}>
|
||||||
|
<IconButton size="small" onClick={() => setView((v) => v.subtract(1, 'month'))} sx={{ color: '#5F6368' }}>
|
||||||
|
<ChevronLeftRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography sx={{ fontWeight: 800, color: '#212529' }}>{view.format('MMMM YYYY')}</Typography>
|
||||||
|
<IconButton size="small" onClick={() => setView((v) => v.add(1, 'month'))} sx={{ color: '#5F6368' }}>
|
||||||
|
<ChevronRightRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Weekday labels */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', mb: 0.5 }}>
|
||||||
|
{DAY_LABELS.map((d, i) => (
|
||||||
|
<Typography key={i} align="center" sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#9AA0A6', py: 0.5 }}>
|
||||||
|
{d}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Days */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', rowGap: 0.25 }}>
|
||||||
|
{cells.map((d) => {
|
||||||
|
const inMonth = d.month() === view.month();
|
||||||
|
const isStart = start && d.isSame(start, 'day');
|
||||||
|
const isEnd = end && d.isSame(end, 'day');
|
||||||
|
const isEndpoint = isStart || isEnd;
|
||||||
|
const inRange = start && end && d.isAfter(start, 'day') && d.isBefore(end, 'day');
|
||||||
|
const isToday = d.isSame(dayjs(), 'day');
|
||||||
|
const disabled = max && d.isAfter(max, 'day');
|
||||||
|
return (
|
||||||
|
<Box key={d.format(DATE_FMT)} sx={{ display: 'flex', justifyContent: 'center', bgcolor: inRange ? alpha(BRAND, 0.08) : 'transparent',
|
||||||
|
borderTopLeftRadius: isStart ? 8 : 0, borderBottomLeftRadius: isStart ? 8 : 0,
|
||||||
|
borderTopRightRadius: isEnd ? 8 : 0, borderBottomRightRadius: isEnd ? 8 : 0 }}>
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleDay(d)}
|
||||||
|
sx={{
|
||||||
|
width: 36, height: 36, m: '2px', border: 'none', cursor: disabled ? 'default' : 'pointer',
|
||||||
|
borderRadius: 2, fontSize: '0.85rem', fontFamily: 'inherit',
|
||||||
|
fontWeight: isEndpoint ? 800 : 600,
|
||||||
|
color: disabled ? '#CED4DA' : isEndpoint ? '#fff' : inMonth ? '#212529' : '#B9BEC4',
|
||||||
|
bgcolor: isEndpoint ? BRAND : 'transparent',
|
||||||
|
boxShadow: isToday && !isEndpoint ? `inset 0 0 0 1.5px ${BRAND}` : 'none',
|
||||||
|
transition: 'background-color .15s',
|
||||||
|
'&:hover': { bgcolor: disabled ? 'transparent' : isEndpoint ? '#9E0E20' : alpha(BRAND, 0.12) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d.date()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-day snapshot for a single hub day. `cumulative` metrics add up across the
|
||||||
|
// selected date range; the rest are "right now" figures that stay as a live count.
|
||||||
|
const STAT_DEFS = [
|
||||||
|
{ label: 'Total Parcels', base: 3142, cumulative: true, icon: DynamicFeedIcon, color: '#1A73E8', sub: 'Handled in range' },
|
||||||
|
{ label: 'Picked Up Locally', base: 1482, cumulative: true, icon: LocalOfferIcon, color: '#1E8E3E', sub: 'Collected by milers' },
|
||||||
|
{ label: 'From Other Cities', base: 1660, cumulative: true, icon: LocalShippingIcon, color: '#1A73E8', sub: 'Arrived by truck' },
|
||||||
|
{ label: 'Ready for Delivery', base: 840, cumulative: false, icon: AssignmentIcon, color: '#00A854', sub: 'Sorted for local areas' },
|
||||||
|
{ label: 'Ready to Transfer', base: 1120, cumulative: false, icon: LocalShippingIcon, color: '#8E24AA', sub: 'Going to other cities' },
|
||||||
|
{ label: 'Out for Delivery', base: 620, cumulative: false, icon: DeliveryDiningIcon, color: '#F29900', sub: 'With milers right now' },
|
||||||
|
{ label: 'Needs Checking', base: 48, cumulative: true, icon: WarningAmberIcon, color: '#D93025', sub: 'Damaged or unclear' },
|
||||||
|
{ label: 'Returns', base: 12, cumulative: true, icon: WarningAmberIcon, color: '#F29900', sub: 'Going back to sender' },
|
||||||
|
{ label: 'Available Milers', base: 24, cumulative: false, icon: InfoOutlinedIcon, color: '#1A73E8', sub: 'Free or on duty' },
|
||||||
|
{ label: 'Batches Going Out', base: 8, cumulative: true, icon: LocalShippingIcon, color: '#1E8E3E', sub: 'Sent in range' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATE_FMT = 'YYYY-MM-DD';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const today = dayjs().format(DATE_FMT);
|
||||||
|
const weekAgo = dayjs().subtract(6, 'day').format(DATE_FMT);
|
||||||
|
const [range, setRange] = useState({ from: weekAgo, to: today });
|
||||||
|
const [calAnchor, setCalAnchor] = useState(null);
|
||||||
|
|
||||||
|
// Inclusive day count for the chosen window (min 1); drives cumulative metrics.
|
||||||
|
const dayCount = useMemo(() => {
|
||||||
|
const from = dayjs(range.from);
|
||||||
|
const to = dayjs(range.to);
|
||||||
|
if (!from.isValid() || !to.isValid() || to.isBefore(from)) return 1;
|
||||||
|
return to.diff(from, 'day') + 1;
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
const invalidRange = dayjs(range.to).isBefore(dayjs(range.from));
|
||||||
|
|
||||||
|
const applyPreset = (days) => {
|
||||||
|
setRange({ from: dayjs().subtract(days - 1, 'day').format(DATE_FMT), to: today });
|
||||||
|
};
|
||||||
|
const isPreset = (days) =>
|
||||||
|
range.to === today && range.from === dayjs().subtract(days - 1, 'day').format(DATE_FMT);
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() =>
|
||||||
|
STAT_DEFS.map((s) => ({
|
||||||
|
...s,
|
||||||
|
value: (s.cumulative ? s.base * dayCount : s.base).toLocaleString('en-IN')
|
||||||
|
})),
|
||||||
|
[dayCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rangeLabel =
|
||||||
|
dayCount === 1
|
||||||
|
? dayjs(range.from).format('DD MMM YYYY')
|
||||||
|
: `${dayjs(range.from).format('DD MMM')} – ${dayjs(range.to).format('DD MMM YYYY')} · ${dayCount} days`;
|
||||||
|
|
||||||
|
const incomingVehicles = [
|
||||||
|
{ id: 'Truck MH-04-8822', origin: 'Mumbai Hub', estTime: 'Arrived (Bay 4)', status: 'Unloading', progress: 85, color: 'success' },
|
||||||
|
{ id: 'Truck RJ-14-1049', origin: 'Jaipur Hub', estTime: '15 min away', status: 'Expected', progress: 0, color: 'info' },
|
||||||
|
{ id: 'Truck KA-03-0284', origin: 'Bengaluru Hub', estTime: '1.5 hrs away', status: 'On the way', progress: 0, color: 'default' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const recentActivity = [
|
||||||
|
{ time: '11:24 AM', type: 'inbound', text: 'Received 142 parcels from the Mumbai truck' },
|
||||||
|
{ time: '11:15 AM', type: 'dispatch', text: 'Batch BATCH-9281 sent out with miler Deepak (West Delhi)' },
|
||||||
|
{ time: '10:50 AM', type: 'exception', text: 'Parcel DM-1005 put on hold (damaged label)' },
|
||||||
|
{ time: '10:30 AM', type: 'sorting', text: 'Cold room temperature checked — all good (4.2°C)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeRoutes = [
|
||||||
|
{ zone: 'West Delhi (Dwarka)', packages: 145, riders: 4, status: 'Active' },
|
||||||
|
{ zone: 'South Delhi (Saket)', packages: 210, riders: 6, status: 'Active' },
|
||||||
|
{ zone: 'East Delhi (Mayur Vihar)', packages: 98, riders: 3, status: 'Need Milers' },
|
||||||
|
{ zone: 'North Delhi (Rohini)', packages: 122, riders: 4, status: 'Active' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Title + date-range filter, all on one line */}
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', md: 'row' }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: 'stretch', md: 'center' }}
|
||||||
|
sx={{ mb: 2.5 }} // Reduced from mb: 4 to tighten vertical spacing
|
||||||
|
gap={1.5}
|
||||||
|
>
|
||||||
|
{/* Left Title Section */}
|
||||||
|
<Box sx={{ flexShrink: 0 }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1E293B', letterSpacing: '-0.02em', mb: 0.5 }}>
|
||||||
|
Delhi Hub
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748B', fontSize: '0.825rem' }}>
|
||||||
|
Showing hub activity for{' '}
|
||||||
|
<Box component="span" sx={{ fontWeight: 600, color: '#0F172A' }}>
|
||||||
|
{rangeLabel}
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Controls Section */}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
flexWrap="wrap"
|
||||||
|
useFlexGap
|
||||||
|
sx={{ flexGrow: 1, minWidth: 0, justifyContent: { xs: 'flex-start', md: 'flex-end' } }}
|
||||||
|
>
|
||||||
|
{/* Refresh Button */}
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
color: '#64748B',
|
||||||
|
'&:hover': { bgcolor: '#F8FAFC', color: '#0F172A' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{/* Quick Presets */}
|
||||||
|
{[
|
||||||
|
{ label: 'Today', days: 1 },
|
||||||
|
{ label: 'Last 7 days', days: 7 },
|
||||||
|
{ label: 'Last 30 days', days: 30 }
|
||||||
|
].map((p) => {
|
||||||
|
const active = isPreset(p.days);
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={p.label}
|
||||||
|
label={p.label}
|
||||||
|
onClick={() => applyPreset(p.days)}
|
||||||
|
sx={{
|
||||||
|
height: 36,
|
||||||
|
fontSize: '0.825rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: active ? BRAND : '#F1F5F9',
|
||||||
|
color: active ? '#ffffff' : '#475569',
|
||||||
|
boxShadow: active ? '0 4px 10px rgba(192,18,39,0.15)' : 'none',
|
||||||
|
'& .MuiChip-label': { px: 1.5 },
|
||||||
|
'&:hover': { bgcolor: active ? '#9E0E20' : '#E2E8F0' }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Range Trigger */}
|
||||||
|
<Button
|
||||||
|
onClick={(e) => setCalAnchor(e.currentTarget)}
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<CalendarTodayOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||||
|
sx={{
|
||||||
|
height: 36,
|
||||||
|
px: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.825rem',
|
||||||
|
color: '#334155',
|
||||||
|
borderColor: invalidRange ? '#EF4444' : '#E2E8F0',
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
minWidth: 210, // Compressed from 250px to remove dead layout space
|
||||||
|
'&:hover': { borderColor: BRAND, bgcolor: alpha(BRAND, 0.02) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ color: '#334155' }}>
|
||||||
|
<Box component="span">{dayjs(range.from).format('DD MMM YYYY')}</Box>
|
||||||
|
<ArrowRightAltRoundedIcon sx={{ fontSize: 16, color: '#94A3B8' }} />
|
||||||
|
<Box component="span">{dayjs(range.to).format('DD MMM YYYY')}</Box>
|
||||||
|
</Stack>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Themed calendar popover */}
|
||||||
|
<Popover
|
||||||
|
open={Boolean(calAnchor)}
|
||||||
|
anchorEl={calAnchor}
|
||||||
|
onClose={() => setCalAnchor(null)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
PaperProps={{ sx: { mt: 1, borderRadius: 3, border: '1px solid #ECEEF1', boxShadow: '0 12px 40px rgba(0,0,0,0.14)', overflow: 'hidden' } }}
|
||||||
|
>
|
||||||
|
<Box sx={{ px: 2, pt: 2 }}>
|
||||||
|
<Typography sx={{ fontWeight: 800, color: '#212529' }}>Select date range</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{dayjs(range.from).format('DD MMM')} – {dayjs(range.to).format('DD MMM YYYY')} · {dayCount} {dayCount === 1 ? 'day' : 'days'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<RangeCalendar
|
||||||
|
from={range.from}
|
||||||
|
to={range.to}
|
||||||
|
maxDate={today}
|
||||||
|
onSelect={(from, to) => setRange({ from, to })}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 2, py: 1.25 }}>
|
||||||
|
<Button size="small" onClick={() => applyPreset(1)} sx={{ textTransform: 'none', color: '#5F6368', fontWeight: 700 }}>
|
||||||
|
Reset to today
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="contained" onClick={() => setCalAnchor(null)}
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 700, bgcolor: BRAND, borderRadius: 2, '&:hover': { bgcolor: '#9E0E20' } }}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Metrics Row — CSS grid with minmax(0,1fr) never overflows on mobile */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: 'repeat(2, minmax(0, 1fr))',
|
||||||
|
sm: 'repeat(3, minmax(0, 1fr))',
|
||||||
|
md: 'repeat(4, minmax(0, 1fr))',
|
||||||
|
lg: 'repeat(5, minmax(0, 1fr))'
|
||||||
|
},
|
||||||
|
gap: { xs: 1.5, sm: 2, md: 3 },
|
||||||
|
mb: { xs: 3, md: 5 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats.map((s) => {
|
||||||
|
const Icon = s.icon;
|
||||||
|
return (
|
||||||
|
<Card key={s.label} sx={{ height: '100%', position: 'relative', overflow: 'hidden', borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.03)', border: '1px solid #ECEEF1' }}>
|
||||||
|
<CardContent sx={{ p: { xs: 2, sm: 2.5 }, '&:last-child': { pb: { xs: 2, sm: 2.5 } } }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.75} sx={{ mb: 1.75 }}>
|
||||||
|
<Avatar sx={{ bgcolor: s.color + '15', color: s.color, width: { xs: 40, sm: 46 }, height: { xs: 40, sm: 46 }, borderRadius: 2, flexShrink: 0 }}>
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
</Avatar>
|
||||||
|
<Typography sx={{ color: '#868E96', fontWeight: 700, lineHeight: 1.3, fontSize: { xs: '0.72rem', sm: '0.78rem' }, textTransform: 'uppercase', letterSpacing: 0.6 }}>
|
||||||
|
{s.label}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography sx={{ fontWeight: 800, color: '#212529', fontSize: { xs: '1.5rem', sm: '1.9rem' }, lineHeight: 1.15, mb: 0.75 }}>
|
||||||
|
{s.value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: '#6c757d', fontWeight: 500 }}>
|
||||||
|
{s.sub}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Operations Grid */}
|
||||||
|
<Grid container spacing={{ xs: 2, md: 3.5 }}>
|
||||||
|
|
||||||
|
{/* Sorting Progress & Incoming Vehicles */}
|
||||||
|
<Grid size={{ xs: 12, md: 7, lg: 8 }} >
|
||||||
|
<Stack spacing={3.5}>
|
||||||
|
|
||||||
|
{/* Sorting Station Overview */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Sorting Progress" subheader="How many parcels we've sorted today" />
|
||||||
|
<Divider />
|
||||||
|
<CardContent sx={{ pt: 3 }}>
|
||||||
|
<Box sx={{ mb: 3.5 }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }} gap={1} flexWrap="wrap">
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Today's target — 2,000 parcels</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 700, whiteSpace: 'nowrap' }}>74% done</Typography>
|
||||||
|
</Stack>
|
||||||
|
<LinearProgress variant="determinate" value={74.1} sx={{ height: 10, borderRadius: 2, bgcolor: 'grey.200' }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 700 }}>Trucks Arriving</Typography>
|
||||||
|
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small" sx={{ minWidth: 480 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Truck</TableCell>
|
||||||
|
<TableCell>Coming From</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Unloaded</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{incomingVehicles.map((v) => (
|
||||||
|
<TableRow key={v.id}>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>{v.id}</TableCell>
|
||||||
|
<TableCell>{v.origin}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={v.status} color={v.color} variant={v.status === 'Expected' ? 'outlined' : 'filled'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ minWidth: 150 }}>
|
||||||
|
{v.progress > 0 ? (
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<LinearProgress variant="determinate" value={v.progress} sx={{ flex: 1, height: 6, borderRadius: 2 }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>{v.progress}%</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.secondary">—</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Online Research: Heavy Traffic / Peak Season Control Center */}
|
||||||
|
<Card sx={{ borderLeft: '1px solid', borderColor: 'primary.main' }}>
|
||||||
|
<CardHeader
|
||||||
|
title="Handling Busy Days"
|
||||||
|
subheader="Simple ways the hub keeps up when parcels pile up"
|
||||||
|
avatar={
|
||||||
|
<Avatar sx={{ bgcolor: 'primary.lighter', color: 'primary.main', borderRadius: 2 }}>
|
||||||
|
<SpeedIcon />
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<CardContent sx={{ pt: 3 }}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }} >
|
||||||
|
<Box sx={{ p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
|
<ElectricBoltIcon color="success" sx={{ fontSize: 18 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Fast Lane</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Urgent parcels skip the shelves and go straight from arrival to the outgoing trucks.
|
||||||
|
</Typography>
|
||||||
|
<Chip label="On" color="success" size="small" variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }} >
|
||||||
|
<Box sx={{ p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
|
<DynamicFeedIcon color="info" sx={{ fontSize: 18 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Separate Lines</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Big, heavy boxes are sorted on a different line from small letters and envelopes.
|
||||||
|
</Typography>
|
||||||
|
<Chip label="Using Line 4" color="info" size="small" variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }} >
|
||||||
|
<Box sx={{ p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
|
<QrCodeScannerIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Quick Scan</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
A scanner reads each parcel automatically as it passes, so nothing is missed.
|
||||||
|
</Typography>
|
||||||
|
<Chip label="Working" color="primary" size="small" variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Delivery Routes */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Delivery Areas Today" subheader="Parcels and milers for each part of the city" />
|
||||||
|
<Divider />
|
||||||
|
<CardContent sx={{ pt: 3 }}>
|
||||||
|
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small" sx={{ minWidth: 420 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Area</TableCell>
|
||||||
|
<TableCell align="center">Parcels</TableCell>
|
||||||
|
<TableCell align="center">Milers</TableCell>
|
||||||
|
<TableCell align="center">Status</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{activeRoutes.map((r) => (
|
||||||
|
<TableRow key={r.zone}>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{r.zone}</TableCell>
|
||||||
|
<TableCell align="center">{r.packages}</TableCell>
|
||||||
|
<TableCell align="center">{r.riders}</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={r.status}
|
||||||
|
color={r.status === 'Active' ? 'success' : 'warning'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Sidebar logs / Active updates */}
|
||||||
|
<Grid size={{ xs: 12, md: 5, lg: 4 }} >
|
||||||
|
<Card sx={{ height: '100%' }}>
|
||||||
|
<CardHeader title="Recent Activity" subheader="What's been happening at the hub" />
|
||||||
|
<Divider />
|
||||||
|
<CardContent sx={{ pt: 3 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{recentActivity.map((act, i) => (
|
||||||
|
<Stack direction="row" spacing={2} key={i} alignItems="flex-start">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: act.type === 'inbound' ? '#C01227' : act.type === 'exception' ? '#F04134' : '#00A854',
|
||||||
|
mt: 0.75,
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack spacing={0.25} sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{act.text}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{act.time}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4, p: 2.5, bgcolor: '#8E1F2A10', borderRadius: 2, border: '1px dashed #8E1F2A30' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.dark', mb: 1 }}>
|
||||||
|
Not sure where a parcel goes?
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
|
Scan it and we'll tell you exactly what to do next.
|
||||||
|
</Typography>
|
||||||
|
<Button size="small" variant="contained" endIcon={<ArrowForwardIcon fontSize="small" />} href="/routing">
|
||||||
|
Where Does It Go?
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/pages/auth/Login.jsx
Normal file
212
src/pages/auth/Login.jsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
Link
|
||||||
|
} from '@mui/material';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
|
import BoltIcon from '@mui/icons-material/Bolt';
|
||||||
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
|
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||||
|
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const [auth, setAuth] = useState('hub.delhi@doormile.in');
|
||||||
|
const [pwd, setPwd] = useState('password123');
|
||||||
|
|
||||||
|
const handleSignIn = () => {
|
||||||
|
navigate('/dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100vw', bgcolor: '#ffffff', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Brand Side Panel */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', md: 'flex' },
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: { md: '28%', lg: '27%', xl: '25%' },
|
||||||
|
minWidth: '360px',
|
||||||
|
p: 5,
|
||||||
|
color: '#fff',
|
||||||
|
background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background Decorative Circles */}
|
||||||
|
<Box sx={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} />
|
||||||
|
<Box sx={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} />
|
||||||
|
|
||||||
|
{/* BLACK LOGO REPLACEMENT (Sidebar) */}
|
||||||
|
<Box sx={{ filter: 'brightness(0) invert(0)', display: 'inline-flex' }}>
|
||||||
|
<Logo height={24} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ position: 'relative', my: 'auto' }}>
|
||||||
|
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600 }}>
|
||||||
|
Doormile Hub Console
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, mt: 1, mb: 2, fontSize: { md: '1.8rem', lg: '2.2rem' } }}>
|
||||||
|
Every parcel,
|
||||||
|
<br /> handled with ease.
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.8)', mb: 4, fontSize: '0.9rem', lineHeight: 1.5 }}>
|
||||||
|
Receive parcels, sort them, and send them out for delivery or transfer to another city — all from one simple screen.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{[
|
||||||
|
{ icon: BoltIcon, t: 'We tell you which shelf each parcel goes on' },
|
||||||
|
{ icon: LocalShippingOutlinedIcon, t: 'Scan parcels in as trucks arrive' },
|
||||||
|
{ icon: VerifiedOutlinedIcon, t: 'Keep an eye on cold-storage parcels' }
|
||||||
|
].map((f) => (
|
||||||
|
<Stack key={f.t} direction="row" spacing={1.5} alignItems="center">
|
||||||
|
<Box sx={{ width: 34, height: 34, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<f.icon fontSize="small" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', mt: 3 }}>
|
||||||
|
© 2026 Doormile Logistics Pvt. Ltd.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Form Panel */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: { xs: 3, sm: 6 },
|
||||||
|
bgcolor: '#ffffff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 420,
|
||||||
|
p: { xs: 3, sm: 4.5 },
|
||||||
|
border: '1px solid #eaeaea',
|
||||||
|
borderRadius: 3,
|
||||||
|
boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* BLACK LOGO REPLACEMENT (Mobile View) */}
|
||||||
|
<Box sx={{ display: { xs: 'flex', md: 'none' }, mb: 3, filter: 'brightness(0)' }}>
|
||||||
|
<Logo />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem' }}>Hub Sign In</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 4 }}>
|
||||||
|
Sign in to your Doormile Hub operations account.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>
|
||||||
|
Username / Email
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Enter your email"
|
||||||
|
value={auth}
|
||||||
|
onChange={(e) => setAuth(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>
|
||||||
|
Password
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={pwd}
|
||||||
|
onChange={(e) => setPwd(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
||||||
|
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox defaultChecked size="small" sx={{ color: '#C01227', '&.Mui-checked': { color: '#C01227' } }} />}
|
||||||
|
label={<Typography variant="body2" sx={{ color: '#555' }}>Remember me</Typography>}
|
||||||
|
/>
|
||||||
|
<Link href="#" underline="hover" variant="body2" sx={{ color: '#C01227', fontWeight: 600 }}>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="large"
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSignIn}
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#C01227',
|
||||||
|
color: '#fff',
|
||||||
|
py: 1.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: '#9E0E20',
|
||||||
|
boxShadow: 'none'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center', mt: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
New to Hub Operations?{' '}
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => { e.preventDefault(); navigate('/signup'); }}
|
||||||
|
underline="hover"
|
||||||
|
sx={{ color: '#C01227', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
src/pages/auth/Signup.jsx
Normal file
261
src/pages/auth/Signup.jsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
Button,
|
||||||
|
Link,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
Select,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel
|
||||||
|
} from '@mui/material';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
|
import HubIcon from '@mui/icons-material/Hub';
|
||||||
|
import AssignmentIndIcon from '@mui/icons-material/AssignmentInd';
|
||||||
|
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
|
||||||
|
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
const HUBS = [
|
||||||
|
{ value: 'delhi', label: 'Delhi Hub (DEL-01)' },
|
||||||
|
{ value: 'mumbai', label: 'Mumbai Hub (BOM-02)' },
|
||||||
|
{ value: 'bangalore', label: 'Bengaluru Hub (BLR-03)' },
|
||||||
|
{ value: 'chennai', label: 'Chennai Hub (MAA-04)' },
|
||||||
|
{ value: 'kolkata', label: 'Kolkata Hub (CCU-05)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ value: 'manager', label: 'Hub Manager / Supervisor' },
|
||||||
|
{ value: 'sorter', label: 'Sorting Station Operator' },
|
||||||
|
{ value: 'inbound', label: 'Inbound Associate' },
|
||||||
|
{ value: 'dispatch', label: 'Outbound Dispatch Coordinator' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Signup() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
// Fields
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [hub, setHub] = useState('delhi');
|
||||||
|
const [role, setRole] = useState('manager');
|
||||||
|
const [pwd, setPwd] = useState('');
|
||||||
|
const [agree, setAgree] = useState(true);
|
||||||
|
|
||||||
|
const handleSignUp = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100vw', bgcolor: '#ffffff', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Brand Side Panel */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', md: 'flex' },
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: { md: '28%', lg: '27%', xl: '25%' },
|
||||||
|
minWidth: '360px',
|
||||||
|
p: 5,
|
||||||
|
color: '#fff',
|
||||||
|
background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background Decorative Circles */}
|
||||||
|
<Box sx={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} />
|
||||||
|
<Box sx={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} />
|
||||||
|
|
||||||
|
{/* BLACK LOGO REPLACEMENT (Sidebar) */}
|
||||||
|
<Box sx={{ filter: 'brightness(0) invert(0)', display: 'inline-flex' }}>
|
||||||
|
<Logo height={24} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ position: 'relative', my: 'auto' }}>
|
||||||
|
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600 }}>
|
||||||
|
Hub Registration Gateway
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, mt: 1, mb: 2, fontSize: { md: '1.8rem', lg: '2.2rem' } }}>
|
||||||
|
Join the Connected
|
||||||
|
<br /> Logistics Network.
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.8)', mb: 4, fontSize: '0.9rem', lineHeight: 1.5 }}>
|
||||||
|
Create an operational profile to access state-of-the-art sorting stations, live manifest creations, and miler optimization modules.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{[
|
||||||
|
{ icon: HubIcon, t: 'Connect to any of the 15+ nationwide hubs' },
|
||||||
|
{ icon: AssignmentIndIcon, t: 'Role-based access controls for security' },
|
||||||
|
{ icon: VerifiedUserIcon, t: 'Activity logging and dispatch compliance verification' }
|
||||||
|
].map((f) => (
|
||||||
|
<Stack key={f.t} direction="row" spacing={1.5} alignItems="center">
|
||||||
|
<Box sx={{ width: 34, height: 34, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<f.icon fontSize="small" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', mt: 3 }}>
|
||||||
|
© 2026 Doormile Logistics Pvt. Ltd.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Form Panel */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: { xs: 3, sm: 6 },
|
||||||
|
bgcolor: '#ffffff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 480,
|
||||||
|
p: { xs: 3, sm: 4.5 },
|
||||||
|
border: '1px solid #eaeaea',
|
||||||
|
borderRadius: 3,
|
||||||
|
boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* BLACK LOGO REPLACEMENT (Mobile View) */}
|
||||||
|
<Box sx={{ display: { xs: 'flex', md: 'none' }, mb: 3, filter: 'brightness(0)' }}>
|
||||||
|
<Logo />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem' }}>Request Access</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 4 }}>
|
||||||
|
Register your credentials to join hub operations.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSignUp}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Full Name</Typography>
|
||||||
|
<TextField fullWidth required placeholder="Enter full name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Work Email</Typography>
|
||||||
|
<TextField fullWidth required type="email" placeholder="Enter work email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }} >
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Assign Hub</Typography>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<Select value={hub} onChange={(e) => setHub(e.target.value)}>
|
||||||
|
{HUBS.map((h) => (
|
||||||
|
<MenuItem key={h.value} value={h.value}>{h.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }} >
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Select Role</Typography>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<Select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<MenuItem key={r.value} value={r.value}>{r.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Password</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
|
placeholder="Create password"
|
||||||
|
value={pwd}
|
||||||
|
onChange={(e) => setPwd(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
||||||
|
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={agree} onChange={(e) => setAgree(e.target.checked)} size="small" sx={{ color: '#C01227', '&.Mui-checked': { color: '#C01227' } }} required />}
|
||||||
|
label={
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
I agree to the{' '}
|
||||||
|
<Link href="#" underline="hover" sx={{ color: '#C01227', fontWeight: 500 }}>Terms of Service</Link> and{' '}
|
||||||
|
<Link href="#" underline="hover" sx={{ color: '#C01227', fontWeight: 500 }}>Operations Guidelines</Link>.
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="large"
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#C01227',
|
||||||
|
color: '#fff',
|
||||||
|
py: 1.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: '#9E0E20',
|
||||||
|
boxShadow: 'none'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Register Account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center', mt: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => { e.preventDefault(); navigate('/login'); }}
|
||||||
|
underline="hover"
|
||||||
|
sx={{ color: '#C01227', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
src/pages/operations/Dispatch.jsx
Normal file
326
src/pages/operations/Dispatch.jsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Grid,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Snackbar,
|
||||||
|
Alert,
|
||||||
|
Avatar,
|
||||||
|
Divider,
|
||||||
|
useMediaQuery
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import EastRoundedIcon from '@mui/icons-material/EastRounded';
|
||||||
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
|
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||||
|
import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined';
|
||||||
|
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
|
||||||
|
const INITIAL_MANIFESTS = [
|
||||||
|
{ id: 'BATCH-9281', route: 'Transfer to Mumbai Hub', vehicle: 'Transfer Truck: DL-01-BZ-8055', packagesCount: 154, status: 'Preparing', time: 'Created 1 hr ago', origin: 'Delhi Hub', currentLoc: 'Delhi Hub (Dispatch Dock)', destination: 'Mumbai Hub', kind: 'transfer' },
|
||||||
|
{ id: 'BATCH-9282', route: 'Local Delivery: Dwarka', vehicle: 'Miler: Deepak Sharma (Two-wheeler)', packagesCount: 12, status: 'Sent', time: 'Sent out 20 min ago', origin: 'Delhi Hub', currentLoc: 'On the way', destination: 'Dwarka, Delhi', kind: 'local' },
|
||||||
|
{ id: 'BATCH-9283', route: 'Local Delivery: Saket', vehicle: 'Miler: Karthik S. (EV)', packagesCount: 5, status: 'Ready', time: 'Ready 45 min ago', origin: 'Delhi Hub', currentLoc: 'Delhi Hub (Dispatch Dock)', destination: 'Saket, Delhi', kind: 'local' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_META = {
|
||||||
|
Preparing: { color: '#B06000', bg: '#FEF7E0', label: 'Preparing' },
|
||||||
|
Ready: { color: '#1A73E8', bg: '#E8F0FE', label: 'Ready to send' },
|
||||||
|
Sent: { color: '#1E8E3E', bg: '#E6F4EA', label: 'Sent' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dispatch() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
const [manifests, setManifests] = useState(INITIAL_MANIFESTS);
|
||||||
|
const [openModal, setOpenModal] = useState(false);
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub');
|
||||||
|
const [newDestination, setNewDestination] = useState('');
|
||||||
|
const [newVehicle, setNewVehicle] = useState('');
|
||||||
|
const [newPkgsCount, setNewPkgsCount] = useState('5');
|
||||||
|
|
||||||
|
// Toast state
|
||||||
|
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
|
||||||
|
|
||||||
|
const handleCreateManifest = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newVehicle) {
|
||||||
|
setToast({ open: true, msg: 'Please input Miler or Vehicle info', severity: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomNum = Math.floor(1000 + Math.random() * 9000);
|
||||||
|
const newManifest = {
|
||||||
|
id: `BATCH-${randomNum}`,
|
||||||
|
route: newRoute,
|
||||||
|
vehicle: newVehicle,
|
||||||
|
packagesCount: parseInt(newPkgsCount, 10),
|
||||||
|
status: 'Preparing',
|
||||||
|
time: 'Created just now',
|
||||||
|
origin: 'Delhi Hub',
|
||||||
|
currentLoc: 'Delhi Hub (Dispatch Dock)',
|
||||||
|
destination: newDestination || newRoute
|
||||||
|
};
|
||||||
|
|
||||||
|
setManifests([newManifest, ...manifests]);
|
||||||
|
setOpenModal(false);
|
||||||
|
setNewVehicle('');
|
||||||
|
setNewDestination('');
|
||||||
|
setToast({ open: true, msg: `Batch ${newManifest.id} created successfully`, severity: 'success' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeal = (id) => {
|
||||||
|
setManifests((prev) =>
|
||||||
|
prev.map((m) => (m.id === id ? { ...m, status: 'Ready', time: 'Checked & ready just now' } : m))
|
||||||
|
);
|
||||||
|
setToast({ open: true, msg: `Batch ${id} checked and ready to send.`, severity: 'success' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDispatch = (id) => {
|
||||||
|
setManifests((prev) =>
|
||||||
|
prev.map((m) => (m.id === id ? { ...m, status: 'Sent', time: 'Sent out just now', currentLoc: 'On the way' } : m))
|
||||||
|
);
|
||||||
|
setToast({ open: true, msg: `Batch ${id} sent out! The miler/driver has been notified.`, severity: 'success' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Action button shown for each batch based on its status
|
||||||
|
const BatchAction = ({ m, fullWidth }) => {
|
||||||
|
if (m.status === 'Preparing') {
|
||||||
|
return (
|
||||||
|
<Button size="small" variant="outlined" color="info" fullWidth={fullWidth} onClick={() => handleSeal(m.id)} sx={{ borderRadius: 2, fontWeight: 700 }}>
|
||||||
|
Check & Mark Ready
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (m.status === 'Ready') {
|
||||||
|
return (
|
||||||
|
<Button size="small" variant="contained" color="success" fullWidth={fullWidth} startIcon={<SendIcon sx={{ fontSize: 16 }} />} onClick={() => handleDispatch(m.id)} sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none' }}>
|
||||||
|
Send Out
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Chip size="small" icon={<CheckCircleIcon />} label="Sent" color="success" variant="outlined" sx={{ fontWeight: 700 }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Origin → destination journey, reused in cards and table
|
||||||
|
const Journey = ({ m }) => {
|
||||||
|
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
||||||
|
return (
|
||||||
|
<Stack spacing={0.75}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.75} flexWrap="wrap">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{m.origin}</Typography>
|
||||||
|
<EastRoundedIcon sx={{ fontSize: 16, color: '#ADB5BD' }} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: meta.color }}>{m.destination}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary">{m.currentLoc}</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<PageHeader
|
||||||
|
icon={LocalShippingIcon}
|
||||||
|
title="Dispatch & Transfer"
|
||||||
|
subtitle="Group parcels that go out together, check them, and send them — either out for local delivery or transferred to another city hub."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Manifest Actions & List */}
|
||||||
|
<Grid size={{ xs: 12 }} >
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Outgoing Batches"
|
||||||
|
subheader="Each batch is a group of parcels leaving Delhi Hub together"
|
||||||
|
action={
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenModal(true)}>
|
||||||
|
New Batch
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{isMdDown ? (
|
||||||
|
/* ── MOBILE / TABLET: spacious cards ── */
|
||||||
|
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{manifests.map((m) => {
|
||||||
|
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
||||||
|
const KindIcon = m.kind === 'transfer' ? SwapHorizOutlinedIcon : TwoWheelerOutlinedIcon;
|
||||||
|
return (
|
||||||
|
<Card key={m.id} elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1' }}>
|
||||||
|
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }} gap={1}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1.5} sx={{ minWidth: 0 }}>
|
||||||
|
<Avatar variant="rounded" sx={{ bgcolor: meta.bg, color: meta.color, borderRadius: 2, width: 38, height: 38 }}>
|
||||||
|
<KindIcon sx={{ fontSize: 20 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{m.id}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{m.time}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Chip size="small" label={meta.label} sx={{ fontWeight: 700, bgcolor: meta.bg, color: meta.color, flexShrink: 0 }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ p: 1.5, bgcolor: '#FAFBFC', borderRadius: 2, mb: 1.5 }}>
|
||||||
|
<Journey m={m} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mb: 2 }}>
|
||||||
|
<Chip size="small" icon={<TwoWheelerOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={m.vehicle}
|
||||||
|
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, maxWidth: '100%', '& .MuiChip-icon': { color: '#9AA0A6' } }} />
|
||||||
|
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '15px !important' }} />} label={`${m.packagesCount} parcels`}
|
||||||
|
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<BatchAction m={m} fullWidth />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
/* ── DESKTOP: spacious table ── */
|
||||||
|
<TableContainer sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table sx={{ minWidth: 820 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
|
||||||
|
{['Batch', 'Journey', 'Miler / Vehicle', 'Parcels', 'Status', 'Action'].map((h, i) => (
|
||||||
|
<TableCell key={h} align={i === 3 ? 'center' : i === 5 ? 'right' : 'left'}
|
||||||
|
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 2, borderBottom: '1px solid #ECEEF1', whiteSpace: 'nowrap' }}>
|
||||||
|
{h}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{manifests.map((m) => {
|
||||||
|
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
||||||
|
const KindIcon = m.kind === 'transfer' ? SwapHorizOutlinedIcon : TwoWheelerOutlinedIcon;
|
||||||
|
return (
|
||||||
|
<TableRow key={m.id} hover sx={{ '& td': { borderBottom: '1px solid #F4F6F8', py: 2.25 }, '&:last-child td': { border: 0 } }}>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1.5}>
|
||||||
|
<Avatar variant="rounded" sx={{ bgcolor: meta.bg, color: meta.color, borderRadius: 2, width: 38, height: 38 }}>
|
||||||
|
<KindIcon sx={{ fontSize: 20 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{m.id}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{m.time}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ minWidth: 240 }}><Journey m={m} /></TableCell>
|
||||||
|
<TableCell sx={{ color: '#495057', fontWeight: 600, maxWidth: 200 }}>{m.vehicle}</TableCell>
|
||||||
|
<TableCell align="center" sx={{ whiteSpace: 'nowrap', fontWeight: 700, color: '#1A1A2E' }}>{m.packagesCount}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={meta.label} sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: meta.bg, color: meta.color }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right"><BatchAction m={m} /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Creation Modal */}
|
||||||
|
<Dialog open={openModal} onClose={() => setOpenModal(false)} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle sx={{ fontWeight: 700 }}>Create a New Batch</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box component="form" onSubmit={handleCreateManifest} sx={{ mt: 1 }}>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
fullWidth
|
||||||
|
label="Where is this batch going?"
|
||||||
|
value={newRoute}
|
||||||
|
onChange={(e) => setNewRoute(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="Transfer to Mumbai Hub">Transfer to another city — Mumbai Hub</MenuItem>
|
||||||
|
<MenuItem value="Transfer to Bengaluru Hub">Transfer to another city — Bengaluru Hub</MenuItem>
|
||||||
|
<MenuItem value="Local Delivery: Dwarka">Local Delivery — Dwarka</MenuItem>
|
||||||
|
<MenuItem value="Local Delivery: Saket">Local Delivery — Saket</MenuItem>
|
||||||
|
<MenuItem value="Local Delivery: Rohini">Local Delivery — Rohini</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Full destination address / hub"
|
||||||
|
placeholder="e.g. Dwarka Sector 12, Delhi"
|
||||||
|
value={newDestination}
|
||||||
|
onChange={(e) => setNewDestination(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Miler name or vehicle number"
|
||||||
|
placeholder="e.g. Amit Kumar (EV) or DL-3C-YY-1092"
|
||||||
|
value={newVehicle}
|
||||||
|
onChange={(e) => setNewVehicle(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
label="How many parcels?"
|
||||||
|
value={newPkgsCount}
|
||||||
|
onChange={(e) => setNewPkgsCount(e.target.value)}
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpenModal(false)}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleCreateManifest}>
|
||||||
|
Create Batch
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={toast.open}
|
||||||
|
autoHideDuration={4000}
|
||||||
|
onClose={() => setToast({ ...toast, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<Alert severity={toast.severity} onClose={() => setToast({ ...toast, open: false })} sx={{ width: '100%' }}>
|
||||||
|
{toast.msg}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
367
src/pages/operations/Inbound.jsx
Normal file
367
src/pages/operations/Inbound.jsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Typography, Card, CardContent, Grid, TextField, Button, Stack,
|
||||||
|
MenuItem, Table, TableBody, TableCell, TableContainer, TableHead,
|
||||||
|
TableRow, Chip, Alert, Snackbar, InputAdornment, Avatar, Divider,
|
||||||
|
useMediaQuery
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import QrCodeScannerOutlinedIcon from '@mui/icons-material/QrCodeScannerOutlined';
|
||||||
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
|
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||||
|
import EastOutlinedIcon from '@mui/icons-material/EastOutlined';
|
||||||
|
import BoltOutlinedIcon from '@mui/icons-material/BoltOutlined';
|
||||||
|
import MoveToInboxOutlinedIcon from '@mui/icons-material/MoveToInboxOutlined';
|
||||||
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
|
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined';
|
||||||
|
import AcUnitOutlinedIcon from '@mui/icons-material/AcUnitOutlined';
|
||||||
|
import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined';
|
||||||
|
import ThermostatOutlinedIcon from '@mui/icons-material/ThermostatOutlined';
|
||||||
|
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
|
||||||
|
import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined';
|
||||||
|
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||||
|
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
|
||||||
|
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
|
||||||
|
const SHELVES = ['Zone A (Shelf 1)', 'Zone A (Shelf 2)', 'Zone B (Shelf 1)', 'Zone C (Cold Room)', 'Exception Area'];
|
||||||
|
|
||||||
|
const INITIAL_INBOUND = [
|
||||||
|
{ trackingId: 'DM-882201', sender: 'Acme Corp, Mumbai', origin: 'Mumbai Hub', currentLoc: 'Delhi Hub', destination: 'Dwarka, Sec 12, Delhi', weight: '2.4 kg', condition: 'Good', temp: 'N/A', shelf: 'Zone A (Shelf 1)', time: '10 min ago' },
|
||||||
|
{ trackingId: 'DM-882202', sender: 'Tech Ltd, Mumbai', origin: 'Mumbai Hub', currentLoc: 'Delhi Hub', destination: 'Saket, Block J, Delhi', weight: '1.2 kg', condition: 'Good', temp: '4.1°C', shelf: 'Zone C (Cold Room)', time: '12 min ago' },
|
||||||
|
{ trackingId: 'DM-882203', sender: 'Crafts India, Jaipur', origin: 'Jaipur Hub', currentLoc: 'Delhi Hub', destination: 'Mayur Vihar Ph 1, Delhi', weight: '8.5 kg', condition: 'Damaged Box', temp: 'N/A', shelf: 'Exception Area', time: '20 min ago' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORIGINS = [
|
||||||
|
{ value: 'Mumbai Hub', label: 'Mumbai Hub (BOM-02)' },
|
||||||
|
{ value: 'Jaipur Hub', label: 'Jaipur Hub (JAI-08)' },
|
||||||
|
{ value: 'Bengaluru Hub', label: 'Bengaluru Hub (BLR-03)' },
|
||||||
|
{ value: 'Client Pickup', label: 'Direct Client Pickup (Delhi Local)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const shelfStyle = (shelf) => {
|
||||||
|
if (shelf === 'Exception Area') return { color: '#D93025', bg: '#FCE8E6' };
|
||||||
|
if (shelf.includes('Cold')) return { color: '#00838F', bg: '#E0F7FA' };
|
||||||
|
return { color: '#1A73E8', bg: '#E8F0FE' };
|
||||||
|
};
|
||||||
|
const isGood = (c) => c === 'Good';
|
||||||
|
|
||||||
|
export default function Inbound() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
const [trackingId, setTrackingId] = useState('');
|
||||||
|
const [origin, setOrigin] = useState('Mumbai Hub');
|
||||||
|
const [customer, setCustomer] = useState('');
|
||||||
|
const [senderAddress, setSenderAddress] = useState('');
|
||||||
|
const [destination, setDestination] = useState('');
|
||||||
|
const [weight, setWeight] = useState('');
|
||||||
|
const [condition, setCondition] = useState('Good');
|
||||||
|
const [temp, setTemp] = useState('');
|
||||||
|
|
||||||
|
const [inboundLogs, setInboundLogs] = useState(INITIAL_INBOUND);
|
||||||
|
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const received = inboundLogs.length;
|
||||||
|
const exceptions = inboundLogs.filter(l => l.condition !== 'Good' || l.shelf === 'Exception Area').length;
|
||||||
|
const coldChain = inboundLogs.filter(l => l.temp && l.temp !== 'N/A').length;
|
||||||
|
const pendingSort = inboundLogs.filter(l => l.shelf !== 'Exception Area').length;
|
||||||
|
return { received, exceptions, coldChain, pendingSort };
|
||||||
|
}, [inboundLogs]);
|
||||||
|
|
||||||
|
const handleRandomScan = () => {
|
||||||
|
const randomNum = Math.floor(100000 + Math.random() * 900000);
|
||||||
|
setTrackingId(`DM-${randomNum}`);
|
||||||
|
const customers = ['Acme Electronics', 'Delhi Medicos', 'Urban Fashion', 'Astro Retail', 'Fresho Foods'];
|
||||||
|
const senderAddresses = ['Andheri East, Mumbai', 'Malviya Nagar, Jaipur', 'Koramangala, Bengaluru', 'Lajpat Nagar, Delhi'];
|
||||||
|
const destinations = ['Dwarka Sec 4, Delhi', 'Saket Marg, Delhi', 'Karol Bagh, Delhi', 'Connaught Place, Delhi', 'Vasant Kunj, Delhi'];
|
||||||
|
const weights = ['1.5 kg', '0.8 kg', '5.2 kg', '12.0 kg', '3.1 kg'];
|
||||||
|
const origins = ['Mumbai Hub', 'Jaipur Hub', 'Bengaluru Hub', 'Client Pickup'];
|
||||||
|
setCustomer(customers[Math.floor(Math.random() * customers.length)]);
|
||||||
|
setSenderAddress(senderAddresses[Math.floor(Math.random() * senderAddresses.length)]);
|
||||||
|
setDestination(destinations[Math.floor(Math.random() * destinations.length)]);
|
||||||
|
setWeight(weights[Math.floor(Math.random() * weights.length)]);
|
||||||
|
setCondition(Math.random() > 0.85 ? 'Damaged Box' : 'Good');
|
||||||
|
setTemp(Math.random() > 0.8 ? '3.8°C' : 'N/A');
|
||||||
|
setOrigin(origins[Math.floor(Math.random() * origins.length)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!trackingId || !destination) {
|
||||||
|
setToast({ open: true, msg: 'Please scan or fill tracking details', severity: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let recommendedShelf = SHELVES[0];
|
||||||
|
if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) recommendedShelf = 'Exception Area';
|
||||||
|
else if (temp !== 'N/A' && temp !== '') recommendedShelf = 'Zone C (Cold Room)';
|
||||||
|
else recommendedShelf = SHELVES[Math.floor(Math.random() * 3)];
|
||||||
|
|
||||||
|
const newLog = {
|
||||||
|
trackingId, sender: customer || 'Unknown Sender', origin, currentLoc: 'Delhi Hub',
|
||||||
|
destination, weight: weight || '1.0 kg', condition, temp: temp || 'N/A',
|
||||||
|
shelf: recommendedShelf, time: 'Just now'
|
||||||
|
};
|
||||||
|
setInboundLogs([newLog, ...inboundLogs]);
|
||||||
|
setToast({ open: true, msg: `${trackingId} received · routed to ${recommendedShelf}`, severity: 'success' });
|
||||||
|
setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldSx = { '& .MuiOutlinedInput-root': { borderRadius: 2 } };
|
||||||
|
|
||||||
|
// Reusable journey block
|
||||||
|
const Journey = ({ log }) => (
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.75} flexWrap="wrap">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{log.origin}</Typography>
|
||||||
|
<EastOutlinedIcon sx={{ fontSize: 14, color: '#ADB5BD' }} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#C01227' }}>{log.currentLoc}</Typography>
|
||||||
|
<EastOutlinedIcon sx={{ fontSize: 14, color: '#ADB5BD' }} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{log.destination.split(',')[0]}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
|
<PlaceOutlinedIcon sx={{ fontSize: 13, color: '#9AA0A6' }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" noWrap>{log.destination}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<PageHeader
|
||||||
|
icon={MoveToInboxOutlinedIcon}
|
||||||
|
title="Receive Parcels"
|
||||||
|
subtitle="Scan each parcel as it arrives, note its condition, and we'll suggest which shelf to put it on."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── KPI strip ── */}
|
||||||
|
<Grid container spacing={{ xs: 1.5, sm: 2 }} sx={{ mb: 3 }}>
|
||||||
|
{[
|
||||||
|
{ icon: MoveToInboxOutlinedIcon, label: 'Received Today', value: stats.received, color: '#1A73E8', bg: '#E8F0FE' },
|
||||||
|
{ icon: Inventory2OutlinedIcon, label: 'To Sort', value: stats.pendingSort, color: '#B06000', bg: '#FEF7E0' },
|
||||||
|
{ icon: WarningAmberOutlinedIcon, label: 'Needs Checking', value: stats.exceptions, color: '#D93025', bg: '#FCE8E6' },
|
||||||
|
{ icon: AcUnitOutlinedIcon, label: 'Cold Items', value: stats.coldChain, color: '#00838F', bg: '#E0F7FA' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<Grid size={{ xs: 6, md: 3 }} key={i}>
|
||||||
|
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%' }}>
|
||||||
|
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2.5} sx={{ mb: 2 }}>
|
||||||
|
<Avatar variant="rounded" sx={{ bgcolor: s.bg, color: s.color, borderRadius: 1, width: 48, height: 48, flexShrink: 0 }}>
|
||||||
|
<s.icon sx={{ fontSize: 24 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Typography sx={{ fontSize: '0.8rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', lineHeight: 1.4 }}>{s.label}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography sx={{ fontSize: '2rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.15, mb: 1 }}>{s.value}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* ── Main ── */}
|
||||||
|
<Grid container spacing={{ xs: 2, md: 2.5 }} alignItems="stretch">
|
||||||
|
|
||||||
|
{/* Scanner Panel */}
|
||||||
|
<Grid size={{ xs: 12, lg: 4 }}>
|
||||||
|
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%' }}>
|
||||||
|
<Box sx={{ p: 2.5, pb: 2 }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={2} // Increase this value
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
variant="rounded"
|
||||||
|
sx={{
|
||||||
|
bgcolor: "#C0122710",
|
||||||
|
color: "#C01227",
|
||||||
|
borderRadius: 1,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QrCodeScannerOutlinedIcon />
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{ fontWeight: 700, color: "#1A1A2E" }}
|
||||||
|
>
|
||||||
|
Add a Parcel
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Record a parcel arriving at Delhi Hub
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<CardContent sx={{ pt: 3 }}>
|
||||||
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
<TextField fullWidth label="Tracking ID" placeholder="e.g. DM-882204" value={trackingId}
|
||||||
|
onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start"><QrCodeScannerOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment>,
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Button size="small" variant="contained" startIcon={<BoltOutlinedIcon sx={{ fontSize: '16px !important' }} />} onClick={handleRandomScan}
|
||||||
|
sx={{ mr: -0.75, borderRadius: 2, bgcolor: '#1A1A2E', boxShadow: 'none', whiteSpace: 'nowrap', '&:hover': { bgcolor: '#000' } }}>
|
||||||
|
Auto
|
||||||
|
</Button>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<TextField select fullWidth label="Where it came from" value={origin}
|
||||||
|
onChange={(e) => setOrigin(e.target.value)} sx={fieldSx}
|
||||||
|
InputProps={{ startAdornment: <InputAdornment position="start"><HomeWorkOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }}>
|
||||||
|
{ORIGINS.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField fullWidth label="Sender Address" placeholder="e.g. Andheri East, Mumbai" value={senderAddress}
|
||||||
|
onChange={(e) => setSenderAddress(e.target.value)} sx={fieldSx} />
|
||||||
|
|
||||||
|
<TextField fullWidth label="Where it's going (delivery address)" placeholder="e.g. Rohini Sec 9, Delhi" value={destination}
|
||||||
|
onChange={(e) => setDestination(e.target.value)} required sx={fieldSx}
|
||||||
|
InputProps={{ startAdornment: <InputAdornment position="start"><PlaceOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField fullWidth label="Weight" placeholder="2.4 kg" value={weight}
|
||||||
|
onChange={(e) => setWeight(e.target.value)} sx={fieldSx}
|
||||||
|
InputProps={{ startAdornment: <InputAdornment position="start"><ScaleOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 18 }} /></InputAdornment> }} />
|
||||||
|
<TextField fullWidth select label="Condition" value={condition}
|
||||||
|
onChange={(e) => setCondition(e.target.value)} sx={fieldSx}>
|
||||||
|
<MenuItem value="Good">Good</MenuItem>
|
||||||
|
<MenuItem value="Damaged Box">Damaged Box</MenuItem>
|
||||||
|
<MenuItem value="Wet / Crushed">Wet / Crushed</MenuItem>
|
||||||
|
<MenuItem value="Missing Label">Missing Label</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField fullWidth label="Temperature (Cold Chain)" placeholder="4.0°C — or N/A if dry" value={temp}
|
||||||
|
onChange={(e) => setTemp(e.target.value)} sx={fieldSx}
|
||||||
|
InputProps={{ startAdornment: <InputAdornment position="start"><ThermostatOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
|
||||||
|
|
||||||
|
<Button fullWidth size="large" variant="contained" type="submit" startIcon={<CheckCircleOutlinedIcon />}
|
||||||
|
sx={{ mt: 0.5, py: 1.3, borderRadius: 2, bgcolor: '#C01227', fontWeight: 700,
|
||||||
|
boxShadow: '0 4px 14px rgba(192,18,39,0.30)', '&:hover': { bgcolor: '#9E0E20' } }}>
|
||||||
|
Mark Received at Hub
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Ledger Panel */}
|
||||||
|
<Grid size={{ xs: 12, lg: 8 }}>
|
||||||
|
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box sx={{ p: 2.5, pb: 2 }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={2} spacing={2}>
|
||||||
|
<Avatar variant="rounded" sx={{ bgcolor: '#E8F0FE', color: '#1A73E8', borderRadius: 2, width: 40, height: 40 }}>
|
||||||
|
<LocalShippingOutlinedIcon />
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1A1A2E' }}>Recently Received</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Parcels logged at the hub today</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Chip label={`${inboundLogs.length} total`} size="small"
|
||||||
|
sx={{ fontWeight: 700, bgcolor: '#F1F3F5', color: '#5F6368', borderRadius: 2 }} />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── MOBILE: card list ── */}
|
||||||
|
{isMdDown ? (
|
||||||
|
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{inboundLogs.map((log, i) => {
|
||||||
|
const ss = shelfStyle(log.shelf);
|
||||||
|
return (
|
||||||
|
<Card key={i} elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1' }}>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E' }}>{log.trackingId}</Typography>
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
|
<AccessTimeOutlinedIcon sx={{ fontSize: 13, color: '#9AA0A6' }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">{log.time}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Journey log={log} />
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mt: 1.5 }}>
|
||||||
|
<Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.weight}
|
||||||
|
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} />
|
||||||
|
<Chip size="small" label={log.condition}
|
||||||
|
sx={{ fontWeight: 700, bgcolor: isGood(log.condition) ? '#E6F4EA' : '#FCE8E6', color: isGood(log.condition) ? '#1E8E3E' : '#D93025' }} />
|
||||||
|
{log.temp !== 'N/A' && (
|
||||||
|
<Chip size="small" icon={<ThermostatOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.temp}
|
||||||
|
sx={{ bgcolor: '#E0F7FA', color: '#00838F', fontWeight: 600, '& .MuiChip-icon': { color: '#00838F' } }} />
|
||||||
|
)}
|
||||||
|
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.shelf}
|
||||||
|
sx={{ bgcolor: ss.bg, color: ss.color, fontWeight: 600, '& .MuiChip-icon': { color: ss.color } }} />
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
/* ── DESKTOP: table with horizontal scroll guard ── */
|
||||||
|
<TableContainer sx={{ flexGrow: 1 }}>
|
||||||
|
<Table sx={{ minWidth: 820 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
|
||||||
|
{['Parcel No.', 'Journey', 'Weight', 'Condition', 'Temp', 'Goes On Shelf', 'Time'].map((h, i) => (
|
||||||
|
<TableCell key={h} align={i >= 2 && i <= 4 ? 'center' : i === 6 ? 'right' : 'left'}
|
||||||
|
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, borderBottom: '1px solid #ECEEF1', py: 1.5, whiteSpace: 'nowrap' }}>
|
||||||
|
{h}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{inboundLogs.map((log, index) => {
|
||||||
|
const ss = shelfStyle(log.shelf);
|
||||||
|
return (
|
||||||
|
<TableRow key={index} hover sx={{ '& td': { borderBottom: '1px solid #F4F6F8' }, '&:last-child td': { border: 0 } }}>
|
||||||
|
<TableCell sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', whiteSpace: 'nowrap' }}>{log.trackingId}</TableCell>
|
||||||
|
<TableCell sx={{ minWidth: 240 }}><Journey log={log} /></TableCell>
|
||||||
|
<TableCell align="center" sx={{ whiteSpace: 'nowrap', fontWeight: 600, color: '#495057' }}>{log.weight}</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Chip size="small" label={log.condition}
|
||||||
|
sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: isGood(log.condition) ? '#E6F4EA' : '#FCE8E6', color: isGood(log.condition) ? '#1E8E3E' : '#D93025' }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center" sx={{ whiteSpace: 'nowrap', fontWeight: 600, color: log.temp !== 'N/A' ? '#00838F' : '#9AA0A6' }}>{log.temp}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.shelf}
|
||||||
|
sx={{ fontWeight: 600, whiteSpace: 'nowrap', bgcolor: ss.bg, color: ss.color, '& .MuiChip-icon': { color: ss.color } }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ whiteSpace: 'nowrap', color: '#9AA0A6', fontSize: '0.78rem' }}>{log.time}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Snackbar open={toast.open} autoHideDuration={4000} onClose={() => setToast({ ...toast, open: false })}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||||
|
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })}
|
||||||
|
sx={{ borderRadius: 2, fontWeight: 600, boxShadow: '0 8px 24px rgba(0,0,0,0.18)' }}>
|
||||||
|
{toast.msg}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
285
src/pages/operations/Inventory.jsx
Normal file
285
src/pages/operations/Inventory.jsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Grid,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Avatar,
|
||||||
|
Divider,
|
||||||
|
InputAdornment,
|
||||||
|
useMediaQuery
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||||
|
import AcUnitIcon from '@mui/icons-material/AcUnit';
|
||||||
|
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
|
||||||
|
import StorageIcon from '@mui/icons-material/Storage';
|
||||||
|
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
|
||||||
|
import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined';
|
||||||
|
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
|
||||||
|
import OpenWithRoundedIcon from '@mui/icons-material/OpenWithRounded';
|
||||||
|
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
|
||||||
|
const zoneColor = (zone) => {
|
||||||
|
if (zone === 'C') return { color: '#00838F', bg: '#E0F7FA' };
|
||||||
|
if (zone === 'D') return { color: '#D93025', bg: '#FCE8E6' };
|
||||||
|
if (zone === 'B') return { color: '#8E24AA', bg: '#F3E5F5' };
|
||||||
|
return { color: '#1A73E8', bg: '#E8F0FE' };
|
||||||
|
};
|
||||||
|
const onShelf = (s) => s === 'On Shelf';
|
||||||
|
|
||||||
|
const SHELVES = ['Zone A (Shelf 1)', 'Zone A (Shelf 2)', 'Zone B (Shelf 1)', 'Zone B (Shelf 2)', 'Zone C (Cold Room)', 'Exception Area'];
|
||||||
|
|
||||||
|
const INITIAL_INVENTORY = [
|
||||||
|
{ id: 'DM-882201', customer: 'Acme Corp', weight: '2.4 kg', shelf: 'Zone A (Shelf 1)', zone: 'A', status: 'On Shelf' },
|
||||||
|
{ id: 'DM-882202', customer: 'BioPharma India', weight: '1.2 kg', shelf: 'Zone C (Cold Room)', zone: 'C', status: 'On Shelf' },
|
||||||
|
{ id: 'DM-882203', customer: 'Rajesh Textiles', weight: '8.5 kg', shelf: 'Exception Area', zone: 'D', status: 'Under Review' },
|
||||||
|
{ id: 'DM-109241', customer: 'Urban Fashion', weight: '3.1 kg', shelf: 'Zone B (Shelf 1)', zone: 'B', status: 'On Shelf' },
|
||||||
|
{ id: 'DM-402941', customer: 'Astro Retail', weight: '0.5 kg', shelf: 'Zone A (Shelf 2)', zone: 'A', status: 'On Shelf' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Inventory() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
const [inventory, setInventory] = useState(INITIAL_INVENTORY);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Relocation modal state
|
||||||
|
const [editItem, setEditItem] = useState(null);
|
||||||
|
const [newShelf, setNewShelf] = useState('');
|
||||||
|
|
||||||
|
const handleOpenRelocate = (item) => {
|
||||||
|
setEditItem(item);
|
||||||
|
setNewShelf(item.shelf);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRelocate = () => {
|
||||||
|
if (!editItem) return;
|
||||||
|
|
||||||
|
// Determine new zone label
|
||||||
|
let zoneLabel = 'A';
|
||||||
|
if (newShelf.includes('Zone B')) zoneLabel = 'B';
|
||||||
|
else if (newShelf.includes('Zone C') || newShelf.includes('Cold')) zoneLabel = 'C';
|
||||||
|
else if (newShelf.includes('Exception')) zoneLabel = 'D';
|
||||||
|
|
||||||
|
setInventory((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === editItem.id ? { ...item, shelf: newShelf, zone: zoneLabel } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setEditItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter inventory list
|
||||||
|
const filteredInventory = inventory.filter(
|
||||||
|
(item) =>
|
||||||
|
item.id.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.customer.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.shelf.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<PageHeader
|
||||||
|
icon={StorageIcon}
|
||||||
|
title="Storage Shelves"
|
||||||
|
subtitle="See which shelf every parcel is sitting on, move parcels between shelves, and keep an eye on the cold room."
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Environmental Sensors for Cold-Chain Zone C */}
|
||||||
|
<Grid container spacing={{ xs: 2, md: 3 }} sx={{ mb: 4 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Cold Room Temperature', value: '4.2°C', icon: AcUnitIcon, color: '#00838F', bg: '#E0F7FA', chip: 'All good · Within safe range', chipColor: 'success' },
|
||||||
|
{ label: 'Storage Space', value: '120 Shelves', icon: ViewModuleIcon, color: '#1A73E8', bg: '#E8F0FE', chip: '28% full', chipColor: 'primary' },
|
||||||
|
{ label: 'Needs Attention', value: '1 Parcel', icon: LocalOfferIcon, color: '#D93025', bg: '#FCE8E6', chip: 'Waiting to be checked', chipColor: 'error' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
|
||||||
|
<Card elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1', height: '100%' }}>
|
||||||
|
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2.5} sx={{ mb: 2 }}>
|
||||||
|
<Avatar variant="rounded" sx={{ bgcolor: s.bg, color: s.color, borderRadius: 2.5, width: 48, height: 48, flexShrink: 0 }}>
|
||||||
|
<s.icon sx={{ fontSize: 24 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Typography sx={{ fontSize: '0.8rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', lineHeight: 1.4 }}>
|
||||||
|
{s.label}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography sx={{ fontSize: '2rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.15, mb: 1 }}>{s.value}</Typography>
|
||||||
|
<Chip size="small" label={s.chip} color={s.chipColor} sx={{ fontWeight: 600 }} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Main Inventory Board */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="What's On Our Shelves"
|
||||||
|
subheader="Find a parcel and move it to a different shelf if needed"
|
||||||
|
action={
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search parcel or name…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
sx={{ width: { xs: 160, sm: 220, md: 280 }, '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
|
||||||
|
InputProps={{ startAdornment: <InputAdornment position="start"><SearchOutlinedIcon sx={{ fontSize: 20, color: '#9AA0A6' }} /></InputAdornment> }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{isMdDown ? (
|
||||||
|
/* ── MOBILE / TABLET: cards ── */
|
||||||
|
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{filteredInventory.map((item) => {
|
||||||
|
const zc = zoneColor(item.zone);
|
||||||
|
return (
|
||||||
|
<Card key={item.id} elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1' }}>
|
||||||
|
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }} gap={1}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1.5} sx={{ minWidth: 0 }}>
|
||||||
|
<Avatar variant="rounded" sx={{ bgcolor: zc.bg, color: zc.color, borderRadius: 2, width: 40, height: 40 }}>
|
||||||
|
<WarehouseOutlinedIcon sx={{ fontSize: 21 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{item.id}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" noWrap>{item.customer}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Chip size="small" label={item.status} sx={{ fontWeight: 700, flexShrink: 0, bgcolor: onShelf(item.status) ? '#E6F4EA' : '#FEF7E0', color: onShelf(item.status) ? '#1E8E3E' : '#B06000' }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mb: 2 }}>
|
||||||
|
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={item.shelf}
|
||||||
|
sx={{ bgcolor: zc.bg, color: zc.color, fontWeight: 700, '& .MuiChip-icon': { color: zc.color } }} />
|
||||||
|
<Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={item.weight}
|
||||||
|
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button fullWidth size="small" variant="outlined" startIcon={<OpenWithRoundedIcon sx={{ fontSize: 16 }} />}
|
||||||
|
onClick={() => handleOpenRelocate(item)} sx={{ borderRadius: 2, fontWeight: 700 }}>
|
||||||
|
Move to another shelf
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredInventory.length === 0 && (
|
||||||
|
<Box sx={{ py: 5, textAlign: 'center', color: 'text.secondary' }}>No parcels found. Try a different search.</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
/* ── DESKTOP: spacious table ── */
|
||||||
|
<TableContainer sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table sx={{ minWidth: 760 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
|
||||||
|
{['Parcel', 'On Shelf', 'Weight', 'Status', 'Action'].map((h, i) => (
|
||||||
|
<TableCell key={h} align={i === 4 ? 'right' : 'left'}
|
||||||
|
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 2, borderBottom: '1px solid #ECEEF1', whiteSpace: 'nowrap' }}>
|
||||||
|
{h}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{filteredInventory.map((item) => {
|
||||||
|
const zc = zoneColor(item.zone);
|
||||||
|
return (
|
||||||
|
<TableRow key={item.id} hover sx={{ '& td': { borderBottom: '1px solid #F4F6F8', py: 2.25 }, '&:last-child td': { border: 0 } }}>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" alignItems="center" gap={3.5}>
|
||||||
|
<Avatar variant="rounded" sx={{ bgcolor: zc.bg, color: zc.color, borderRadius: 2, width: 40, height: 40 }}>
|
||||||
|
<WarehouseOutlinedIcon sx={{ fontSize: 21 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{item.id}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{item.customer}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={item.shelf}
|
||||||
|
sx={{ bgcolor: zc.bg, color: zc.color, fontWeight: 700, whiteSpace: 'nowrap', '& .MuiChip-icon': { color: zc.color } }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600, color: '#495057', whiteSpace: 'nowrap' }}>{item.weight}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={item.status}
|
||||||
|
sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: onShelf(item.status) ? '#E6F4EA' : '#FEF7E0', color: onShelf(item.status) ? '#1E8E3E' : '#B06000' }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button size="small" variant="outlined" startIcon={<OpenWithRoundedIcon sx={{ fontSize: 16 }} />}
|
||||||
|
onClick={() => handleOpenRelocate(item)} sx={{ borderRadius: 2, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||||
|
Move
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredInventory.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} align="center" sx={{ py: 5, color: 'text.secondary' }}>
|
||||||
|
No parcels found. Try a different search.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Relocate Dialog */}
|
||||||
|
<Dialog open={Boolean(editItem)} onClose={() => setEditItem(null)} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle sx={{ fontWeight: 700 }}>Move Parcel {editItem?.id}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Choose a new shelf for <strong>{editItem?.id}</strong> (from <strong>{editItem?.customer}</strong>).
|
||||||
|
</Typography>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<Select value={newShelf} onChange={(e) => setNewShelf(e.target.value)}>
|
||||||
|
{SHELVES.map((shelf) => (
|
||||||
|
<MenuItem key={shelf} value={shelf}>{shelf}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setEditItem(null)}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleConfirmRelocate}>
|
||||||
|
Move Parcel
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
src/pages/operations/OrderAssignment.jsx
Normal file
317
src/pages/operations/OrderAssignment.jsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Avatar,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
ListItemAvatar,
|
||||||
|
Radio,
|
||||||
|
Badge,
|
||||||
|
Checkbox,
|
||||||
|
useMediaQuery
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import AssignmentIndIcon from '@mui/icons-material/AssignmentInd';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import TwoWheelerIcon from '@mui/icons-material/TwoWheeler';
|
||||||
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
|
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||||
|
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
|
||||||
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
|
import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded';
|
||||||
|
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
|
||||||
|
const UNASSIGNED_ORDERS = [
|
||||||
|
{ id: 'PICK-991', customer: 'Ramesh K.', pickup: 'Dwarka Sec 12, Delhi', drop: 'Mumbai', package: 'Small Box, 2kg', time: '10 mins ago', status: 'Pending Assignment' },
|
||||||
|
{ id: 'PICK-992', customer: 'Anita P.', pickup: 'Saket, Delhi', drop: 'Hyderabad', package: 'Document, 0.5kg', time: '15 mins ago', status: 'Pending Assignment' },
|
||||||
|
{ id: 'PICK-993', customer: 'Suresh V.', pickup: 'Rohini, Delhi', drop: 'Bengaluru', package: 'Large Box, 12kg', time: '1 hour ago', status: 'Pending Assignment' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const AVAILABLE_MILERS = [
|
||||||
|
{ id: 'M-101', name: 'Deepak Sharma', vehicle: 'Two-Wheeler', area: 'Dwarka', distance: '1.2 km away' },
|
||||||
|
{ id: 'M-102', name: 'Karthik S.', vehicle: 'EV-Rickshaw', area: 'Saket', distance: '2.5 km away' },
|
||||||
|
{ id: 'M-103', name: 'Sanjay R.', vehicle: 'Mini Truck', area: 'Rohini', distance: '0.8 km away' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OrderAssignment() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
const [orders, setOrders] = useState(UNASSIGNED_ORDERS);
|
||||||
|
const [selectedOrders, setSelectedOrders] = useState([]);
|
||||||
|
|
||||||
|
// Single Assign Dialog State
|
||||||
|
const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null);
|
||||||
|
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
|
||||||
|
const [selectedMiler, setSelectedMiler] = useState('');
|
||||||
|
|
||||||
|
const handleSelectAll = (event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
const pendingIds = orders.filter(o => o.status === 'Pending Assignment').map(o => o.id);
|
||||||
|
setSelectedOrders(pendingIds);
|
||||||
|
} else {
|
||||||
|
setSelectedOrders([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectOne = (id) => {
|
||||||
|
if (selectedOrders.includes(id)) {
|
||||||
|
setSelectedOrders(selectedOrders.filter(selectedId => selectedId !== id));
|
||||||
|
} else {
|
||||||
|
setSelectedOrders([...selectedOrders, id]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAssign = (order) => {
|
||||||
|
setSelectedOrderForAssign(order);
|
||||||
|
setSelectedMiler('');
|
||||||
|
setAssignDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssign = () => {
|
||||||
|
if (!selectedMiler) return;
|
||||||
|
const miler = AVAILABLE_MILERS.find(m => m.id === selectedMiler);
|
||||||
|
|
||||||
|
setOrders(orders.map(o => {
|
||||||
|
if (o.id === selectedOrderForAssign.id) {
|
||||||
|
return { ...o, status: `Assigned to ${miler.name}`, assignedMiler: miler };
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Remove from selection if it was selected
|
||||||
|
setSelectedOrders(prev => prev.filter(id => id !== selectedOrderForAssign.id));
|
||||||
|
setAssignDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoAssignAll = () => {
|
||||||
|
if (selectedOrders.length === 0) return;
|
||||||
|
|
||||||
|
setOrders(orders.map(o => {
|
||||||
|
if (selectedOrders.includes(o.id) && o.status === 'Pending Assignment') {
|
||||||
|
// Just pick a random miler for auto-assign simulation
|
||||||
|
const randomMiler = AVAILABLE_MILERS[Math.floor(Math.random() * AVAILABLE_MILERS.length)];
|
||||||
|
return { ...o, status: `Assigned to ${randomMiler.name}`, assignedMiler: randomMiler };
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
setSelectedOrders([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingCount = orders.filter(o => o.status === 'Pending Assignment').length;
|
||||||
|
const isAllSelected = selectedOrders.length > 0 && selectedOrders.length === pendingCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<PageHeader
|
||||||
|
icon={AssignmentIndIcon}
|
||||||
|
title="Pickup Requests"
|
||||||
|
subtitle="Customers want these parcels collected. Pick a nearby miler for each one, or select several and assign them all at once."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Waiting for a Miler"
|
||||||
|
subheader="New pickup requests around Delhi"
|
||||||
|
avatar={<Avatar variant="rounded" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', borderRadius: 2 }}><AssignmentIndIcon /></Avatar>}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant={selectedOrders.length === 0 ? 'outlined' : 'contained'}
|
||||||
|
color="primary"
|
||||||
|
startIcon={<AutoAwesomeIcon />}
|
||||||
|
onClick={handleAutoAssignAll}
|
||||||
|
disabled={selectedOrders.length === 0}
|
||||||
|
sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{selectedOrders.length === 0 ? 'Auto-Assign' : `Auto-Assign (${selectedOrders.length})`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{isMdDown ? (
|
||||||
|
/* ── MOBILE / TABLET: cards ── */
|
||||||
|
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{orders.map((row) => {
|
||||||
|
const isSelected = selectedOrders.includes(row.id);
|
||||||
|
const isAssigned = row.status !== 'Pending Assignment';
|
||||||
|
return (
|
||||||
|
<Card key={row.id} elevation={0}
|
||||||
|
sx={{ borderRadius: 2, border: '1px solid', borderColor: isSelected ? 'primary.main' : '#ECEEF1', bgcolor: isAssigned ? '#FAFBFC' : '#fff' }}>
|
||||||
|
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1} sx={{ mb: 1.5 }}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}>
|
||||||
|
{!isAssigned && (
|
||||||
|
<Checkbox size="small" sx={{ p: 0 }} checked={isSelected} onChange={() => handleSelectOne(row.id)} />
|
||||||
|
)}
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{row.id}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{row.time}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
{!isAssigned
|
||||||
|
? <Chip size="small" label="Needs a miler" sx={{ fontWeight: 700, flexShrink: 0, bgcolor: '#FEF7E0', color: '#B06000' }} />
|
||||||
|
: <Chip size="small" icon={<CheckCircleIcon sx={{ fontSize: '15px !important' }} />} label="Assigned" sx={{ fontWeight: 700, flexShrink: 0, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1} sx={{ p: 1.5, bgcolor: '#FAFBFC', borderRadius: 2, mb: 1.5 }}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1}>
|
||||||
|
<PersonOutlineRoundedIcon sx={{ fontSize: 17, color: '#9AA0A6' }} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{row.customer}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1}>
|
||||||
|
<PlaceOutlinedIcon sx={{ fontSize: 17, color: '#1E8E3E' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary"><b>Pick up:</b> {row.pickup}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1}>
|
||||||
|
<FlagOutlinedIcon sx={{ fontSize: 17, color: '#C01227' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary"><b>Going to:</b> {row.drop}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1}>
|
||||||
|
<Inventory2OutlinedIcon sx={{ fontSize: 17, color: '#9AA0A6' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">{row.package}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!isAssigned ? (
|
||||||
|
<Button fullWidth size="small" variant="contained" startIcon={<TwoWheelerIcon sx={{ fontSize: 16 }} />}
|
||||||
|
onClick={() => handleOpenAssign(row)} sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none' }}>
|
||||||
|
Choose a Miler
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ textAlign: 'center', fontWeight: 700, color: '#1E8E3E' }}>{row.status}</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
/* ── DESKTOP: spacious table ── */
|
||||||
|
<TableContainer sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table sx={{ minWidth: 820 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
|
||||||
|
<TableCell padding="checkbox">
|
||||||
|
<Checkbox
|
||||||
|
indeterminate={selectedOrders.length > 0 && selectedOrders.length < pendingCount}
|
||||||
|
checked={isAllSelected && pendingCount > 0}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
disabled={pendingCount === 0}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
{['Request', 'Pick Up From', 'Going To', 'Parcel', 'Status', 'Action'].map((h, i) => (
|
||||||
|
<TableCell key={h} align={i === 5 ? 'right' : 'left'}
|
||||||
|
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 2, borderBottom: '1px solid #ECEEF1', whiteSpace: 'nowrap' }}>
|
||||||
|
{h}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{orders.map((row) => {
|
||||||
|
const isSelected = selectedOrders.includes(row.id);
|
||||||
|
const isAssigned = row.status !== 'Pending Assignment';
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id} selected={isSelected} hover
|
||||||
|
sx={{ bgcolor: isAssigned ? '#FAFBFC' : 'inherit', '& td': { borderBottom: '1px solid #F4F6F8', py: 2 }, '&:last-child td': { border: 0 } }}>
|
||||||
|
<TableCell padding="checkbox">
|
||||||
|
<Checkbox checked={isSelected} onChange={() => handleSelectOne(row.id)} disabled={isAssigned} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', whiteSpace: 'nowrap' }}>{row.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={700}>{row.customer}</Typography>
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||||
|
<PlaceOutlinedIcon sx={{ fontSize: 14, color: '#1E8E3E' }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">{row.pickup}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600, color: '#495057' }}>{row.drop}</TableCell>
|
||||||
|
<TableCell sx={{ color: '#495057' }}>{row.package}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{!isAssigned
|
||||||
|
? <Chip size="small" label="Needs a miler" sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: '#FEF7E0', color: '#B06000' }} />
|
||||||
|
: <Chip size="small" icon={<CheckCircleIcon sx={{ fontSize: '15px !important' }} />} label="Assigned" sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{!isAssigned ? (
|
||||||
|
<Button variant="outlined" size="small" startIcon={<TwoWheelerIcon sx={{ fontSize: 16 }} />}
|
||||||
|
onClick={() => handleOpenAssign(row)} sx={{ borderRadius: 2, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||||
|
Choose Miler
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1E8E3E', whiteSpace: 'nowrap' }}>{row.status}</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={assignDialogOpen} onClose={() => setAssignDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Choose a Miler for {selectedOrderForAssign?.id}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 2 }}>Available milers near {selectedOrderForAssign?.pickup}:</Typography>
|
||||||
|
<List>
|
||||||
|
{AVAILABLE_MILERS.map((miler) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={miler.id}
|
||||||
|
onClick={() => setSelectedMiler(miler.id)}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: selectedMiler === miler.id ? 'primary.main' : 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
mb: 1,
|
||||||
|
bgcolor: selectedMiler === miler.id ? 'primary.lighter' : 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Badge color="success" variant="dot" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
||||||
|
<Avatar sx={{ bgcolor: 'grey.200', color: 'grey.700' }}><TwoWheelerIcon /></Avatar>
|
||||||
|
</Badge>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={miler.name}
|
||||||
|
secondary={`${miler.vehicle} • ${miler.distance}`}
|
||||||
|
primaryTypographyProps={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Radio checked={selectedMiler === miler.id} onChange={() => setSelectedMiler(miler.id)} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setAssignDialogOpen(false)} color="inherit">Cancel</Button>
|
||||||
|
<Button onClick={handleAssign} variant="contained" disabled={!selectedMiler}>Confirm Assignment</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
844
src/pages/operations/RiderRoutes.jsx
Normal file
844
src/pages/operations/RiderRoutes.jsx
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Typography, Card, CardContent, Avatar, Chip, Stack, Button, Grid,
|
||||||
|
IconButton, List, ListItemButton, ListItemText, Collapse, Tooltip, Divider,
|
||||||
|
LinearProgress, Menu, MenuItem, Drawer, Paper
|
||||||
|
} from '@mui/material';
|
||||||
|
import { alpha } from '@mui/material/styles';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip, useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
||||||
|
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
||||||
|
import PauseRoundedIcon from '@mui/icons-material/PauseRounded';
|
||||||
|
import ReplayRoundedIcon from '@mui/icons-material/ReplayRounded';
|
||||||
|
import SpeedRoundedIcon from '@mui/icons-material/SpeedRounded';
|
||||||
|
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||||
|
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
||||||
|
import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded';
|
||||||
|
import ExpandLessRoundedIcon from '@mui/icons-material/ExpandLessRounded';
|
||||||
|
import WarehouseRoundedIcon from '@mui/icons-material/WarehouseRounded';
|
||||||
|
import FlagRoundedIcon from '@mui/icons-material/FlagRounded';
|
||||||
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
|
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
||||||
|
import CancelRoundedIcon from '@mui/icons-material/CancelRounded';
|
||||||
|
import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded';
|
||||||
|
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||||
|
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||||
|
import StraightenRoundedIcon from '@mui/icons-material/StraightenRounded';
|
||||||
|
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||||
|
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||||
|
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||||
|
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
|
||||||
|
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
|
||||||
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined';
|
||||||
|
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
|
||||||
|
import NotesOutlinedIcon from '@mui/icons-material/NotesOutlined';
|
||||||
|
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
|
||||||
|
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined';
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Leaflet base-icon fix (same approach as TrackingMap.jsx)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keeps Leaflet's canvas sized correctly inside a flex layout / on sidebar toggle.
|
||||||
|
function MapResizeHandler() {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
const fix = () => map.invalidateSize();
|
||||||
|
const t = setTimeout(fix, 250);
|
||||||
|
window.addEventListener('resize', fix);
|
||||||
|
const ro = new ResizeObserver(fix);
|
||||||
|
ro.observe(map.getContainer());
|
||||||
|
return () => { clearTimeout(t); window.removeEventListener('resize', fix); ro.disconnect(); };
|
||||||
|
}, [map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imperatively fits the map to a set of latlng points whenever they change.
|
||||||
|
function FitBounds({ points }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!points || points.length === 0) return;
|
||||||
|
const bounds = L.latLngBounds(points.map((p) => [p.lat, p.lng]));
|
||||||
|
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15 });
|
||||||
|
}, [points, map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pans/zooms to a single point when an order is focused (clicked).
|
||||||
|
function FlyTo({ target }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (target) map.flyTo([target.lat, target.lng], Math.max(map.getZoom(), 14), { duration: 0.6 });
|
||||||
|
}, [target, map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Leaflet DivIcons
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
const hubIcon = new L.DivIcon({
|
||||||
|
className: 'rr-icon',
|
||||||
|
html: `<div style="background:#C01227;width:34px;height:34px;border-radius:10px;border:3px solid #fff;box-shadow:0 4px 12px rgba(0,0,0,.25);display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg fill="#fff" width="18" height="18" viewBox="0 0 24 24"><path d="M12 3 2 8l10 5 10-5-10-5Zm0 7L4.21 6.11 12 2.22l7.79 3.89L12 10Z"/><path d="M2 12l10 5 10-5M2 16l10 5 10-5" stroke="#fff" stroke-width="1.6" fill="none"/></svg>
|
||||||
|
</div>`,
|
||||||
|
iconSize: [34, 34],
|
||||||
|
iconAnchor: [17, 17],
|
||||||
|
});
|
||||||
|
|
||||||
|
const flagIcon = new L.DivIcon({
|
||||||
|
className: 'rr-icon',
|
||||||
|
html: `<div style="background:#1E8E3E;width:30px;height:30px;border-radius:50%;border:3px solid #fff;box-shadow:0 4px 10px rgba(0,0,0,.25);display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg fill="#fff" width="15" height="15" viewBox="0 0 24 24"><path d="M14.4 6 14 4H5v17h2v-7h5.6l.4 2h7V6z"/></svg>
|
||||||
|
</div>`,
|
||||||
|
iconSize: [30, 30],
|
||||||
|
iconAnchor: [15, 15],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Numbered stop pin in the rider's colour. Pickups get a square pin, deliveries a
|
||||||
|
// round one — so the two journey types are distinguishable at a glance on the map.
|
||||||
|
const stopIcon = (n, color, focused, isPickup) => new L.DivIcon({
|
||||||
|
className: 'rr-icon',
|
||||||
|
html: `<div style="background:${color};width:${focused ? 30 : 24}px;height:${focused ? 30 : 24}px;border-radius:${isPickup ? '6px' : '50%'};border:2.5px solid #fff;box-shadow:0 3px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;color:#fff;font-family:Arial,sans-serif;font-weight:700;font-size:${focused ? 14 : 12}px;">${n}</div>`,
|
||||||
|
iconSize: focused ? [30, 30] : [24, 24],
|
||||||
|
iconAnchor: focused ? [15, 15] : [12, 12],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The animated "moving rider" puck in the rider's colour.
|
||||||
|
const moverIcon = (color) => new L.DivIcon({
|
||||||
|
className: 'rr-icon rr-mover',
|
||||||
|
html: `<div style="background:${color};width:30px;height:30px;border-radius:50%;border:3px solid #fff;box-shadow:0 0 0 6px ${color}33, 0 4px 12px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg fill="#fff" width="17" height="17" viewBox="0 0 24 24"><path d="M19 7c0-1.1-.9-2-2-2h-3v2h3v2.65L13.52 14H10V9H6c-2.21 0-4 1.79-4 4v3h2c0 1.66 1.34 3 3 3s3-1.34 3-3h4.48L19 10.35V7ZM7 17c-.55 0-1-.45-1-1h2c0 .55-.45 1-1 1Z"/></svg>
|
||||||
|
</div>`,
|
||||||
|
iconSize: [30, 30],
|
||||||
|
iconAnchor: [15, 15],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Mock data — every rider has a DELIVERY trip and a PICKUP trip for the day, each
|
||||||
|
// with its own ordered stops, timings and distance. Every order stop carries the
|
||||||
|
// full detail shown in the side drawer when clicked.
|
||||||
|
//
|
||||||
|
// Delivery: hub → customer drops. Pickup: merchant collections → hub.
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
const HUB = { lat: 28.6139, lng: 77.2090, label: 'Delhi Operations Hub' };
|
||||||
|
const hubStop = (time) => ({ kind: 'hub', label: HUB.label, lat: HUB.lat, lng: HUB.lng, time });
|
||||||
|
|
||||||
|
const RIDERS = [
|
||||||
|
{
|
||||||
|
id: 'RDR-8012', name: 'Muthu Kumar', color: '#1A73E8', vehicle: 'Electric Bike', vehicleNo: 'DL-04-EB-1234', phone: '+91 98765 43210',
|
||||||
|
delivery: {
|
||||||
|
startTime: '08:12', endTime: '13:40', distanceKm: 18.4,
|
||||||
|
stops: [
|
||||||
|
hubStop('08:12'),
|
||||||
|
{ kind: 'order', orderId: 'ORD-100231', customer: 'Aarav Mehta', phone: '+91 90011 22334', address: 'Flat 402, Dwarka Sector 12, New Delhi', lat: 28.5921, lng: 77.0460, time: '08:48', status: 'Picked', items: 2, weight: '1.4 kg', slot: '08:00–10:00', cod: 0, payment: 'Prepaid', legKm: 5.2, instructions: 'Leave at reception if not home.' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100244', customer: 'Priya Nair', phone: '+91 90022 33445', address: 'House 18, Janakpuri B-Block, New Delhi', lat: 28.6219, lng: 77.0878, time: '09:36', status: 'Picked', items: 1, weight: '0.6 kg', slot: '09:00–11:00', cod: 1200, payment: 'COP', legKm: 4.1, instructions: 'Call on arrival.' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100258', customer: 'Rohit Sethi', phone: '+91 90033 44556', address: 'Shop 7, Rajouri Garden Market, New Delhi', lat: 28.6492, lng: 77.1207, time: '10:25', status: 'Failed', items: 3, weight: '2.8 kg', slot: '10:00–12:00', cod: 0, payment: 'Prepaid', legKm: 4.6, instructions: 'Customer unreachable — reattempt tomorrow.' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100269', customer: 'Sana Kapoor', phone: '+91 90044 55667', address: 'A-22, Karol Bagh, New Delhi', lat: 28.6512, lng: 77.1907, time: '11:30', status: 'Picked', items: 1, weight: '0.9 kg', slot: '11:00–13:00', cod: 850, payment: 'COP', legKm: 4.5, instructions: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pickup: {
|
||||||
|
startTime: '14:10', endTime: '16:50', distanceKm: 11.2,
|
||||||
|
stops: [
|
||||||
|
{ kind: 'order', orderId: 'PCK-50118', customer: 'TrendKart Store', phone: '+91 98801 11222', address: 'Tilak Nagar Main Rd, New Delhi', lat: 28.6363, lng: 77.0945, time: '14:35', status: 'Picked', items: 6, weight: '4.2 kg', slot: '14:00–16:00', cod: 0, payment: 'Merchant', legKm: 6.0, instructions: 'Collect from back gate.' },
|
||||||
|
{ kind: 'order', orderId: 'PCK-50126', customer: 'FreshLeaf Organics', phone: '+91 98802 22333', address: 'Subhash Nagar, New Delhi', lat: 28.6404, lng: 77.1199, time: '15:30', status: 'Picked', items: 3, weight: '5.5 kg', slot: '15:00–17:00', cod: 0, payment: 'Merchant', legKm: 3.0, instructions: '' },
|
||||||
|
{ kind: 'order', orderId: 'PCK-50131', customer: 'GadgetHub', phone: '+91 98803 33444', address: 'Moti Nagar, New Delhi', lat: 28.6580, lng: 77.1450, time: '16:15', status: 'Missed', items: 2, weight: '1.1 kg', slot: '16:00–18:00', cod: 0, payment: 'Merchant', legKm: 2.2, instructions: 'Shop was closed at pickup time.' },
|
||||||
|
hubStop('16:50'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'RDR-8041', name: 'Sana Sheikh', color: '#8E24AA', vehicle: 'Motorcycle', vehicleNo: 'DL-02-MC-1199', phone: '+91 98765 43214',
|
||||||
|
delivery: {
|
||||||
|
startTime: '08:00', endTime: '14:05', distanceKm: 22.9,
|
||||||
|
stops: [
|
||||||
|
hubStop('08:00'),
|
||||||
|
{ kind: 'order', orderId: 'ORD-100277', customer: 'Imran Qureshi', phone: '+91 90055 66778', address: 'N-Block, Connaught Place, New Delhi', lat: 28.6315, lng: 77.2167, time: '08:30', status: 'Picked', items: 1, weight: '0.4 kg', slot: '08:00–10:00', cod: 0, payment: 'Prepaid', legKm: 2.1, instructions: '' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100283', customer: 'Neha Gupta', phone: '+91 90066 77889', address: 'C-44, Lajpat Nagar II, New Delhi', lat: 28.5677, lng: 77.2433, time: '09:20', status: 'Picked', items: 2, weight: '1.7 kg', slot: '09:00–11:00', cod: 1450, payment: 'COP', legKm: 7.4, instructions: 'Ring twice.' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100291', customer: 'Vikas Rao', phone: '+91 90077 88990', address: 'J-Block, Saket, New Delhi', lat: 28.5245, lng: 77.2066, time: '10:40', status: 'Picked', items: 1, weight: '0.8 kg', slot: '10:00–12:00', cod: 0, payment: 'Prepaid', legKm: 6.2, instructions: '' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100305', customer: 'Diya Shah', phone: '+91 90088 99001', address: 'Malviya Nagar Main Market, New Delhi', lat: 28.5355, lng: 77.2110, time: '11:55', status: 'Picked', items: 4, weight: '3.1 kg', slot: '11:00–13:00', cod: 2300, payment: 'COP', legKm: 1.4, instructions: 'Heavy parcel — handle with care.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pickup: {
|
||||||
|
startTime: '14:30', endTime: '17:20', distanceKm: 14.6,
|
||||||
|
stops: [
|
||||||
|
{ kind: 'order', orderId: 'PCK-50140', customer: 'Bloom & Co Florists', phone: '+91 98804 44555', address: 'Greater Kailash I, New Delhi', lat: 28.5494, lng: 77.2426, time: '15:00', status: 'Picked', items: 4, weight: '2.0 kg', slot: '14:30–16:30', cod: 0, payment: 'Merchant', legKm: 8.1, instructions: '' },
|
||||||
|
{ kind: 'order', orderId: 'PCK-50147', customer: 'BookNook', phone: '+91 98805 55666', address: 'Hauz Khas Village, New Delhi', lat: 28.5535, lng: 77.1944, time: '16:05', status: 'Picked', items: 9, weight: '7.8 kg', slot: '15:30–17:30', cod: 0, payment: 'Merchant', legKm: 4.5, instructions: 'Multiple boxes.' },
|
||||||
|
hubStop('17:20'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'RDR-8015', name: 'Rajesh Sharma', color: '#1E8E3E', vehicle: 'Cargo Van', vehicleNo: 'DL-01-CV-9876', phone: '+91 98765 43211',
|
||||||
|
delivery: {
|
||||||
|
startTime: '07:50', endTime: '15:10', distanceKm: 31.2,
|
||||||
|
stops: [
|
||||||
|
hubStop('07:50'),
|
||||||
|
{ kind: 'order', orderId: 'ORD-100312', customer: 'Anil Verma', phone: '+91 90099 00112', address: 'Sector 7, Rohini, New Delhi', lat: 28.7042, lng: 77.1025, time: '08:55', status: 'Picked', items: 5, weight: '6.2 kg', slot: '08:00–10:00', cod: 0, payment: 'Prepaid', legKm: 12.4, instructions: '' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100320', customer: 'Meera Iyer', phone: '+91 90100 11223', address: 'NSP, Pitampura, New Delhi', lat: 28.6996, lng: 77.1314, time: '09:50', status: 'Picked', items: 2, weight: '2.0 kg', slot: '09:00–11:00', cod: 990, payment: 'COP', legKm: 3.2, instructions: '' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100334', customer: 'Sahil Khan', phone: '+91 90111 22334', address: 'Model Town III, New Delhi', lat: 28.7158, lng: 77.1910, time: '11:10', status: 'Failed', items: 1, weight: '0.7 kg', slot: '10:00–12:00', cod: 0, payment: 'Prepaid', legKm: 6.1, instructions: 'Wrong address provided.' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100349', customer: 'Tara Bose', phone: '+91 90122 33445', address: 'Civil Lines, New Delhi', lat: 28.6796, lng: 77.2240, time: '12:30', status: 'Picked', items: 3, weight: '4.4 kg', slot: '12:00–14:00', cod: 3100, payment: 'COP', legKm: 5.0, instructions: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pickup: {
|
||||||
|
startTime: '15:40', endTime: '18:30', distanceKm: 19.8,
|
||||||
|
stops: [
|
||||||
|
{ kind: 'order', orderId: 'PCK-50155', customer: 'MegaMart Warehouse', phone: '+91 98806 66777', address: 'Wazirpur Industrial Area, New Delhi', lat: 28.6991, lng: 77.1612, time: '16:20', status: 'Picked', items: 24, weight: '38.0 kg', slot: '16:00–18:00', cod: 0, payment: 'Merchant', legKm: 11.0, instructions: 'Use loading dock 4.' },
|
||||||
|
{ kind: 'order', orderId: 'PCK-50163', customer: 'HomeStyle Furnishings', phone: '+91 98807 77888', address: 'Ashok Vihar, New Delhi', lat: 28.6924, lng: 77.1760, time: '17:25', status: 'Picked', items: 8, weight: '22.5 kg', slot: '17:00–19:00', cod: 0, payment: 'Merchant', legKm: 3.0, instructions: '' },
|
||||||
|
hubStop('18:30'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'RDR-8044', name: 'Harpreet Gill', color: '#E8710A', vehicle: 'Cycle', vehicleNo: '—', phone: '+91 98765 43215',
|
||||||
|
delivery: {
|
||||||
|
startTime: '09:20', endTime: '13:15', distanceKm: 9.7,
|
||||||
|
stops: [
|
||||||
|
hubStop('09:20'),
|
||||||
|
{ kind: 'order', orderId: 'ORD-100356', customer: 'Kabir Anand', phone: '+91 90133 44556', address: 'Mayur Vihar Phase I, New Delhi', lat: 28.6090, lng: 77.2920, time: '10:05', status: 'Picked', items: 1, weight: '0.5 kg', slot: '10:00–12:00', cod: 600, payment: 'COP', legKm: 9.0, instructions: '' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100361', customer: 'Ritu Saxena', phone: '+91 90144 55667', address: 'Mayur Vihar Phase III, New Delhi', lat: 28.6135, lng: 77.3215, time: '11:10', status: 'Picked', items: 2, weight: '1.2 kg', slot: '11:00–13:00', cod: 0, payment: 'Prepaid', legKm: 3.0, instructions: '' },
|
||||||
|
{ kind: 'order', orderId: 'ORD-100370', customer: 'Farhan Ali', phone: '+91 90155 66778', address: 'Patparganj, New Delhi', lat: 28.6280, lng: 77.2960, time: '12:20', status: 'Picked', items: 1, weight: '0.8 kg', slot: '12:00–14:00', cod: 450, payment: 'COP', legKm: 2.9, instructions: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pickup: {
|
||||||
|
startTime: '13:40', endTime: '15:30', distanceKm: 6.4,
|
||||||
|
stops: [
|
||||||
|
{ kind: 'order', orderId: 'PCK-50170', customer: 'Cafe Mosaic', phone: '+91 98808 88999', address: 'Mayur Vihar Phase I Market, New Delhi', lat: 28.6055, lng: 77.2985, time: '14:10', status: 'Picked', items: 2, weight: '1.5 kg', slot: '14:00–16:00', cod: 0, payment: 'Merchant', legKm: 4.0, instructions: '' },
|
||||||
|
hubStop('15:30'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
const initials = (n) => n.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
|
||||||
|
const inr = (n) => `₹${(Number(n) || 0).toLocaleString('en-IN')}`;
|
||||||
|
const lerpPoint = (a, b, t) => ({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t });
|
||||||
|
|
||||||
|
const STATUS_META = {
|
||||||
|
Picked: { color: '#1A73E8', icon: CheckCircleRoundedIcon, label: 'Picked up' },
|
||||||
|
Missed: { color: '#D93025', icon: CancelRoundedIcon, label: 'Missed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODES = {
|
||||||
|
pickup: { label: 'Pickups', icon: StorefrontOutlinedIcon, doneLabel: 'Picked up', doneStatus: 'Picked', failStatus: 'Missed', pointLabel: 'Pickup', dash: '8 8' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Route resolution — OSRM road geometry (free, no API key); straight-line fallback.
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function fetchRoadRoute(stops) {
|
||||||
|
const coords = stops.map((s) => `${s.lng},${s.lat}`).join(';');
|
||||||
|
const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.routes && json.routes[0]) {
|
||||||
|
return json.routes[0].geometry.coordinates.map(([lng, lat]) => ({ lat, lng }));
|
||||||
|
}
|
||||||
|
throw new Error('No route');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Small presentational pieces
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
function KpiCard({ icon: Icon, label, value, sub, color, bg }) {
|
||||||
|
return (
|
||||||
|
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%' }}>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1 }}>
|
||||||
|
<Avatar variant="rounded" sx={{ bgcolor: bg, color, width: 34, height: 34, borderRadius: 2 }}>
|
||||||
|
<Icon sx={{ fontSize: 18 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography sx={{ fontSize: '1.5rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.1 }}>{value}</Typography>
|
||||||
|
{sub && <Typography variant="caption" color="text.secondary">{sub}</Typography>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ icon: Icon, label, value, valueColor }) {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||||
|
<Icon sx={{ fontSize: 20, color: '#9AA0A6', mt: 0.25 }} />
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{label}</Typography>
|
||||||
|
<Typography sx={{ fontWeight: 600, color: valueColor || '#343A40', wordBreak: 'break-word' }}>{value}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full order/pickup detail panel — rendered INLINE in the left column (not a modal)
|
||||||
|
// so the map stays visible and zoomed to the stop while these details are read.
|
||||||
|
function OrderDetailPanel({ data, onBack }) {
|
||||||
|
const { order, rider, mode, index } = data;
|
||||||
|
const meta = STATUS_META[order.status] || {};
|
||||||
|
const StatusIcon = meta.icon || CheckCircleRoundedIcon;
|
||||||
|
const isPickup = mode === 'pickup';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', bgcolor: '#fff' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ background: `linear-gradient(135deg, ${rider.color} 0%, ${alpha(rider.color, 0.82)} 100%)`, px: 2.75, py: 2.25, color: '#fff', flexShrink: 0 }}>
|
||||||
|
<Button onClick={onBack} startIcon={<ArrowBackRoundedIcon />} size="small"
|
||||||
|
sx={{ color: 'rgba(255,255,255,0.95)', textTransform: 'none', fontWeight: 600, mb: 1.25, ml: -0.5, '&:hover': { bgcolor: 'rgba(255,255,255,0.12)' } }}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={0.75} mb={1} flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip size="small" icon={(isPickup ? <StorefrontOutlinedIcon /> : <DeliveryDiningRoundedIcon />)} label={isPickup ? 'Pickup' : 'Delivery'}
|
||||||
|
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700, '& .MuiChip-icon': { color: '#fff' } }} />
|
||||||
|
<Chip size="small" icon={<StatusIcon sx={{ color: '#fff !important' }} />} label={meta.label || order.status}
|
||||||
|
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 800, lineHeight: 1.25 }}>{order.customer}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.9, fontFamily: 'monospace', mt: 0.25 }}>#{order.orderId}</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Box sx={{ p: 2, bgcolor: '#F8F9FB', flex: 1, overflow: 'auto' }}>
|
||||||
|
<Stack spacing={1.75}>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
||||||
|
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
||||||
|
{isPickup ? 'MERCHANT / SENDER' : 'CUSTOMER'}
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.75}>
|
||||||
|
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : PersonOutlineOutlinedIcon} label="Name" value={order.customer} />
|
||||||
|
<DetailRow icon={PhoneOutlinedIcon} label="Phone" value={order.phone} />
|
||||||
|
<DetailRow icon={PlaceOutlinedIcon} label="Pickup address" value={order.address} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Pickup summary */}
|
||||||
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
||||||
|
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
||||||
|
PICKUP SUMMARY
|
||||||
|
</Typography>
|
||||||
|
<Grid container rowSpacing={2} columnSpacing={1.5}>
|
||||||
|
<Grid item xs={6}><DetailRow icon={AccessTimeOutlinedIcon} label="Picked at" value={order.time} /></Grid>
|
||||||
|
<Grid item xs={6}><DetailRow icon={ScheduleOutlinedIcon} label="Time slot" value={order.slot} /></Grid>
|
||||||
|
<Grid item xs={6}><DetailRow icon={Inventory2OutlinedIcon} label="Items" value={`${order.items} ${order.items === 1 ? 'parcel' : 'parcels'}`} /></Grid>
|
||||||
|
<Grid item xs={6}><DetailRow icon={ScaleOutlinedIcon} label="Weight" value={order.weight} /></Grid>
|
||||||
|
<Grid item xs={6}><DetailRow icon={StraightenRoundedIcon} label="Leg distance" value={`${order.legKm} km`} /></Grid>
|
||||||
|
<Grid item xs={6}><DetailRow icon={PaymentsOutlinedIcon} label="Payment" value={order.payment} /></Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{order.cod > 0 && (
|
||||||
|
<Box sx={{ p: 2, borderRadius: 2, bgcolor: alpha('#F29900', 0.1), border: `1px solid ${alpha('#F29900', 0.3)}` }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||||
|
<PaymentsOutlinedIcon sx={{ color: '#B06000' }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#B06000', fontWeight: 700 }}>CASH ON PICKUP</Typography>
|
||||||
|
<Typography sx={{ fontWeight: 800, color: '#B06000' }}>{inr(order.cod)}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Route & location */}
|
||||||
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
||||||
|
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
||||||
|
ROUTE & LOCATION
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.75}>
|
||||||
|
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : WarehouseRoundedIcon} label="Route leg"
|
||||||
|
value={isPickup ? `${order.customer} → ${HUB.label}` : `${HUB.label} → ${order.customer}`} />
|
||||||
|
<DetailRow icon={MyLocationOutlinedIcon} label="Coordinates" value={`${order.lat.toFixed(4)}, ${order.lng.toFixed(4)}`} />
|
||||||
|
{order.instructions && <DetailRow icon={NotesOutlinedIcon} label="Instructions" value={order.instructions} valueColor="#5F6368" />}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Assigned miler */}
|
||||||
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
||||||
|
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
||||||
|
ASSIGNED MILER
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||||
|
<Avatar sx={{ width: 40, height: 40, bgcolor: alpha(rider.color, 0.14), color: rider.color, fontWeight: 700 }}>{initials(rider.name)}</Avatar>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700 }} noWrap>{rider.name}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{rider.vehicle} · {rider.vehicleNo} · Stop {index}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box sx={{ p: 2, borderTop: '1px solid #E9ECEF', bgcolor: '#fff', display: 'flex', gap: 1.25, flexShrink: 0 }}>
|
||||||
|
<Button fullWidth variant="outlined" startIcon={<CallOutlinedIcon />} href={`tel:${order.phone}`} sx={{ borderRadius: 2 }}>Call</Button>
|
||||||
|
<Button fullWidth variant="contained" startIcon={<CheckCircleRoundedIcon />} onClick={onBack} sx={{ borderRadius: 2, bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }}>Done</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// MAIN COMPONENT
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
|
export default function RiderRoutes() {
|
||||||
|
const [mode] = useState('pickup'); // pickups only
|
||||||
|
const [visible, setVisible] = useState(() => Object.fromEntries(RIDERS.map((r) => [r.id, true])));
|
||||||
|
const [routes, setRoutes] = useState({}); // keyed `${id}__${mode}`
|
||||||
|
const [expanded, setExpanded] = useState(RIDERS[0].id);
|
||||||
|
const [focusedStop, setFocusedStop] = useState(null); // `${riderId}-${index}`
|
||||||
|
const [detail, setDetail] = useState(null); // { order, rider, mode, index }
|
||||||
|
const [flyTarget, setFlyTarget] = useState(null);
|
||||||
|
|
||||||
|
// Animation state.
|
||||||
|
const [playing, setPlaying] = useState(null);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [speed, setSpeed] = useState(1);
|
||||||
|
const [speedAnchor, setSpeedAnchor] = useState(null);
|
||||||
|
const rafRef = useRef(null);
|
||||||
|
const lastTsRef = useRef(0);
|
||||||
|
const resolvedRef = useRef({}); // keys we've already fetched/attempted
|
||||||
|
|
||||||
|
const modeCfg = MODES[mode];
|
||||||
|
|
||||||
|
// Resolve road routes for the active mode (cached per id+mode). A ref guards
|
||||||
|
// against re-fetching keys we've already resolved when the tab is revisited.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
RIDERS.map(async (r) => {
|
||||||
|
const key = `${r.id}__${mode}`;
|
||||||
|
if (resolvedRef.current[key]) return;
|
||||||
|
resolvedRef.current[key] = true;
|
||||||
|
const stops = r[mode].stops;
|
||||||
|
let path;
|
||||||
|
try { path = await fetchRoadRoute(stops); }
|
||||||
|
catch { path = stops.map((s) => ({ lat: s.lat, lng: s.lng })); }
|
||||||
|
if (!cancelled) setRoutes((prev) => ({ ...prev, [key]: path }));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
const pathFor = useCallback(
|
||||||
|
(r) => routes[`${r.id}__${mode}`] || r[mode].stops.map((s) => ({ lat: s.lat, lng: s.lng })),
|
||||||
|
[routes, mode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopsOf = useCallback((r) => r[mode].stops, [mode]);
|
||||||
|
|
||||||
|
// Auto-fit points = visible riders' stops for the active mode.
|
||||||
|
const fitPoints = useMemo(() => {
|
||||||
|
const pts = [];
|
||||||
|
RIDERS.forEach((r) => { if (visible[r.id]) stopsOf(r).forEach((s) => pts.push(s)); });
|
||||||
|
return pts;
|
||||||
|
}, [visible, stopsOf]);
|
||||||
|
|
||||||
|
// ── Analysis KPIs for the active mode ─────────────────────────────────────────
|
||||||
|
const kpi = useMemo(() => {
|
||||||
|
let orders = 0, done = 0, fail = 0, km = 0, cod = 0, activeRiders = 0;
|
||||||
|
RIDERS.forEach((r) => {
|
||||||
|
const trip = r[mode];
|
||||||
|
const orderStops = trip.stops.filter((s) => s.kind === 'order');
|
||||||
|
if (orderStops.length) activeRiders += 1;
|
||||||
|
orders += orderStops.length;
|
||||||
|
done += orderStops.filter((s) => s.status === modeCfg.doneStatus).length;
|
||||||
|
fail += orderStops.filter((s) => s.status === modeCfg.failStatus).length;
|
||||||
|
km += trip.distanceKm;
|
||||||
|
cod += orderStops.reduce((s, o) => s + (o.cod || 0), 0);
|
||||||
|
});
|
||||||
|
return { orders, done, fail, km: km.toFixed(1), cod, activeRiders };
|
||||||
|
}, [mode, modeCfg]);
|
||||||
|
|
||||||
|
// ── Animation driver ──────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing) { if (rafRef.current) cancelAnimationFrame(rafRef.current); return; }
|
||||||
|
lastTsRef.current = 0;
|
||||||
|
const DURATION = 14000;
|
||||||
|
const tick = (ts) => {
|
||||||
|
if (!lastTsRef.current) lastTsRef.current = ts;
|
||||||
|
const dt = ts - lastTsRef.current;
|
||||||
|
lastTsRef.current = ts;
|
||||||
|
setProgress((p) => {
|
||||||
|
const nextP = p + (dt * speed) / DURATION;
|
||||||
|
if (nextP >= 1) { setPlaying(null); return 1; }
|
||||||
|
return nextP;
|
||||||
|
});
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||||
|
}, [playing, speed]);
|
||||||
|
|
||||||
|
// Stop any animation when switching tabs.
|
||||||
|
useEffect(() => { setPlaying(null); setProgress(0); }, [mode]);
|
||||||
|
|
||||||
|
const startAnim = (riderId) => {
|
||||||
|
setVisible((v) => ({ ...v, [riderId]: true }));
|
||||||
|
if (playing === riderId) { setPlaying(null); return; }
|
||||||
|
if (playing !== riderId) setProgress(0);
|
||||||
|
setPlaying(riderId);
|
||||||
|
};
|
||||||
|
const resetAnim = () => { setPlaying(null); setProgress(0); };
|
||||||
|
const toggleVisible = (id) => setVisible((v) => ({ ...v, [id]: !v[id] }));
|
||||||
|
|
||||||
|
const openDetail = (rider, order, index) => {
|
||||||
|
setDetail({ order, rider, mode, index });
|
||||||
|
setExpanded(rider.id);
|
||||||
|
setFocusedStop(`${rider.id}-${index}`);
|
||||||
|
setFlyTarget({ lat: order.lat, lng: order.lng });
|
||||||
|
};
|
||||||
|
const closeDetail = () => { setDetail(null); setFlyTarget(null); setFocusedStop(null); };
|
||||||
|
|
||||||
|
const playState = useMemo(() => {
|
||||||
|
if (!playing) return null;
|
||||||
|
const rider = RIDERS.find((r) => r.id === playing);
|
||||||
|
const path = pathFor(rider);
|
||||||
|
if (path.length < 2) return null;
|
||||||
|
const totalSegs = path.length - 1;
|
||||||
|
const exact = progress * totalSegs;
|
||||||
|
const i = Math.min(Math.floor(exact), totalSegs - 1);
|
||||||
|
const frac = exact - i;
|
||||||
|
const pos = lerpPoint(path[i], path[i + 1], frac);
|
||||||
|
const travelled = [...path.slice(0, i + 1), pos];
|
||||||
|
return { rider, path, travelled, pos };
|
||||||
|
}, [playing, progress, pathFor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ pt: { xs: 1, md: 1 }, pb: 1 }}>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" alignItems={{ md: 'center' }} gap={1} mb={2} sx={{ mb: 2 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2.5}>
|
||||||
|
<Avatar sx={{ bgcolor: alpha('#C01227', 0.1), color: '#C01227', width: 56, height: 56, borderRadius: 2 }}>
|
||||||
|
<RouteOutlinedIcon sx={{ fontSize: 30 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800, letterSpacing: '-0.4px', lineHeight: 1.2 }}>Rider Routes</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||||
|
Pickups each miler covered today — open any order for full details, or press play to replay the trip.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* ── Analysis KPI strip ── */}
|
||||||
|
<Grid container spacing={2.5} mt={4} sx={{ mb: 2 }}>
|
||||||
|
{[
|
||||||
|
{ icon: GroupsOutlinedIcon, label: 'Active Milers', value: kpi.activeRiders, sub: `of ${RIDERS.length}`, color: '#1A73E8', bg: '#E8F0FE' },
|
||||||
|
{ icon: modeCfg.icon, label: modeCfg.label, value: kpi.orders, sub: 'pickups covered', color: '#C01227', bg: alpha('#C01227', 0.1) },
|
||||||
|
{ icon: CheckCircleRoundedIcon, label: modeCfg.doneLabel, value: kpi.done, sub: `${kpi.fail} missed`, color: '#1E8E3E', bg: '#E6F4EA' },
|
||||||
|
{ icon: StraightenRoundedIcon, label: 'Distance', value: `${kpi.km} km`, sub: 'fleet total today', color: '#8E24AA', bg: '#F3E5F5' },
|
||||||
|
].map((k, i) => (
|
||||||
|
<Grid item xs={6} sm={6} md={3} key={i}><KpiCard {...k} /></Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3, pb: 4 }}>
|
||||||
|
{/* ── Left control panel — the milers list; clicking an order opens the
|
||||||
|
full detail in a right-side drawer (below). ── */}
|
||||||
|
<Box sx={{ width: { xs: '100%', lg: 380 }, flexShrink: 0 }}>
|
||||||
|
<Card sx={{ borderRadius: 2, border: '1px solid #ECEEF1', boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}>
|
||||||
|
<Box sx={{ px: 3, py: 2.25, borderBottom: '1px solid #F1F3F5', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Typography sx={{ fontWeight: 700 }}>Milers & their {modeCfg.label.toLowerCase()}</Typography>
|
||||||
|
{(playing || progress > 0) && (
|
||||||
|
<Button size="small" startIcon={<ReplayRoundedIcon />} onClick={resetAnim} sx={{ textTransform: 'none', color: '#5F6368' }}>Reset</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<List disablePadding sx={{ maxHeight: { lg: 620 }, overflow: 'auto' }}>
|
||||||
|
{RIDERS.map((rider) => {
|
||||||
|
const trip = rider[mode];
|
||||||
|
const orders = trip.stops.filter((s) => s.kind === 'order');
|
||||||
|
const done = orders.filter((o) => o.status === modeCfg.doneStatus).length;
|
||||||
|
const isOpen = expanded === rider.id;
|
||||||
|
const isPlaying = playing === rider.id;
|
||||||
|
const isVisible = visible[rider.id];
|
||||||
|
return (
|
||||||
|
<Box key={rider.id} sx={{ borderBottom: '1px solid #F4F5F7' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 1.5, gap: 1, opacity: isVisible ? 1 : 0.5 }}>
|
||||||
|
<Box sx={{ width: 6, height: 40, borderRadius: 2, bgcolor: rider.color, flexShrink: 0 }} />
|
||||||
|
<Avatar sx={{ width: 38, height: 38, bgcolor: alpha(rider.color, 0.12), color: rider.color, fontWeight: 700, fontSize: 14 }}>{initials(rider.name)}</Avatar>
|
||||||
|
<ListItemButton disableGutters onClick={() => setExpanded(isOpen ? null : rider.id)} sx={{ flex: 1, borderRadius: 2, px: 1, py: 0.5, minWidth: 0 }}>
|
||||||
|
<ListItemText
|
||||||
|
primary={<Typography sx={{ fontWeight: 700, fontSize: '0.92rem' }} noWrap>{rider.name}</Typography>}
|
||||||
|
secondary={<Typography variant="caption" color="text.secondary" noWrap>{done}/{orders.length} {modeCfg.doneLabel.toLowerCase()} · {trip.distanceKm} km</Typography>}
|
||||||
|
/>
|
||||||
|
{isOpen ? <ExpandLessRoundedIcon sx={{ color: '#9AA0A6' }} /> : <ExpandMoreRoundedIcon sx={{ color: '#9AA0A6' }} />}
|
||||||
|
</ListItemButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* per-rider stat chips (analysis) */}
|
||||||
|
<Stack direction="row" gap={1} flexWrap="wrap" sx={{ px: 2, pb: 1.5 }}>
|
||||||
|
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '14px !important' }} />} label={`${orders.length} ${modeCfg.label.toLowerCase()}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
||||||
|
<Chip size="small" icon={<CheckCircleRoundedIcon sx={{ fontSize: '14px !important', color: '#1E8E3E !important' }} />} label={done} sx={{ height: 24, fontWeight: 600, bgcolor: alpha('#1E8E3E', 0.1), color: '#1E8E3E' }} />
|
||||||
|
<Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '14px !important' }} />} label={`${trip.distanceKm} km`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
||||||
|
<Chip size="small" icon={<AccessTimeOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={`${trip.startTime}–${trip.endTime}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* action row */}
|
||||||
|
<Stack direction="row" gap={1.25} sx={{ px: 2, pb: 2 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={isPlaying ? 'contained' : 'outlined'}
|
||||||
|
startIcon={isPlaying ? <PauseRoundedIcon /> : <PlayArrowRoundedIcon />}
|
||||||
|
onClick={() => startAnim(rider.id)}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none', fontWeight: 600, borderRadius: 2, flex: 1,
|
||||||
|
...(isPlaying
|
||||||
|
? { bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }
|
||||||
|
: { color: rider.color, borderColor: alpha(rider.color, 0.5), '&:hover': { borderColor: rider.color, bgcolor: alpha(rider.color, 0.06) } }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? 'Playing…' : 'Animate route'}
|
||||||
|
</Button>
|
||||||
|
<Tooltip title={isVisible ? 'Hide route' : 'Show route'}>
|
||||||
|
<IconButton size="small" onClick={() => toggleVisible(rider.id)} sx={{ border: '1px solid #E9ECEF', borderRadius: 2 }}>
|
||||||
|
{isVisible ? <VisibilityOutlinedIcon fontSize="small" /> : <VisibilityOffOutlinedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{isPlaying && (
|
||||||
|
<Box sx={{ px: 2, pb: 1.5 }}>
|
||||||
|
<LinearProgress variant="determinate" value={progress * 100} sx={{ height: 6, borderRadius: 2, bgcolor: '#EDEFF2', '& .MuiLinearProgress-bar': { bgcolor: rider.color, borderRadius: 2 } }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* expandable stops — rich order cards; click to open detail */}
|
||||||
|
<Collapse in={isOpen} unmountOnExit>
|
||||||
|
<Box sx={{ px: 2, pb: 2, pt: 0.5 }}>
|
||||||
|
<Stack spacing={1.25}>
|
||||||
|
{trip.stops.map((s, i) => {
|
||||||
|
const key = `${rider.id}-${i}`;
|
||||||
|
const isHub = s.kind === 'hub';
|
||||||
|
const meta = STATUS_META[s.status];
|
||||||
|
const StatusIcon = meta?.icon;
|
||||||
|
|
||||||
|
// Hub return — compact row, not a card.
|
||||||
|
if (isHub) {
|
||||||
|
return (
|
||||||
|
<Stack key={key} direction="row" alignItems="center" gap={1.25} sx={{ px: 0.5, py: 0.5 }}>
|
||||||
|
<Box sx={{ width: 28, height: 28, borderRadius: 2, flexShrink: 0, bgcolor: alpha('#C01227', 0.12), color: '#C01227', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<WarehouseRoundedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.label}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Back to hub · {s.time}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
onMouseEnter={() => setFocusedStop(key)}
|
||||||
|
onMouseLeave={() => setFocusedStop((f) => (f === key ? null : f))}
|
||||||
|
onClick={() => openDetail(rider, s, i)}
|
||||||
|
sx={{
|
||||||
|
p: 1.5, borderRadius: 2, cursor: 'pointer', transition: 'all .15s',
|
||||||
|
border: '1px solid', borderColor: focusedStop === key ? alpha(rider.color, 0.55) : '#ECEEF1',
|
||||||
|
bgcolor: focusedStop === key ? alpha(rider.color, 0.04) : '#fff',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.03)',
|
||||||
|
'&:hover': { borderColor: alpha(rider.color, 0.55), boxShadow: '0 4px 14px rgba(0,0,0,0.07)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* order id + status */}
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}>
|
||||||
|
<Box sx={{ width: 26, height: 26, borderRadius: 2, flexShrink: 0, bgcolor: alpha(rider.color, 0.14), color: rider.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 12 }}>{i}</Box>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>
|
||||||
|
<Box component="span" sx={{ color: '#9AA0A6', fontWeight: 600 }}>Order </Box>#{s.orderId}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{meta && (
|
||||||
|
<Chip size="small" icon={<StatusIcon sx={{ fontSize: '14px !important', color: `${meta.color} !important` }} />} label={meta.label}
|
||||||
|
sx={{ height: 22, fontSize: '0.68rem', fontWeight: 700, color: meta.color, bgcolor: alpha(meta.color, 0.1), flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* rider + time */}
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mt: 1 }}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.75} sx={{ minWidth: 0 }}>
|
||||||
|
<DeliveryDiningRoundedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }} noWrap>{rider.name}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.5} sx={{ flexShrink: 0 }}>
|
||||||
|
<AccessTimeOutlinedIcon sx={{ fontSize: 14, color: '#9AA0A6' }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{s.time}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.25 }} />
|
||||||
|
|
||||||
|
{/* merchant + address */}
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.75}>
|
||||||
|
<StorefrontOutlinedIcon sx={{ fontSize: 16, color: rider.color, flexShrink: 0 }} />
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.customer}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" alignItems="flex-start" gap={0.75} sx={{ mt: 0.5 }}>
|
||||||
|
<PlaceOutlinedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0, mt: '1px' }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ flex: 1, minWidth: 0 }}>{s.address}</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* metric chips */}
|
||||||
|
<Stack direction="row" gap={0.75} flexWrap="wrap" sx={{ mt: 1.25 }}>
|
||||||
|
<Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.legKm} km`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
||||||
|
<Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '13px !important' }} />} label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
||||||
|
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Map ── */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Card sx={{ height: { xs: 460, sm: 560, lg: 700 }, borderRadius: 2, border: '1px solid #ECEEF1', overflow: 'hidden', position: 'relative', boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}>
|
||||||
|
{playState && (
|
||||||
|
<Box sx={{ position: 'absolute', top: 14, left: 14, zIndex: 1000, minWidth: 230, bgcolor: 'rgba(255,255,255,0.97)', borderRadius: 2, p: 1.75, boxShadow: '0 8px 28px rgba(0,0,0,0.14)', border: `1px solid ${alpha(playState.rider.color, 0.3)}` }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.25} mb={1}>
|
||||||
|
<Avatar sx={{ width: 30, height: 30, bgcolor: playState.rider.color, fontSize: 12, fontWeight: 700 }}>{initials(playState.rider.name)}</Avatar>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem' }} noWrap>{playState.rider.name}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Replaying {modeCfg.label.toLowerCase().replace(/s$/, '')} · {Math.round(progress * 100)}%</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="small" onClick={() => setPlaying(null)}><PauseRoundedIcon fontSize="small" /></IconButton>
|
||||||
|
<Tooltip title="Speed"><IconButton size="small" onClick={(e) => setSpeedAnchor(e.currentTarget)}><SpeedRoundedIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
|
</Stack>
|
||||||
|
<LinearProgress variant="determinate" value={progress * 100} sx={{ height: 6, borderRadius: 2, bgcolor: '#EDEFF2', '& .MuiLinearProgress-bar': { bgcolor: playState.rider.color, borderRadius: 2 } }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Menu anchorEl={speedAnchor} open={Boolean(speedAnchor)} onClose={() => setSpeedAnchor(null)}>
|
||||||
|
{[0.5, 1, 2, 4].map((s) => (<MenuItem key={s} selected={speed === s} onClick={() => { setSpeed(s); setSpeedAnchor(null); }}>{s}× speed</MenuItem>))}
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<MapContainer center={[HUB.lat, HUB.lng]} zoom={11} scrollWheelZoom style={{ height: '100%', width: '100%' }}>
|
||||||
|
<MapResizeHandler />
|
||||||
|
{!playing && !flyTarget && <FitBounds points={fitPoints} />}
|
||||||
|
<FlyTo target={flyTarget} />
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Marker position={[HUB.lat, HUB.lng]} icon={hubIcon}>
|
||||||
|
<Popup><b>{HUB.label}</b><br />Pickups return here</Popup>
|
||||||
|
</Marker>
|
||||||
|
|
||||||
|
{RIDERS.map((rider) => {
|
||||||
|
if (!visible[rider.id]) return null;
|
||||||
|
const path = pathFor(rider);
|
||||||
|
const stops = stopsOf(rider);
|
||||||
|
const dimmed = playing && playing !== rider.id;
|
||||||
|
const endStop = stops[stops.length - 1];
|
||||||
|
return (
|
||||||
|
<React.Fragment key={rider.id}>
|
||||||
|
<Polyline positions={path.map((p) => [p.lat, p.lng])} pathOptions={{ color: rider.color, weight: 5, opacity: dimmed ? 0.15 : 0.85, dashArray: modeCfg.dash }} />
|
||||||
|
{stops.map((s, i) => {
|
||||||
|
if (s.kind === 'hub') return null;
|
||||||
|
const key = `${rider.id}-${i}`;
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={key}
|
||||||
|
position={[s.lat, s.lng]}
|
||||||
|
icon={stopIcon(i, rider.color, focusedStop === key, mode === 'pickup')}
|
||||||
|
opacity={dimmed ? 0.3 : 1}
|
||||||
|
zIndexOffset={focusedStop === key ? 1000 : 0}
|
||||||
|
eventHandlers={{ click: () => openDetail(rider, s, i) }}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{modeCfg.pointLabel} {i} · {s.customer}</Typography>
|
||||||
|
<Typography variant="caption" display="block">{s.address}</Typography>
|
||||||
|
<Typography variant="caption" display="block">#{s.orderId} · {s.time} · {STATUS_META[s.status]?.label}</Typography>
|
||||||
|
<Typography variant="caption" display="block" sx={{ color: rider.color, fontWeight: 700, cursor: 'pointer' }} onClick={() => openDetail(rider, s, i)}>View full details →</Typography>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!dimmed && (
|
||||||
|
<Marker position={[endStop.lat, endStop.lng]} icon={flagIcon} zIndexOffset={-100}>
|
||||||
|
<LTooltip direction="top" offset={[0, -14]}>{mode === 'pickup' ? 'Returned to hub' : 'Trip end'} · {rider[mode].endTime}</LTooltip>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{playState && (
|
||||||
|
<>
|
||||||
|
<Polyline positions={playState.travelled.map((p) => [p.lat, p.lng])} pathOptions={{ color: playState.rider.color, weight: 7, opacity: 1 }} />
|
||||||
|
<Marker position={[playState.pos.lat, playState.pos.lng]} icon={moverIcon(playState.rider.color)} zIndexOffset={2000}>
|
||||||
|
<LTooltip direction="top" offset={[0, -16]} permanent>{playState.rider.name}</LTooltip>
|
||||||
|
</Marker>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={2} sx={{ mt: 2 }}>
|
||||||
|
{RIDERS.map((r) => (
|
||||||
|
<Stack key={r.id} direction="row" alignItems="center" gap={0.75} sx={{ opacity: visible[r.id] ? 1 : 0.4, cursor: 'pointer' }} onClick={() => toggleVisible(r.id)}>
|
||||||
|
<Box sx={{ width: 18, height: 4, borderRadius: 2, bgcolor: r.color }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{r.name}</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.75}>
|
||||||
|
<FlagRoundedIcon sx={{ fontSize: 16, color: '#1E8E3E' }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{mode === 'pickup' ? 'Hub return' : 'Trip end'}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" alignItems="center" gap={0.75}>
|
||||||
|
<WarehouseRoundedIcon sx={{ fontSize: 16, color: '#C01227' }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>Hub</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Order detail — right-side drawer ── */}
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
open={Boolean(detail)}
|
||||||
|
onClose={closeDetail}
|
||||||
|
sx={{
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
width: { xs: '100%', sm: 360 },
|
||||||
|
maxWidth: '100%',
|
||||||
|
top: { xs: 0, sm: 64 },
|
||||||
|
height: { xs: '100%', sm: 'calc(100% - 64px)' },
|
||||||
|
borderTopLeftRadius: { sm: 16 },
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '-8px 0 30px rgba(0,0,0,0.12)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{detail && <OrderDetailPanel data={detail} onBack={closeDetail} />}
|
||||||
|
</Drawer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1373
src/pages/operations/Riders.jsx
Normal file
1373
src/pages/operations/Riders.jsx
Normal file
File diff suppressed because it is too large
Load Diff
342
src/pages/operations/Routing.jsx
Normal file
342
src/pages/operations/Routing.jsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box, Typography, Card, CardContent, CardHeader, Grid, TextField, Button,
|
||||||
|
Stack, Divider, Alert, AlertTitle, Avatar, List, ListItemButton, ListItemText, Chip
|
||||||
|
} from '@mui/material';
|
||||||
|
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
|
||||||
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
|
import HelpIcon from '@mui/icons-material/Help';
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||||
|
import HubIcon from '@mui/icons-material/Hub';
|
||||||
|
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
|
||||||
|
|
||||||
|
// Mock DB with Advanced Logistics Scenarios
|
||||||
|
const HUB_NAME = 'Delhi Hub (DEL-01)';
|
||||||
|
const LOCAL_ZONES = ['Dwarka', 'Janakpuri', 'Saket', 'Malviya Nagar', 'Rohini', 'Vasant Kunj', 'Central Delhi'];
|
||||||
|
|
||||||
|
const PACKAGES_DB = {
|
||||||
|
'DM-1001': {
|
||||||
|
id: 'DM-1001',
|
||||||
|
source: 'Local Pickup',
|
||||||
|
origin: 'Customer - Dwarka',
|
||||||
|
destHub: 'Delhi Hub (DEL-01)',
|
||||||
|
destZone: 'Janakpuri',
|
||||||
|
weight: '1.2 kg',
|
||||||
|
status: 'Arrived at Hub'
|
||||||
|
},
|
||||||
|
'DM-1002': {
|
||||||
|
id: 'DM-1002',
|
||||||
|
source: 'Local Pickup',
|
||||||
|
origin: 'Merchant - Saket',
|
||||||
|
destHub: 'Mumbai Hub (BOM-02)',
|
||||||
|
destZone: 'Andheri West',
|
||||||
|
weight: '3.5 kg',
|
||||||
|
status: 'Arrived at Hub'
|
||||||
|
},
|
||||||
|
'DM-1003': {
|
||||||
|
id: 'DM-1003',
|
||||||
|
source: 'Arrived from another city',
|
||||||
|
origin: 'Bengaluru Hub (BLR-02)',
|
||||||
|
destHub: 'Delhi Hub (DEL-01)',
|
||||||
|
destZone: 'Rohini',
|
||||||
|
weight: '0.5 kg',
|
||||||
|
status: 'Arrived at Hub'
|
||||||
|
},
|
||||||
|
'DM-1004': {
|
||||||
|
id: 'DM-1004',
|
||||||
|
source: 'Local Pickup',
|
||||||
|
origin: 'Customer - Vasant Kunj',
|
||||||
|
destHub: 'Delhi Hub (DEL-01)',
|
||||||
|
destZone: 'Central Delhi',
|
||||||
|
weight: '2.0 kg',
|
||||||
|
status: 'Arrived at Hub'
|
||||||
|
},
|
||||||
|
'DM-1005': {
|
||||||
|
id: 'DM-1005',
|
||||||
|
source: 'Arrived from another city',
|
||||||
|
origin: 'Chennai Hub (MAA-05)',
|
||||||
|
destHub: 'Delhi Hub (DEL-01)',
|
||||||
|
destZone: 'Dwarka',
|
||||||
|
weight: '15.0 kg',
|
||||||
|
status: 'Damaged Packaging',
|
||||||
|
exception: 'Box crushed during transit.'
|
||||||
|
},
|
||||||
|
'DM-1006': {
|
||||||
|
id: 'DM-1006',
|
||||||
|
source: 'Arrived from another city',
|
||||||
|
origin: 'Pune Hub (PNQ-03)',
|
||||||
|
destHub: 'Pune Hub (PNQ-03)',
|
||||||
|
destZone: 'Koregaon Park',
|
||||||
|
weight: '1.0 kg',
|
||||||
|
status: 'RTS (Return to Sender)',
|
||||||
|
exception: 'Customer rejected delivery at destination.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Routing() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [searchId, setSearchId] = useState('');
|
||||||
|
const [matchedPkg, setMatchedPkg] = useState(null);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = searchParams.get('q');
|
||||||
|
if (query) {
|
||||||
|
setSearchId(query);
|
||||||
|
handleSearch(query);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleSearch = (idToSearch) => {
|
||||||
|
const id = typeof idToSearch === 'string' ? idToSearch : searchId;
|
||||||
|
setSearched(true);
|
||||||
|
if (id && PACKAGES_DB[id]) {
|
||||||
|
setMatchedPkg(PACKAGES_DB[id]);
|
||||||
|
} else {
|
||||||
|
setMatchedPkg(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Advanced Sorting Logic: Classify by Next Action
|
||||||
|
const determineNextAction = (pkg) => {
|
||||||
|
if (pkg.status.includes('Exception') || pkg.status === 'Damaged Packaging') {
|
||||||
|
return {
|
||||||
|
queue: 'Needs Checking',
|
||||||
|
action: 'Set aside for a supervisor',
|
||||||
|
color: '#D93025', bg: '#FCE8E6', icon: <WarningAmberIcon />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pkg.status === 'RTS (Return to Sender)') {
|
||||||
|
return {
|
||||||
|
queue: 'Send Back',
|
||||||
|
action: 'Return to the sender',
|
||||||
|
color: '#F29900', bg: '#FEF7E0', icon: <LocalShippingIcon />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pkg.destHub !== HUB_NAME) {
|
||||||
|
return {
|
||||||
|
queue: 'Transfer to Another City',
|
||||||
|
action: `Send to ${pkg.destHub}`,
|
||||||
|
color: '#8E24AA', bg: '#F3E5F5', icon: <LocalShippingIcon />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// If destHub IS this hub, it's local delivery
|
||||||
|
if (LOCAL_ZONES.includes(pkg.destZone)) {
|
||||||
|
// If it was picked up locally but is going to a different area, it's a cross-area delivery.
|
||||||
|
if (pkg.source === 'Local Pickup' && !pkg.origin.includes(pkg.destZone)) {
|
||||||
|
return {
|
||||||
|
queue: 'Local Delivery (Other Area)',
|
||||||
|
action: `Send to ${pkg.destZone}`,
|
||||||
|
color: '#1A73E8', bg: '#E8F0FE', icon: <LocalShippingIcon />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
queue: 'Local Delivery',
|
||||||
|
action: `Give to a ${pkg.destZone} miler`,
|
||||||
|
color: '#1E8E3E', bg: '#E6F4EA', icon: <HubIcon />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { queue: 'Unknown', action: 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: <HelpIcon /> };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800, color: '#212529', mb: 1 }}>Where Does It Go?</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Scan a parcel and we'll tell you exactly what to do with it next — deliver locally, transfer to another city, or set it aside.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={3.5}>
|
||||||
|
{/* Search Panel */}
|
||||||
|
<Grid size={{ xs: 12, md: 5, lg: 4 }} >
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Card sx={{ borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.04)', border: '1px solid #eaeaea' }}>
|
||||||
|
<CardHeader
|
||||||
|
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Scan a Parcel</Typography>}
|
||||||
|
avatar={<Avatar sx={{ bgcolor: '#C0122710', color: '#C01227', borderRadius: 2 }}><QrCodeScannerIcon /></Avatar>}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<CardContent sx={{ pt: 3 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Parcel tracking number"
|
||||||
|
placeholder="e.g. DM-1001"
|
||||||
|
value={searchId}
|
||||||
|
onChange={(e) => setSearchId(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
InputProps={{
|
||||||
|
sx: { borderRadius: 2, bgcolor: '#fff' }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
onClick={() => handleSearch()}
|
||||||
|
fullWidth
|
||||||
|
sx={{ bgcolor: '#C01227', py: 1.5, borderRadius: 2, boxShadow: '0px 6px 16px rgba(192, 18, 39, 0.28)', '&:hover': { bgcolor: '#9E0E20' } }}
|
||||||
|
>
|
||||||
|
Tell Me What To Do
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card sx={{ borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.04)', border: '1px solid #eaeaea' }}>
|
||||||
|
<CardHeader
|
||||||
|
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Parcels Waiting</Typography>}
|
||||||
|
subheader={<Typography variant="caption" sx={{ color: 'text.secondary' }}>Tap a parcel to check it</Typography>}
|
||||||
|
avatar={<Avatar sx={{ bgcolor: '#E8F0FE', color: '#1A73E8', borderRadius: 2 }}><LocalOfferIcon /></Avatar>}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<CardContent sx={{ pt: 2, p: 1 }}>
|
||||||
|
<List disablePadding>
|
||||||
|
{Object.keys(PACKAGES_DB).map((key) => {
|
||||||
|
const pkg = PACKAGES_DB[key];
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
key={key}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchId(key);
|
||||||
|
handleSearch(key);
|
||||||
|
}}
|
||||||
|
selected={searchId === key}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2, mb: 1, p: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: searchId === key ? '#C01227' : '#eaeaea',
|
||||||
|
bgcolor: searchId === key ? '#C0122708' : '#fff',
|
||||||
|
'&:hover': { bgcolor: '#f8f9fa' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={key}
|
||||||
|
secondary={`${pkg.source} → ${pkg.destHub === HUB_NAME ? pkg.destZone : pkg.destHub}`}
|
||||||
|
primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === key ? '#C01227' : '#212529' }}
|
||||||
|
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.5, color: '#6c757d' }}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Visual Route Guideline Panel */}
|
||||||
|
<Grid size={{ xs: 12, md: 7, lg: 8 }} >
|
||||||
|
{!searched ? (
|
||||||
|
<Card sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: { xs: 8, sm: 15 }, px: 2, border: '2px dashed #CED4DA', bgcolor: '#fff', borderRadius: 2, height: '100%', boxShadow: 'none' }}>
|
||||||
|
<Stack alignItems="center" spacing={3}>
|
||||||
|
<Avatar sx={{ bgcolor: '#F8F9FA', color: '#ADB5BD', width: 80, height: 80 }}>
|
||||||
|
<QrCodeScannerIcon sx={{ fontSize: 40 }} />
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 800, color: '#495057' }}>Scan a parcel to begin</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 1, color: '#868E96' }}>We'll show you where it needs to go.</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
) : matchedPkg ? (
|
||||||
|
<Card sx={{ height: '100%', borderRadius: 2, border: '1px solid #eaeaea', boxShadow: '0px 4px 20px rgba(0,0,0,0.06)' }}>
|
||||||
|
<CardHeader
|
||||||
|
title={<Typography variant="h5" sx={{ fontWeight: 800 }}>{matchedPkg.id}</Typography>}
|
||||||
|
subheader={<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>Source: {matchedPkg.source}</Typography>}
|
||||||
|
action={
|
||||||
|
<Chip
|
||||||
|
label={matchedPkg.status}
|
||||||
|
sx={{ fontWeight: 700, bgcolor: '#F1F3F5', color: '#495057', borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sx={{ px: { xs: 2.5, sm: 4 }, pt: { xs: 3, sm: 4 }, pb: 2 }}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<CardContent sx={{ p: { xs: 2.5, sm: 4 } }}>
|
||||||
|
|
||||||
|
{/* Action Banner */}
|
||||||
|
<Box sx={{ mb: { xs: 3, sm: 5 }, p: { xs: 2, sm: 3 }, bgcolor: '#F8F9FB', borderRadius: 2, display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, border: '1px solid #E9ECEF' }}>
|
||||||
|
<Avatar sx={{ bgcolor: '#212529', color: '#fff', width: { xs: 50, sm: 64 }, height: { xs: 50, sm: 64 }, borderRadius: 2, flexShrink: 0 }}>
|
||||||
|
{determineNextAction(matchedPkg).icon}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="overline" sx={{ display: 'block', color: '#6c757d', fontWeight: 700, letterSpacing: 1, lineHeight: 1.3 }}>
|
||||||
|
{determineNextAction(matchedPkg).queue}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontWeight: 800, color: '#212529', mt: 0.5, fontSize: { xs: '1.3rem', sm: '2.125rem' }, lineHeight: 1.15 }}>
|
||||||
|
{determineNextAction(matchedPkg).action}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Parcel Journey */}
|
||||||
|
<Box sx={{ mb: { xs: 3, sm: 5 }, p: { xs: 2, sm: 3 }, bgcolor: '#f8f9fa', borderRadius: 2, border: '1px solid #eaeaea' }}>
|
||||||
|
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', mb: { xs: 2, sm: 3 }, letterSpacing: '0.08em', fontWeight: 700 }}>
|
||||||
|
The Parcel's Journey
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
spacing={2}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Came From</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.origin}</Typography>
|
||||||
|
</Box>
|
||||||
|
<ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} />
|
||||||
|
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Right Now (Here)</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 800, color: '#C01227', mt: 0.5 }}>{HUB_NAME}</Typography>
|
||||||
|
</Box>
|
||||||
|
<ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} />
|
||||||
|
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Going To</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.destHub === HUB_NAME ? matchedPkg.destZone : matchedPkg.destHub}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Handling Alerts */}
|
||||||
|
{matchedPkg.exception && (
|
||||||
|
<Alert severity="error" variant="filled" sx={{ borderRadius: 2, mb: 2 }}>
|
||||||
|
<AlertTitle sx={{ fontWeight: 700 }}>Something's Wrong</AlertTitle>
|
||||||
|
{matchedPkg.exception}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{determineNextAction(matchedPkg).queue === 'Transfer to Another City' && (
|
||||||
|
<Alert severity="info" sx={{ borderRadius: 2, border: '1px solid #bae1ff', bgcolor: '#e6f2ff' }}>
|
||||||
|
<AlertTitle sx={{ fontWeight: 700, color: '#0055b3' }}>Put in the transfer bin</AlertTitle>
|
||||||
|
Place this parcel in the bin for <strong>{matchedPkg.destHub}</strong>. It will go out with the next city transfer.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{determineNextAction(matchedPkg).queue === 'Local Delivery' && (
|
||||||
|
<Alert severity="success" sx={{ borderRadius: 2, border: '1px solid #c3e6cb', bgcolor: '#d4edda' }}>
|
||||||
|
<AlertTitle sx={{ fontWeight: 700, color: '#155724' }}>Ready for local delivery</AlertTitle>
|
||||||
|
Place this parcel in the <strong>{matchedPkg.destZone}</strong> lane so a miler can take it out.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card sx={{ height: '100%', borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ py: { xs: 8, sm: 15 }, textAlign: 'center' }}>
|
||||||
|
<Alert severity="error" sx={{ justifyContent: 'center', borderRadius: 2 }}>
|
||||||
|
<AlertTitle sx={{ fontWeight: 700 }}>Package Not Found</AlertTitle>
|
||||||
|
The package code <strong>{searchId}</strong> is not registered in the system.
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
src/pages/operations/TrackingMap.jsx
Normal file
256
src/pages/operations/TrackingMap.jsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Typography, Card, CardHeader, Avatar, Stack, Chip, List, ListItem,
|
||||||
|
ListItemAvatar, ListItemText, Badge, Divider
|
||||||
|
} from '@mui/material';
|
||||||
|
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
|
||||||
|
import HubIcon from '@mui/icons-material/Hub';
|
||||||
|
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
// Keeps Leaflet's canvas sized correctly when the container resizes (sidebar
|
||||||
|
// toggle, window resize, first paint inside a flex box). Without this the map
|
||||||
|
// renders grey/blank tiles — the #1 reason a real map "doesn't show".
|
||||||
|
function MapResizeHandler() {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
const fix = () => map.invalidateSize();
|
||||||
|
const t = setTimeout(fix, 250);
|
||||||
|
window.addEventListener('resize', fix);
|
||||||
|
const ro = new ResizeObserver(fix);
|
||||||
|
ro.observe(map.getContainer());
|
||||||
|
return () => { clearTimeout(t); window.removeEventListener('resize', fix); ro.disconnect(); };
|
||||||
|
}, [map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix leaflet default marker icons issue
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom HTML Icons for Leaflet
|
||||||
|
const createHubIcon = () => new L.DivIcon({
|
||||||
|
className: 'custom-leaflet-icon',
|
||||||
|
html: `<div style="background-color: #C01227; width: 36px; height: 36px; border-radius: 50%; border: 3px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="#ffffff" width="20" height="20" viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></div>`,
|
||||||
|
iconSize: [36, 36],
|
||||||
|
iconAnchor: [18, 18],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRiderIcon = () => new L.DivIcon({
|
||||||
|
className: 'custom-leaflet-icon',
|
||||||
|
html: `<div style="background-color: #0070f3; width: 30px; height: 30px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 10px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="#ffffff" width="16" height="16" viewBox="0 0 24 24"><path d="M19 7c0-1.1-.9-2-2-2h-3v2h3v2.65L13.52 14H10V9H6c-2.21 0-4 1.79-4 4v3h2c0 1.66 1.34 3 3 3s3-1.34 3-3h4.48L19 10.35V7zM7 17c-.55 0-1-.45-1-1h2c0 .55-.45 1-1 1z"/></svg></div>`,
|
||||||
|
iconSize: [30, 30],
|
||||||
|
iconAnchor: [15, 15],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createLinehaulIcon = () => new L.DivIcon({
|
||||||
|
className: 'custom-leaflet-icon',
|
||||||
|
html: `<div style="background-color: #ff9900; width: 34px; height: 34px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.18); display: flex; align-items: center; justify-content: center;"><svg fill="#ffffff" width="18" height="18" viewBox="0 0 24 24"><path d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/></svg></div>`,
|
||||||
|
iconSize: [34, 34],
|
||||||
|
iconAnchor: [17, 17],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Coordinates (Real Lat/Lng)
|
||||||
|
const HUBS = [
|
||||||
|
{ id: 'HB-DEL', name: 'Delhi Hub (DEL-01)', position: [28.6139, 77.2090], type: 'Main Hub' },
|
||||||
|
{ id: 'HB-BOM', name: 'Mumbai Hub (BOM-02)', position: [19.0760, 72.8777], type: 'City Hub' },
|
||||||
|
{ id: 'HB-BLR', name: 'Bengaluru Hub (BLR-03)', position: [12.9716, 77.5946], type: 'City Hub' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const INIT_RIDERS = [
|
||||||
|
{ id: 'R-102', name: 'Deepak Sharma', area: 'Dwarka, Delhi', status: 'Out for delivery', progress: 0.0, start: [28.5921, 77.0460], end: [28.6139, 77.2090], type: 'Miler' },
|
||||||
|
{ id: 'R-105', name: 'Karthik S.', area: 'Saket, Delhi', status: 'Picking up', progress: 0.3, start: [28.5245, 77.2066], end: [28.6139, 77.2090], type: 'Miler' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const INIT_LINEHAUL = [
|
||||||
|
{ id: 'LH-DEL-BOM', name: 'Transfer: Delhi → Mumbai', status: 'On the way', progress: 0.1, start: [28.6139, 77.2090], end: [19.0760, 72.8777], type: 'Truck' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TrackingMap() {
|
||||||
|
const [riders, setRiders] = useState(INIT_RIDERS);
|
||||||
|
const [linehauls, setLinehauls] = useState(INIT_LINEHAUL);
|
||||||
|
|
||||||
|
// Smooth operational tracking loop iteration updates
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setRiders((prev) =>
|
||||||
|
prev.map(r => ({
|
||||||
|
...r,
|
||||||
|
progress: r.progress >= 1 ? 0 : parseFloat((r.progress + 0.005).toFixed(3))
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
setLinehauls((prev) =>
|
||||||
|
prev.map(l => ({
|
||||||
|
...l,
|
||||||
|
progress: l.progress >= 1 ? 0 : parseFloat((l.progress + 0.001).toFixed(3))
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, 150);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getInterpolatedPosition = (start, end, progress) => {
|
||||||
|
return [
|
||||||
|
start[0] + (end[0] - start[0]) * progress,
|
||||||
|
start[1] + (end[1] - start[1]) * progress
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapCenter = [22.0, 76.0]; // Centered across the Delhi → Mumbai / Bengaluru network
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800, color: '#111' }}>Live Map</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">See where your milers and transfer trucks are right now, on the map.</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3.5 }}>
|
||||||
|
{/* Map Canvas Frame */}
|
||||||
|
<Box sx={{ flex: 2, minWidth: 0 }}>
|
||||||
|
<Card sx={{ height: { xs: 380, sm: 480, lg: 620 }, width: '100%', position: 'relative', overflow: 'hidden', border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 24px rgba(0,0,0,0.02)' }}>
|
||||||
|
<MapContainer center={mapCenter} zoom={6} scrollWheelZoom style={{ height: '100%', width: '100%' }}>
|
||||||
|
<MapResizeHandler />
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Plot Hubs */}
|
||||||
|
{HUBS.map(hub => (
|
||||||
|
<Marker key={hub.id} position={hub.position} icon={createHubIcon()}>
|
||||||
|
<Popup>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{hub.name}</Typography>
|
||||||
|
<Typography variant="caption">{hub.type}</Typography>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Plot Milers */}
|
||||||
|
{riders.map(rider => {
|
||||||
|
const pos = getInterpolatedPosition(rider.start, rider.end, rider.progress);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={rider.id}>
|
||||||
|
<Polyline positions={[rider.start, rider.end]} color="#0070f3" dashArray="5, 8" weight={2} opacity={0.6} />
|
||||||
|
<Marker position={pos} icon={createRiderIcon()}>
|
||||||
|
<Popup>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{rider.name}</Typography>
|
||||||
|
<Typography variant="caption">{rider.area}</Typography>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Plot Linehaul */}
|
||||||
|
{linehauls.map(lh => {
|
||||||
|
const pos = getInterpolatedPosition(lh.start, lh.end, lh.progress);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={lh.id}>
|
||||||
|
<Polyline positions={[lh.start, lh.end]} color="#C01227" dashArray="10, 10" weight={3} opacity={0.5} />
|
||||||
|
<Marker position={pos} icon={createLinehaulIcon()}>
|
||||||
|
<Popup>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{lh.name}</Typography>
|
||||||
|
<Typography variant="caption">Progress: {Math.round(lh.progress * 100)}%</Typography>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MapContainer>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sidebar Status Trackers */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
|
||||||
|
{/* Active Network Hub Nodes Summary */}
|
||||||
|
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}>
|
||||||
|
<CardHeader
|
||||||
|
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Active Hub Nodes</Typography>}
|
||||||
|
avatar={<Avatar sx={{ bgcolor: '#C0122710', color: '#C01227', borderRadius: 2 }}><HubIcon fontSize="small" /></Avatar>}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<List disablePadding>
|
||||||
|
{HUBS.map(hub => (
|
||||||
|
<ListItem key={hub.id} sx={{ px: 3, py: 1.5, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
|
||||||
|
<ListItemText
|
||||||
|
primary={hub.name}
|
||||||
|
secondary={hub.type}
|
||||||
|
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.9rem' }}
|
||||||
|
secondaryTypographyProps={{ fontSize: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
<Chip size="small" label="Online" sx={{ bgcolor: '#00A85410', color: '#00A854', fontWeight: 600, fontSize: '0.75rem' }} />
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Real-time Last Mile Miler Logs */}
|
||||||
|
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}>
|
||||||
|
<CardHeader
|
||||||
|
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Milers Out Now (Delhi)</Typography>}
|
||||||
|
avatar={<Avatar sx={{ bgcolor: '#0070f310', color: '#0070f3', borderRadius: 2 }}><DeliveryDiningIcon fontSize="small" /></Avatar>}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<List disablePadding>
|
||||||
|
{riders.map(r => (
|
||||||
|
<ListItem key={r.id} sx={{ px: 3, py: 1.75, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Badge color="success" variant="dot" overlap="circular" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
||||||
|
<Avatar sx={{ width: 34, height: 34, bgcolor: '#0070f3', fontWeight: 700, fontSize: 13 }}>{r.name.charAt(0)}</Avatar>
|
||||||
|
</Badge>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={r.name}
|
||||||
|
secondary={r.area}
|
||||||
|
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }}
|
||||||
|
secondaryTypographyProps={{ fontSize: '0.75rem', noWrap: true }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: '#0070f3', fontWeight: 700, ml: 1, bgcolor: '#0070f308', px: 1, py: 0.5, borderRadius: 2 }}>
|
||||||
|
{Math.round(r.progress * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Linehaul Fleet Shipments Tracker */}
|
||||||
|
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 3, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}>
|
||||||
|
<CardHeader
|
||||||
|
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>City Transfers</Typography>}
|
||||||
|
avatar={<Avatar sx={{ bgcolor: '#ff990010', color: '#ff9900', borderRadius: 2 }}><LocalShippingIcon fontSize="small" /></Avatar>}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<List disablePadding>
|
||||||
|
{linehauls.map(lh => (
|
||||||
|
<ListItem key={lh.id} sx={{ px: 3, py: 2 }}>
|
||||||
|
<ListItemText
|
||||||
|
primary={lh.name}
|
||||||
|
secondary={`Status: ${lh.status}`}
|
||||||
|
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }}
|
||||||
|
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.25 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: '#e08500', fontWeight: 700, bgcolor: '#ff990008', px: 1, py: 0.5, borderRadius: 2 }}>
|
||||||
|
{Math.round(lh.progress * 100)}% route
|
||||||
|
</Typography>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
191
src/theme/componentsOverride.js
Normal file
191
src/theme/componentsOverride.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import customShadows from './shadows';
|
||||||
|
|
||||||
|
// ==============================|| DOORMILE THEME - COMPONENT OVERRIDES ||============================== //
|
||||||
|
// Clean, corporate Material Design tuning for the whole console.
|
||||||
|
|
||||||
|
export default function componentsOverride(theme) {
|
||||||
|
const { palette } = theme;
|
||||||
|
|
||||||
|
return {
|
||||||
|
MuiCssBaseline: {
|
||||||
|
styleOverrides: {
|
||||||
|
html: { WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', textRendering: 'optimizeLegibility' },
|
||||||
|
body: { backgroundColor: palette.background.default },
|
||||||
|
'*::-webkit-scrollbar': { width: 8, height: 8 },
|
||||||
|
'*::-webkit-scrollbar-thumb': { background: palette.grey[300], borderRadius: 8 },
|
||||||
|
'*::-webkit-scrollbar-thumb:hover': { background: palette.grey[400] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: { disableElevation: true, disableRipple: false },
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 8,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '8px 18px',
|
||||||
|
transition: 'background-color .2s ease, box-shadow .2s ease, transform .12s ease, border-color .2s ease',
|
||||||
|
'&:active': { transform: 'translateY(0.5px)' },
|
||||||
|
'&.Mui-focusVisible': { boxShadow: `0 0 0 3px ${palette.primary.lighter}` }
|
||||||
|
},
|
||||||
|
containedPrimary: {
|
||||||
|
boxShadow: customShadows.primaryGlow,
|
||||||
|
'&:hover': { boxShadow: customShadows.primaryGlowHover, backgroundColor: palette.primary.dark }
|
||||||
|
},
|
||||||
|
outlined: {
|
||||||
|
borderColor: palette.grey[300],
|
||||||
|
'&:hover': { borderColor: palette.grey[400], backgroundColor: palette.grey[50] }
|
||||||
|
},
|
||||||
|
text: { '&:hover': { backgroundColor: palette.grey[100] } },
|
||||||
|
sizeLarge: { padding: '11px 24px', fontSize: '0.9375rem' },
|
||||||
|
sizeSmall: { padding: '5px 14px' },
|
||||||
|
// Give every button clear breathing room between its icon and label
|
||||||
|
startIcon: { marginRight: 10 },
|
||||||
|
endIcon: { marginLeft: 10 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiIconButton: {
|
||||||
|
styleOverrides: { root: { borderRadius: 8 } }
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 14,
|
||||||
|
border: `1px solid ${palette.grey[200]}`,
|
||||||
|
boxShadow: customShadows.card,
|
||||||
|
backgroundImage: 'none',
|
||||||
|
transition: 'box-shadow .22s ease, border-color .22s ease, transform .22s ease'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiCardHeader: {
|
||||||
|
defaultProps: { titleTypographyProps: { variant: 'h5' }, subheaderTypographyProps: { variant: 'caption' } },
|
||||||
|
styleOverrides: { root: { padding: 20 } }
|
||||||
|
},
|
||||||
|
MuiCardContent: {
|
||||||
|
styleOverrides: { root: { padding: 20, '&:last-child': { paddingBottom: 20 } } }
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
defaultProps: { elevation: 0 },
|
||||||
|
styleOverrides: { rounded: { borderRadius: 14 } }
|
||||||
|
},
|
||||||
|
MuiToggleButtonGroup: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { backgroundColor: palette.grey[100], borderRadius: 10, padding: 4, gap: 4 },
|
||||||
|
grouped: {
|
||||||
|
border: 0,
|
||||||
|
borderRadius: '8px !important',
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiToggleButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
border: 0,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '6px 12px',
|
||||||
|
color: palette.grey[600],
|
||||||
|
transition: 'all .18s ease',
|
||||||
|
'&:hover': { backgroundColor: palette.grey[200] },
|
||||||
|
'&.Mui-selected': {
|
||||||
|
backgroundColor: palette.background.paper,
|
||||||
|
color: palette.primary.main,
|
||||||
|
boxShadow: customShadows.card,
|
||||||
|
'&:hover': { backgroundColor: palette.background.paper }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: 6, fontWeight: 600, fontSize: '0.75rem' },
|
||||||
|
sizeSmall: { height: 22 },
|
||||||
|
label: { paddingLeft: 8, paddingRight: 8 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiTableCell: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderColor: palette.grey[200], padding: '12px 16px', fontSize: '0.8125rem' },
|
||||||
|
head: {
|
||||||
|
fontWeight: 600,
|
||||||
|
color: palette.grey[600],
|
||||||
|
backgroundColor: palette.grey[50],
|
||||||
|
textTransform: 'none',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiTableRow: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { transition: 'background-color .15s ease', '&:hover': { backgroundColor: palette.grey[50] } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiOutlinedInput: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: palette.background.paper,
|
||||||
|
transition: 'box-shadow .2s ease, border-color .2s ease',
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': { borderColor: palette.grey[300], transition: 'border-color .2s ease' },
|
||||||
|
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: palette.grey[400] },
|
||||||
|
'&.Mui-focused': { boxShadow: `0 0 0 3px ${palette.primary.lighter}` },
|
||||||
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: palette.primary.main, borderWidth: 1 }
|
||||||
|
},
|
||||||
|
input: { padding: '11px 14px' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiSelect: {
|
||||||
|
styleOverrides: {
|
||||||
|
select: {
|
||||||
|
paddingTop: '11px',
|
||||||
|
paddingBottom: '11px',
|
||||||
|
paddingLeft: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiInputLabel: {
|
||||||
|
styleOverrides: { root: { color: palette.grey[600], fontSize: '0.875rem' } }
|
||||||
|
},
|
||||||
|
MuiTab: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { textTransform: 'none', fontWeight: 600, minHeight: 46, fontSize: '0.875rem' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiTabs: {
|
||||||
|
styleOverrides: { indicator: { height: 3, borderRadius: 3 } }
|
||||||
|
},
|
||||||
|
MuiTooltip: {
|
||||||
|
styleOverrides: {
|
||||||
|
tooltip: { backgroundColor: palette.grey[800], borderRadius: 6, fontSize: '0.75rem', padding: '6px 10px' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiDialog: {
|
||||||
|
styleOverrides: { paper: { borderRadius: 16 } }
|
||||||
|
},
|
||||||
|
MuiAvatar: {
|
||||||
|
styleOverrides: { root: { fontWeight: 600, fontSize: '0.875rem' } }
|
||||||
|
},
|
||||||
|
MuiListItemButton: {
|
||||||
|
styleOverrides: { root: { borderRadius: 8 } }
|
||||||
|
},
|
||||||
|
MuiLinearProgress: {
|
||||||
|
styleOverrides: { root: { borderRadius: 8, height: 6, backgroundColor: palette.grey[200] } }
|
||||||
|
},
|
||||||
|
MuiMenu: {
|
||||||
|
styleOverrides: { paper: { borderRadius: 12, boxShadow: customShadows.dropdown, marginTop: 6, border: `1px solid ${palette.grey[200]}` } }
|
||||||
|
},
|
||||||
|
MuiMenuItem: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 8,
|
||||||
|
margin: '2px 6px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
'&:hover': { backgroundColor: palette.grey[100] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/theme/index.js
Normal file
20
src/theme/index.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
import palette from './palette';
|
||||||
|
import typography from './typography';
|
||||||
|
import customShadows from './shadows';
|
||||||
|
import componentsOverride from './componentsOverride';
|
||||||
|
|
||||||
|
// ==============================|| DOORMILE THEME - ENTRY ||============================== //
|
||||||
|
|
||||||
|
let theme = createTheme({
|
||||||
|
palette,
|
||||||
|
typography,
|
||||||
|
shape: { borderRadius: 6 },
|
||||||
|
customShadows,
|
||||||
|
mixins: { toolbar: { minHeight: 64 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
theme.components = componentsOverride(theme);
|
||||||
|
|
||||||
|
export default theme;
|
||||||
102
src/theme/palette.js
Normal file
102
src/theme/palette.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// ==============================|| DOORMILE THEME - PALETTE ||============================== //
|
||||||
|
// Corporate red brand palette. Brand red #C01227.
|
||||||
|
|
||||||
|
export const grey = {
|
||||||
|
0: '#FFFFFF',
|
||||||
|
50: '#F8F9FA',
|
||||||
|
100: '#F1F3F5',
|
||||||
|
200: '#E9ECEF',
|
||||||
|
300: '#DEE2E6',
|
||||||
|
400: '#CED4DA',
|
||||||
|
500: '#ADB5BD',
|
||||||
|
600: '#868E96',
|
||||||
|
700: '#495057',
|
||||||
|
800: '#343A40', // Slate
|
||||||
|
900: '#212529', // Graphite
|
||||||
|
A50: '#F8F9FA',
|
||||||
|
A100: '#E9ECEF'
|
||||||
|
};
|
||||||
|
|
||||||
|
const palette = {
|
||||||
|
mode: 'light',
|
||||||
|
common: { black: '#000000', white: '#FFFFFF' },
|
||||||
|
primary: {
|
||||||
|
lighter: '#F8E0E3',
|
||||||
|
100: '#EFBBC1',
|
||||||
|
200: '#E08A92',
|
||||||
|
light: '#D6515C',
|
||||||
|
400: '#CC2E3C',
|
||||||
|
main: '#C01227',
|
||||||
|
dark: '#9E0E20',
|
||||||
|
700: '#870C1B',
|
||||||
|
darker: '#7E0B17',
|
||||||
|
900: '#520710',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
lighter: grey[100],
|
||||||
|
100: grey[100],
|
||||||
|
200: grey[200],
|
||||||
|
light: grey[300],
|
||||||
|
400: grey[400],
|
||||||
|
main: grey[500],
|
||||||
|
600: grey[600],
|
||||||
|
dark: grey[700],
|
||||||
|
800: grey[800],
|
||||||
|
darker: grey[900],
|
||||||
|
A100: grey[0],
|
||||||
|
A200: grey[400],
|
||||||
|
A300: grey[700],
|
||||||
|
contrastText: grey[0]
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
lighter: '#FEEAE9',
|
||||||
|
light: '#F88078',
|
||||||
|
main: '#F04134',
|
||||||
|
dark: '#A82216',
|
||||||
|
darker: '#7A150C',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
lighter: '#FFF7E0',
|
||||||
|
light: '#FFD666',
|
||||||
|
main: '#FFBF00',
|
||||||
|
dark: '#B38600',
|
||||||
|
darker: '#805F00',
|
||||||
|
contrastText: '#262626'
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
lighter: '#E0F7F8',
|
||||||
|
light: '#66CBD2',
|
||||||
|
main: '#00A2AE',
|
||||||
|
dark: '#00727B',
|
||||||
|
darker: '#005159',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
lighter: '#E3F6EC',
|
||||||
|
light: '#5CC98C',
|
||||||
|
main: '#00A854',
|
||||||
|
dark: '#00773B',
|
||||||
|
darker: '#00552A',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
grey,
|
||||||
|
text: {
|
||||||
|
primary: grey[800],
|
||||||
|
secondary: grey[600],
|
||||||
|
disabled: grey[400]
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
disabled: grey[300],
|
||||||
|
hover: 'rgba(192, 18, 39, 0.04)',
|
||||||
|
selected: 'rgba(192, 18, 39, 0.08)'
|
||||||
|
},
|
||||||
|
divider: grey[200],
|
||||||
|
background: {
|
||||||
|
paper: '#FFFFFF',
|
||||||
|
default: grey.A50
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default palette;
|
||||||
15
src/theme/shadows.js
Normal file
15
src/theme/shadows.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// ==============================|| DOORMILE THEME - CUSTOM SHADOWS ||============================== //
|
||||||
|
// Layered elevation (a tight contact shadow + a soft ambient shadow) reads far more
|
||||||
|
// premium than a single blurry drop shadow. Brand glow on CTAs is kept restrained.
|
||||||
|
|
||||||
|
const customShadows = {
|
||||||
|
card: '0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06)',
|
||||||
|
cardHover: '0 10px 28px -6px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(16, 24, 40, 0.05)',
|
||||||
|
widget: '0 1px 3px rgba(16, 24, 40, 0.06)',
|
||||||
|
dropdown: '0 12px 32px -8px rgba(16, 24, 40, 0.18), 0 4px 10px rgba(16, 24, 40, 0.06)',
|
||||||
|
primaryGlow: '0 4px 12px -2px rgba(192, 18, 39, 0.22)',
|
||||||
|
primaryGlowHover: '0 6px 16px -2px rgba(192, 18, 39, 0.30)',
|
||||||
|
header: '0 1px 0 rgba(16, 24, 40, 0.06)'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default customShadows;
|
||||||
28
src/theme/typography.js
Normal file
28
src/theme/typography.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// ==============================|| DOORMILE THEME - TYPOGRAPHY ||============================== //
|
||||||
|
// A deliberate type scale with optical tracking: large display text gets tighter
|
||||||
|
// negative letter-spacing, small UI text stays legible. Optical tracking like this
|
||||||
|
// is what separates a designed interface from a default Material build.
|
||||||
|
|
||||||
|
const typography = {
|
||||||
|
fontFamily: '"Public Sans", "Inter", "Helvetica", "Arial", sans-serif',
|
||||||
|
htmlFontSize: 16,
|
||||||
|
fontWeightLight: 300,
|
||||||
|
fontWeightRegular: 400,
|
||||||
|
fontWeightMedium: 500,
|
||||||
|
fontWeightBold: 700,
|
||||||
|
h1: { fontWeight: 800, fontSize: '2.375rem', lineHeight: 1.15, letterSpacing: '-0.022em' },
|
||||||
|
h2: { fontWeight: 800, fontSize: '1.875rem', lineHeight: 1.2, letterSpacing: '-0.02em' },
|
||||||
|
h3: { fontWeight: 700, fontSize: '1.5rem', lineHeight: 1.28, letterSpacing: '-0.018em' },
|
||||||
|
h4: { fontWeight: 700, fontSize: '1.25rem', lineHeight: 1.35, letterSpacing: '-0.014em' },
|
||||||
|
h5: { fontWeight: 700, fontSize: '1rem', lineHeight: 1.5, letterSpacing: '-0.01em' },
|
||||||
|
h6: { fontWeight: 600, fontSize: '0.875rem', lineHeight: 1.57, letterSpacing: '-0.006em' },
|
||||||
|
caption: { fontWeight: 400, fontSize: '0.75rem', lineHeight: 1.66 },
|
||||||
|
body1: { fontSize: '0.875rem', lineHeight: 1.6, letterSpacing: '-0.003em' },
|
||||||
|
body2: { fontSize: '0.75rem', lineHeight: 1.66 },
|
||||||
|
subtitle1: { fontSize: '0.875rem', fontWeight: 600, lineHeight: 1.57, letterSpacing: '-0.006em' },
|
||||||
|
subtitle2: { fontSize: '0.75rem', fontWeight: 600, lineHeight: 1.66 },
|
||||||
|
overline: { fontSize: '0.6875rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' },
|
||||||
|
button: { textTransform: 'none', fontWeight: 600, letterSpacing: '-0.006em' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default typography;
|
||||||
23
vite.config.js
Normal file
23
vite.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
css: {
|
||||||
|
// Inline postcss config avoids dynamic package resolution issues
|
||||||
|
postcss: { plugins: [] }
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
strictPort: true,
|
||||||
|
host: true,
|
||||||
|
open: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user