wired the api for rest mock data
This commit is contained in:
106
package-lock.json
generated
106
package-lock.json
generated
@@ -17,7 +17,8 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4"
|
"react-router-dom": "^6.30.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.8.0",
|
"@eslint/js": "^9.8.0",
|
||||||
@@ -1891,6 +1892,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -2255,6 +2265,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -2281,6 +2304,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2330,6 +2362,18 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3111,6 +3155,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -4943,6 +4996,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
@@ -5416,6 +5481,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -5426,6 +5509,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4"
|
"react-router-dom": "^6.30.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.8.0",
|
"@eslint/js": "^9.8.0",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { getToken, clearSession } from '@/auth/session';
|
import { getToken, clearSession } from '@/auth/session';
|
||||||
|
import { log } from '@/lib/logger';
|
||||||
|
|
||||||
// In dev, default to same-origin ('') so requests hit `/api/...` and Vite proxies
|
// In dev, default to same-origin ('') so requests hit `/api/...` and Vite proxies
|
||||||
// them to the backend (avoids the backend's localhost-only CORS allowlist).
|
// them to the backend (avoids the backend's localhost-only CORS allowlist).
|
||||||
@@ -61,12 +62,14 @@ export async function request(path, { method = 'GET', body, auth = true, interna
|
|||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${BASE_URL}${path}`, opts);
|
res = await fetch(`${BASE_URL}${path}`, opts);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
log.error(`${method} ${path} — network error`, err);
|
||||||
throw new ApiError('Could not reach the server. Check your connection and try again.', 0);
|
throw new ApiError('Could not reach the server. Check your connection and try again.', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 401 → session is dead. Clear and send to login before anyone reads the body.
|
// 401 → session is dead. Clear and send to login before anyone reads the body.
|
||||||
if (res.status === 401 && !internal) {
|
if (res.status === 401 && !internal) {
|
||||||
|
log.warn(`${method} ${path} — 401, session expired; redirecting to login`);
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
throw new ApiError('Your session has expired. Please sign in again.', 401);
|
throw new ApiError('Your session has expired. Please sign in again.', 401);
|
||||||
}
|
}
|
||||||
@@ -92,9 +95,11 @@ export async function request(path, { method = 'GET', body, auth = true, interna
|
|||||||
: res.status >= 500
|
: res.status >= 500
|
||||||
? 'Something went wrong on our side. Please try again.'
|
? 'Something went wrong on our side. Please try again.'
|
||||||
: `Request failed (${res.status}).`);
|
: `Request failed (${res.status}).`);
|
||||||
|
log.error(`${method} ${path} — ${res.status}`, message, data ?? '');
|
||||||
throw new ApiError(message, res.status, data);
|
throw new ApiError(message, res.status, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug(`${method} ${path} — ${res.status}`);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export function getDashboard() {
|
|||||||
return http.get(`${V1}/hub/dashboard`);
|
return http.get(`${V1}/hub/dashboard`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full hub report for a date range — powers the dashboard "Export" (Excel) button.
|
||||||
|
* Backend must return every record in the window (see the frontend integration doc):
|
||||||
|
* { data: { range, summary, inbound[], dispatch[], assignments[], exceptions[], activity[] } }
|
||||||
|
*/
|
||||||
|
export function getHubReport(from, to) {
|
||||||
|
return http.get(`${V1}/hub/report?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 3. Inbound — receive parcels ─────────────────────────────────────────────
|
// ── 3. Inbound — receive parcels ─────────────────────────────────────────────
|
||||||
export function getInboundToday() {
|
export function getInboundToday() {
|
||||||
return http.get(`${V1}/hub/inbound/today`);
|
return http.get(`${V1}/hub/inbound/today`);
|
||||||
@@ -145,3 +154,27 @@ export function getNotifications() {
|
|||||||
export function markNotificationRead(id) {
|
export function markNotificationRead(id) {
|
||||||
return http.patch(`${V1}/hub/notifications/${id}/read`, {});
|
return http.patch(`${V1}/hub/notifications/${id}/read`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 14. Messages / Chat ──────────────────────────────────────────────────────
|
||||||
|
// One conversation per miler at the hub. The backend lazily provisions a
|
||||||
|
// conversation the first time it's listed, so no explicit "create" call exists.
|
||||||
|
|
||||||
|
/** List conversations for the hub — { data: [{ id, name, lastmessage, time, unread }] }. */
|
||||||
|
export function getConversations() {
|
||||||
|
return http.get(`${V1}/hub/messages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full thread for one conversation — { data: { id, name, messages: [{ sender, text, time }] } }. */
|
||||||
|
export function getConversation(id) {
|
||||||
|
return http.get(`${V1}/hub/messages/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a message into a conversation as hub staff (sender: "me"). */
|
||||||
|
export function sendMessage(id, text) {
|
||||||
|
return http.post(`${V1}/hub/messages/${id}`, { text });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark the other party's ("them") messages in a conversation as read. */
|
||||||
|
export function markConversationRead(id) {
|
||||||
|
return http.patch(`${V1}/hub/messages/${id}/read`, {});
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,14 @@ import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
|||||||
|
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import { getStaff, getHubContext, clearSession } from '@/auth/session';
|
import { getStaff, getHubContext, clearSession } from '@/auth/session';
|
||||||
import { getNotifications, markNotificationRead } from '@/api/hub';
|
import {
|
||||||
|
getNotifications,
|
||||||
|
markNotificationRead,
|
||||||
|
getConversations,
|
||||||
|
getConversation,
|
||||||
|
sendMessage,
|
||||||
|
markConversationRead
|
||||||
|
} from '@/api/hub';
|
||||||
|
|
||||||
const RED = '#C01227';
|
const RED = '#C01227';
|
||||||
|
|
||||||
@@ -63,42 +70,27 @@ const toInitials = (name) =>
|
|||||||
.join('')
|
.join('')
|
||||||
.toUpperCase() || 'HB';
|
.toUpperCase() || 'HB';
|
||||||
|
|
||||||
const MESSAGES = [
|
// Normalise a conversation summary from GET /hub/messages. Be tolerant of a few
|
||||||
{ id: 1, name: 'Devendra (Gate Supervisor)', text: 'Jaipur vehicle is backing into Bay 4 now.', time: '3 min ago', initials: 'DS' },
|
// backend field-name variants so the list still renders if a key is named differently.
|
||||||
{ id: 2, name: 'Suresh (Sorter Zone A)', text: 'Completed sorting for South Delhi batch. Ready to verify.', time: '12 min ago', initials: 'SS' },
|
const toConversation = (c) => {
|
||||||
{ id: 3, name: 'Neha (Last-mile Dispatcher)', text: 'We need 2 more milers for South Delhi route.', time: '45 min ago', initials: 'ND' }
|
const name = c.name || c.milername || c.displayname || `Miler ${c.mileruserid ?? c.id}`;
|
||||||
];
|
return {
|
||||||
|
id: c.id ?? c.conversationid ?? c.mileruserid,
|
||||||
const INITIAL_CHATS = {
|
name,
|
||||||
1: {
|
initials: toInitials(name),
|
||||||
name: 'Devendra (Gate Supervisor)',
|
lastMessage: c.lastmessage ?? c.last_message ?? c.preview ?? '',
|
||||||
initials: 'DS',
|
time: c.time ?? c.lasttime ?? c.updatedat ?? '',
|
||||||
chat: [
|
unread: Number(c.unread ?? c.unreadcount ?? 0) || 0
|
||||||
{ 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' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Normalise one message in a thread from GET /hub/messages/:id.
|
||||||
|
const toMessage = (m) => ({
|
||||||
|
sender: m.sender === 'me' ? 'me' : 'them',
|
||||||
|
text: m.text ?? m.body ?? m.message ?? '',
|
||||||
|
time: m.time ?? m.createdat ?? m.sentat ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
export default function Header({ onToggle }) {
|
export default function Header({ onToggle }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const staff = getStaff();
|
const staff = getStaff();
|
||||||
@@ -117,10 +109,14 @@ export default function Header({ onToggle }) {
|
|||||||
|
|
||||||
// Dialog State
|
// Dialog State
|
||||||
const [selectedNotif, setSelectedNotif] = useState(null);
|
const [selectedNotif, setSelectedNotif] = useState(null);
|
||||||
const [selectedChatId, setSelectedChatId] = useState(null);
|
const [conversations, setConversations] = useState([]);
|
||||||
const [chats, setChats] = useState(INITIAL_CHATS);
|
const [activeChat, setActiveChat] = useState(null); // { id, name, initials, messages: [] }
|
||||||
|
const [chatLoading, setChatLoading] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
const [typedMessage, setTypedMessage] = useState('');
|
const [typedMessage, setTypedMessage] = useState('');
|
||||||
|
|
||||||
|
const unreadMessages = conversations.reduce((sum, c) => sum + c.unread, 0);
|
||||||
|
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
@@ -152,9 +148,28 @@ export default function Header({ onToggle }) {
|
|||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [loadNotifications]);
|
}, [loadNotifications]);
|
||||||
|
|
||||||
|
// Load conversations (one per miler at the hub) for the messages dropdown.
|
||||||
|
const loadConversations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getConversations();
|
||||||
|
setConversations((res?.data || []).map(toConversation));
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: leave the messages list empty if it can't load.
|
||||||
|
setConversations([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConversations();
|
||||||
|
const t = setInterval(loadConversations, 60000); // refresh every 60s
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [loadConversations]);
|
||||||
|
|
||||||
const openNotif = (e) => { setNotifAnchor(e.currentTarget); loadNotifications(); };
|
const openNotif = (e) => { setNotifAnchor(e.currentTarget); loadNotifications(); };
|
||||||
const closeNotif = () => setNotifAnchor(null);
|
const closeNotif = () => setNotifAnchor(null);
|
||||||
|
|
||||||
|
const openMessages = (e) => { setMsgAnchor(e.currentTarget); loadConversations(); };
|
||||||
|
|
||||||
const markAllRead = async () => {
|
const markAllRead = async () => {
|
||||||
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
|
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
|
||||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
@@ -171,30 +186,61 @@ export default function Header({ onToggle }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMessageClick = (mId) => {
|
// Open a conversation: show the header immediately, then load the thread and
|
||||||
|
// mark the other party's messages as read (which clears the unread badge).
|
||||||
|
const onMessageClick = async (conv) => {
|
||||||
setMsgAnchor(null);
|
setMsgAnchor(null);
|
||||||
setSelectedChatId(mId);
|
setTypedMessage('');
|
||||||
|
setActiveChat({ id: conv.id, name: conv.name, initials: conv.initials, messages: [] });
|
||||||
|
setChatLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getConversation(conv.id);
|
||||||
|
const thread = res?.data || {};
|
||||||
|
setActiveChat({
|
||||||
|
id: conv.id,
|
||||||
|
name: thread.name || conv.name,
|
||||||
|
initials: toInitials(thread.name || conv.name),
|
||||||
|
messages: (thread.messages || thread.chat || []).map(toMessage)
|
||||||
|
});
|
||||||
|
if (conv.unread > 0) {
|
||||||
|
await markConversationRead(conv.id).catch(() => {});
|
||||||
|
setConversations((prev) => prev.map((c) => (c.id === conv.id ? { ...c, unread: 0 } : c)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Leave the (empty) thread open; the header still shows who it's with.
|
||||||
|
} finally {
|
||||||
|
setChatLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = () => {
|
const closeChat = () => { setActiveChat(null); setTypedMessage(''); };
|
||||||
if (!typedMessage.trim() || !selectedChatId) return;
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
const text = typedMessage.trim();
|
||||||
|
if (!text || !activeChat || sending) return;
|
||||||
|
setSending(true);
|
||||||
|
// Optimistically append; reconcile with the server's stored copy on success.
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
setActiveChat((c) => ({ ...c, messages: [...c.messages, { sender: 'me', text, time: timeStr }] }));
|
||||||
setChats((prev) => {
|
|
||||||
const activeChat = prev[selectedChatId];
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[selectedChatId]: {
|
|
||||||
...activeChat,
|
|
||||||
chat: [
|
|
||||||
...activeChat.chat,
|
|
||||||
{ sender: 'me', text: typedMessage, time: timeStr }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setTypedMessage('');
|
setTypedMessage('');
|
||||||
|
try {
|
||||||
|
await sendMessage(activeChat.id, text);
|
||||||
|
// Refresh the thread so the persisted message (and its real timestamp) shows.
|
||||||
|
const res = await getConversation(activeChat.id);
|
||||||
|
const thread = res?.data || {};
|
||||||
|
setActiveChat((c) => c && { ...c, messages: (thread.messages || thread.chat || []).map(toMessage) });
|
||||||
|
// Keep the dropdown preview in sync.
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((c) => (c.id === activeChat.id ? { ...c, lastMessage: text, time: timeStr } : c))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// On failure, drop the optimistic bubble and restore the draft to retry.
|
||||||
|
setActiveChat((c) => c && { ...c, messages: c.messages.filter((m) => !(m.sender === 'me' && m.text === text && m.time === timeStr)) });
|
||||||
|
setTypedMessage(text);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitSearch = (e) => {
|
const submitSearch = (e) => {
|
||||||
@@ -258,8 +304,8 @@ export default function Header({ onToggle }) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Tooltip title="Messages">
|
<Tooltip title="Messages">
|
||||||
<IconButton color="inherit" onClick={(e) => setMsgAnchor(e.currentTarget)}>
|
<IconButton color="inherit" onClick={openMessages}>
|
||||||
<Badge badgeContent={MESSAGES.length} color="error">
|
<Badge badgeContent={unreadMessages} color="error">
|
||||||
<ChatIcon />
|
<ChatIcon />
|
||||||
</Badge>
|
</Badge>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -354,8 +400,13 @@ export default function Header({ onToggle }) {
|
|||||||
Messages
|
Messages
|
||||||
</Typography>
|
</Typography>
|
||||||
<Divider />
|
<Divider />
|
||||||
{MESSAGES.map((m) => (
|
{conversations.length === 0 && (
|
||||||
<MenuItem key={m.id} onClick={() => onMessageClick(m.id)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
<MenuItem disabled>
|
||||||
|
<ListItemText primary="No messages" />
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{conversations.map((m) => (
|
||||||
|
<MenuItem key={m.id} onClick={() => onMessageClick(m)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
||||||
<ListItemIcon sx={{ mt: 0.25 }}>
|
<ListItemIcon sx={{ mt: 0.25 }}>
|
||||||
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
|
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
|
||||||
{m.initials}
|
{m.initials}
|
||||||
@@ -363,13 +414,18 @@ export default function Header({ onToggle }) {
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={m.name}
|
primary={m.name}
|
||||||
secondary={m.text}
|
secondary={m.lastMessage || 'No messages yet'}
|
||||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 700 }}
|
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: m.unread > 0 ? 700 : 600 }}
|
||||||
secondaryTypographyProps={{ fontSize: '0.8rem' }}
|
secondaryTypographyProps={{ fontSize: '0.8rem', noWrap: true }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, mt: 0.5, flexShrink: 0 }}>
|
<Stack alignItems="flex-end" sx={{ ml: 1, flexShrink: 0 }}>
|
||||||
{m.time}
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
</Typography>
|
{m.time}
|
||||||
|
</Typography>
|
||||||
|
{m.unread > 0 && (
|
||||||
|
<Badge badgeContent={m.unread} color="error" sx={{ mt: 1, mr: 0.75 }} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -439,21 +495,31 @@ export default function Header({ onToggle }) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* High Fidelity Chat Message Dialog */}
|
{/* High Fidelity Chat Message Dialog */}
|
||||||
<Dialog open={Boolean(selectedChatId)} onClose={() => setSelectedChatId(null)} fullWidth maxWidth="xs">
|
<Dialog open={Boolean(activeChat)} onClose={closeChat} fullWidth maxWidth="xs">
|
||||||
{selectedChatId && chats[selectedChatId] && (
|
{activeChat && (
|
||||||
<>
|
<>
|
||||||
<DialogTitle sx={{ fontWeight: 700, py: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<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 }}>
|
<Avatar sx={{ bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, width: 34, height: 34 }}>
|
||||||
{chats[selectedChatId].initials}
|
{activeChat.initials}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||||
{chats[selectedChatId].name}
|
{activeChat.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Divider />
|
<Divider />
|
||||||
<DialogContent sx={{ p: 2, bgcolor: 'grey.50', height: 280, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
<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 }}>
|
<Stack spacing={2} sx={{ overflowY: 'auto', pr: 0.5, flexGrow: 1, mb: 2 }}>
|
||||||
{chats[selectedChatId].chat.map((msg, idx) => (
|
{chatLoading && activeChat.messages.length === 0 && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
|
||||||
|
Loading…
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{!chatLoading && activeChat.messages.length === 0 && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
|
||||||
|
No messages yet. Say hello.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{activeChat.messages.map((msg, idx) => (
|
||||||
<Box
|
<Box
|
||||||
key={idx}
|
key={idx}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -503,7 +569,7 @@ export default function Header({ onToggle }) {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<IconButton onClick={handleSendMessage} size="small" color="primary">
|
<IconButton onClick={handleSendMessage} size="small" color="primary" disabled={sending || !typedMessage.trim()}>
|
||||||
<SendIcon fontSize="small" />
|
<SendIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
|
|||||||
115
src/lib/hubReport.js
Normal file
115
src/lib/hubReport.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Build and download the hub report as a multi-tab Excel (.xlsx) workbook.
|
||||||
|
//
|
||||||
|
// Input is the payload from GET /api/v1/hub/report?from=&to= (see api/hub.js).
|
||||||
|
// Each section of the report becomes its own worksheet so a manager gets every
|
||||||
|
// record for the chosen date range in one file. Missing sections are skipped
|
||||||
|
// gracefully, so the export never crashes on a sparse response.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { log } from './logger';
|
||||||
|
|
||||||
|
// Turn an array of row objects into a worksheet with human-friendly headers.
|
||||||
|
// `columns` = [{ key, header }]. Rows missing a key render as an empty cell.
|
||||||
|
function sheetFromRows(rows, columns) {
|
||||||
|
const header = columns.map((c) => c.header);
|
||||||
|
const body = (rows || []).map((r) => columns.map((c) => r?.[c.key] ?? ''));
|
||||||
|
return XLSX.utils.aoa_to_sheet([header, ...body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column definitions per section — order here = column order in the sheet.
|
||||||
|
const COLUMNS = {
|
||||||
|
inbound: [
|
||||||
|
{ key: 'date', header: 'Date' },
|
||||||
|
{ key: 'bookingid', header: 'Booking ID' },
|
||||||
|
{ key: 'trackingnumber', header: 'Tracking No.' },
|
||||||
|
{ key: 'sendername', header: 'Sender' },
|
||||||
|
{ key: 'origin', header: 'Origin' },
|
||||||
|
{ key: 'destination', header: 'Destination' },
|
||||||
|
{ key: 'weight', header: 'Weight' },
|
||||||
|
{ key: 'condition', header: 'Condition' },
|
||||||
|
{ key: 'shelf', header: 'Shelf' },
|
||||||
|
{ key: 'inboundedat', header: 'Inbounded At' }
|
||||||
|
],
|
||||||
|
dispatch: [
|
||||||
|
{ key: 'date', header: 'Date' },
|
||||||
|
{ key: 'batchlabel', header: 'Batch' },
|
||||||
|
{ key: 'kind', header: 'Kind' },
|
||||||
|
{ key: 'destination', header: 'Destination' },
|
||||||
|
{ key: 'vehicle', header: 'Vehicle' },
|
||||||
|
{ key: 'itemcount', header: 'Parcels' },
|
||||||
|
{ key: 'status', header: 'Status' },
|
||||||
|
{ key: 'dispatchtime', header: 'Dispatched At' }
|
||||||
|
],
|
||||||
|
assignments: [
|
||||||
|
{ key: 'date', header: 'Date' },
|
||||||
|
{ key: 'bookingid', header: 'Booking ID' },
|
||||||
|
{ key: 'customer', header: 'Customer' },
|
||||||
|
{ key: 'milername', header: 'Miler' },
|
||||||
|
{ key: 'pickup', header: 'Pickup' },
|
||||||
|
{ key: 'delivery', header: 'Delivery' },
|
||||||
|
{ key: 'status', header: 'Status' }
|
||||||
|
],
|
||||||
|
exceptions: [
|
||||||
|
{ key: 'date', header: 'Date' },
|
||||||
|
{ key: 'trackingnumber', header: 'Tracking No.' },
|
||||||
|
{ key: 'reason', header: 'Reason' },
|
||||||
|
{ key: 'shelf', header: 'Shelf' }
|
||||||
|
],
|
||||||
|
activity: [
|
||||||
|
{ key: 'time', header: 'Time' },
|
||||||
|
{ key: 'type', header: 'Type' },
|
||||||
|
{ key: 'text', header: 'Event' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_TABS = [
|
||||||
|
['inbound', 'Inbound Parcels'],
|
||||||
|
['dispatch', 'Dispatched Batches'],
|
||||||
|
['assignments', 'Assignments'],
|
||||||
|
['exceptions', 'Exceptions'],
|
||||||
|
['activity', 'Activity Log']
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the workbook and trigger a browser download.
|
||||||
|
* @param {object} report The `data` object from GET /hub/report.
|
||||||
|
* @param {object} meta { hubName, from, to }
|
||||||
|
*/
|
||||||
|
export function downloadHubReport(report, { hubName = 'Hub', from, to } = {}) {
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
// ── Sheet 1: Summary ──
|
||||||
|
const summary = report?.summary || {};
|
||||||
|
const summaryRows = [
|
||||||
|
['Hub', hubName],
|
||||||
|
['From', from || report?.range?.from || ''],
|
||||||
|
['To', to || report?.range?.to || ''],
|
||||||
|
['Generated', new Date().toLocaleString()],
|
||||||
|
[],
|
||||||
|
['Metric', 'Value'],
|
||||||
|
...Object.entries(summary).map(([k, v]) => [prettyLabel(k), v])
|
||||||
|
];
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summaryRows), 'Summary');
|
||||||
|
|
||||||
|
// ── One sheet per section that has columns defined ──
|
||||||
|
SECTION_TABS.forEach(([key, tabName]) => {
|
||||||
|
const ws = sheetFromRows(report?.[key], COLUMNS[key]);
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, tabName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeFrom = (from || 'start').replaceAll('-', '');
|
||||||
|
const safeTo = (to || 'end').replaceAll('-', '');
|
||||||
|
const fname = `Doormile_${hubName.replace(/[^A-Za-z0-9]+/g, '-')}_${safeFrom}_${safeTo}.xlsx`;
|
||||||
|
|
||||||
|
log.info('Exporting hub report', fname);
|
||||||
|
XLSX.writeFile(wb, fname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// snake_case / lower keys → "Title Case" labels for the Summary sheet.
|
||||||
|
function prettyLabel(k) {
|
||||||
|
return String(k)
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
44
src/lib/logger.js
Normal file
44
src/lib/logger.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Lightweight app-wide logger for the Doormile Hub Console.
|
||||||
|
//
|
||||||
|
// Use this instead of raw console.* so logging behaviour is consistent and can
|
||||||
|
// be tuned in one place:
|
||||||
|
// • In development every level prints (debug/info/warn/error).
|
||||||
|
// • In production only warn/error print — info/debug are silenced to keep the
|
||||||
|
// console clean and avoid leaking internal detail to end users.
|
||||||
|
// • error() is the single place to later forward exceptions to a monitoring
|
||||||
|
// service (Sentry, Datadog, etc.) — wire it in once, here.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// import { log } from '@/lib/logger';
|
||||||
|
// log.info('Dashboard loaded', kpis);
|
||||||
|
// log.error('Failed to assign miler', err);
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
|
// Minimum level that gets printed. Everything at or above this index prints.
|
||||||
|
const LEVELS = ['debug', 'info', 'warn', 'error'];
|
||||||
|
const MIN_LEVEL = isDev ? 'debug' : 'warn';
|
||||||
|
|
||||||
|
const enabled = (level) => LEVELS.indexOf(level) >= LEVELS.indexOf(MIN_LEVEL);
|
||||||
|
const stamp = () => new Date().toISOString();
|
||||||
|
|
||||||
|
export const log = {
|
||||||
|
debug: (...args) => {
|
||||||
|
if (enabled('debug')) console.debug(`[DEBUG ${stamp()}]`, ...args);
|
||||||
|
},
|
||||||
|
info: (...args) => {
|
||||||
|
if (enabled('info')) console.info(`[INFO ${stamp()}]`, ...args);
|
||||||
|
},
|
||||||
|
warn: (...args) => {
|
||||||
|
if (enabled('warn')) console.warn(`[WARN ${stamp()}]`, ...args);
|
||||||
|
},
|
||||||
|
error: (...args) => {
|
||||||
|
if (enabled('error')) console.error(`[ERROR ${stamp()}]`, ...args);
|
||||||
|
// Single hook point for remote error reporting, e.g.:
|
||||||
|
// Sentry.captureException(args.find((a) => a instanceof Error) ?? args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default log;
|
||||||
@@ -22,7 +22,10 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Popover,
|
Popover,
|
||||||
Alert,
|
Alert,
|
||||||
Skeleton
|
Skeleton,
|
||||||
|
useMediaQuery,
|
||||||
|
Snackbar,
|
||||||
|
CircularProgress
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { alpha } from '@mui/material/styles';
|
import { alpha } from '@mui/material/styles';
|
||||||
@@ -38,11 +41,12 @@ import SpeedIcon from '@mui/icons-material/Speed';
|
|||||||
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
|
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
|
||||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||||
|
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||||
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
|
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
|
||||||
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
||||||
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
||||||
|
|
||||||
import { getDashboard, getInboundVehicles, getActivity, getZones } from '@/api/hub';
|
import { getDashboard, getInboundVehicles, getActivity, getZones, getHubReport } from '@/api/hub';
|
||||||
import { getHubContext } from '@/auth/session';
|
import { getHubContext } from '@/auth/session';
|
||||||
|
|
||||||
// Format an ISO time as a short clock label for the activity feed.
|
// Format an ISO time as a short clock label for the activity feed.
|
||||||
@@ -178,11 +182,27 @@ const KPI_DEFS = [
|
|||||||
|
|
||||||
const DATE_FMT = 'YYYY-MM-DD';
|
const DATE_FMT = 'YYYY-MM-DD';
|
||||||
|
|
||||||
|
const RANGE_KEY = 'hub_dashboard_range';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const today = dayjs().format(DATE_FMT);
|
const today = dayjs().format(DATE_FMT);
|
||||||
const weekAgo = dayjs().subtract(6, 'day').format(DATE_FMT);
|
const weekAgo = dayjs().subtract(6, 'day').format(DATE_FMT);
|
||||||
const [range, setRange] = useState({ from: weekAgo, to: today });
|
// Persist the chosen range so it survives leaving the page and coming back
|
||||||
|
// (the component unmounts on navigation, which would otherwise reset it).
|
||||||
|
const [range, setRange] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(RANGE_KEY));
|
||||||
|
// Ignore a stale future/invalid `to`; otherwise restore the saved range.
|
||||||
|
if (saved?.from && saved?.to && !dayjs(saved.to).isAfter(dayjs(today))) return saved;
|
||||||
|
} catch { /* fall through to default */ }
|
||||||
|
return { from: weekAgo, to: today };
|
||||||
|
});
|
||||||
const [calAnchor, setCalAnchor] = useState(null);
|
const [calAnchor, setCalAnchor] = useState(null);
|
||||||
|
const isMobile = useMediaQuery('(max-width:600px)');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(RANGE_KEY, JSON.stringify(range));
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
const hub = getHubContext();
|
const hub = getHubContext();
|
||||||
const [kpis, setKpis] = useState(null);
|
const [kpis, setKpis] = useState(null);
|
||||||
@@ -191,6 +211,8 @@ export default function Dashboard() {
|
|||||||
const [incomingVehicles, setIncomingVehicles] = useState([]);
|
const [incomingVehicles, setIncomingVehicles] = useState([]);
|
||||||
const [recentActivity, setRecentActivity] = useState([]);
|
const [recentActivity, setRecentActivity] = useState([]);
|
||||||
const [activeRoutes, setActiveRoutes] = useState([]);
|
const [activeRoutes, setActiveRoutes] = useState([]);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
|
||||||
|
|
||||||
const loadDashboard = useCallback(async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -235,6 +257,36 @@ export default function Dashboard() {
|
|||||||
loadDashboard();
|
loadDashboard();
|
||||||
}, [loadDashboard]);
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
// Pull the full report for the selected range and download it as an Excel file.
|
||||||
|
const handleExport = useCallback(async () => {
|
||||||
|
if (exporting) return;
|
||||||
|
if (dayjs(range.to).isBefore(dayjs(range.from))) {
|
||||||
|
setToast({ open: true, msg: 'The "to" date is before the "from" date. Fix the range first.', severity: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
// Lazy-load the Excel builder (pulls in the heavy xlsx lib) only on first export.
|
||||||
|
const [{ downloadHubReport }, res] = await Promise.all([
|
||||||
|
import('@/lib/hubReport'),
|
||||||
|
getHubReport(range.from, range.to)
|
||||||
|
]);
|
||||||
|
downloadHubReport(res?.data || {}, { hubName: hub.hubname || 'Hub', from: range.from, to: range.to });
|
||||||
|
setToast({ open: true, msg: 'Report downloaded.', severity: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
const notReady = err?.status === 404;
|
||||||
|
setToast({
|
||||||
|
open: true,
|
||||||
|
msg: notReady
|
||||||
|
? 'Report endpoint not available yet. Ask the backend to add GET /hub/report?from=&to=.'
|
||||||
|
: err?.message || 'Could not generate the report.',
|
||||||
|
severity: notReady ? 'warning' : 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}, [exporting, range.from, range.to, hub.hubname]);
|
||||||
|
|
||||||
const kpiCards = KPI_DEFS.map((d) => ({
|
const kpiCards = KPI_DEFS.map((d) => ({
|
||||||
...d,
|
...d,
|
||||||
value: kpis && kpis[d.key] != null ? Number(kpis[d.key]).toLocaleString('en-IN') : '—'
|
value: kpis && kpis[d.key] != null ? Number(kpis[d.key]).toLocaleString('en-IN') : '—'
|
||||||
@@ -282,9 +334,10 @@ export default function Dashboard() {
|
|||||||
direction="row"
|
direction="row"
|
||||||
spacing={1}
|
spacing={1}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
flexWrap="wrap"
|
|
||||||
useFlexGap
|
useFlexGap
|
||||||
sx={{ flexGrow: 1, minWidth: 0, justifyContent: { xs: 'flex-start', md: 'flex-end' } }}
|
// flexWrap must live in sx — MUI's Stack ignores it as a top-level prop, which
|
||||||
|
// is why this row overflowed instead of wrapping on narrow screens.
|
||||||
|
sx={{ flexWrap: 'wrap', width: { xs: '100%', md: 'auto' }, flexGrow: 1, minWidth: 0, justifyContent: { xs: 'flex-start', md: 'flex-end' } }}
|
||||||
>
|
>
|
||||||
{/* Refresh Button */}
|
{/* Refresh Button */}
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -347,7 +400,9 @@ export default function Dashboard() {
|
|||||||
borderColor: invalidRange ? '#EF4444' : '#E2E8F0',
|
borderColor: invalidRange ? '#EF4444' : '#E2E8F0',
|
||||||
bgcolor: '#ffffff',
|
bgcolor: '#ffffff',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
minWidth: 210, // Compressed from 250px to remove dead layout space
|
// Full width on mobile so it wraps to its own line; fixed on larger screens.
|
||||||
|
width: { xs: '100%', sm: 'auto' },
|
||||||
|
minWidth: { sm: 210 }, // Compressed from 250px to remove dead layout space
|
||||||
'&:hover': { borderColor: BRAND, bgcolor: alpha(BRAND, 0.02) }
|
'&:hover': { borderColor: BRAND, bgcolor: alpha(BRAND, 0.02) }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -357,6 +412,31 @@ export default function Dashboard() {
|
|||||||
<Box component="span">{dayjs(range.to).format('DD MMM YYYY')}</Box>
|
<Box component="span">{dayjs(range.to).format('DD MMM YYYY')}</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Export report (Excel) for the selected date range */}
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exporting || invalidRange}
|
||||||
|
variant="contained"
|
||||||
|
startIcon={exporting
|
||||||
|
? <CircularProgress size={16} sx={{ color: '#fff' }} />
|
||||||
|
: <FileDownloadOutlinedIcon sx={{ fontSize: 18 }} />}
|
||||||
|
sx={{
|
||||||
|
height: 36,
|
||||||
|
px: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.825rem',
|
||||||
|
bgcolor: BRAND,
|
||||||
|
boxShadow: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
width: { xs: '100%', sm: 'auto' },
|
||||||
|
'&:hover': { bgcolor: '#9E0E20', boxShadow: 'none' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exporting ? 'Exporting…' : 'Export'}
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -365,9 +445,12 @@ export default function Dashboard() {
|
|||||||
open={Boolean(calAnchor)}
|
open={Boolean(calAnchor)}
|
||||||
anchorEl={calAnchor}
|
anchorEl={calAnchor}
|
||||||
onClose={() => setCalAnchor(null)}
|
onClose={() => setCalAnchor(null)}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
// On mobile the trigger sits on the left, so a right-aligned popover ran off
|
||||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
// the screen edge. Left-align on mobile and keep MUI clamping it into view.
|
||||||
PaperProps={{ sx: { mt: 1, borderRadius: 2.5, border: '1px solid #EEF0F2', boxShadow: '0 8px 28px rgba(0,0,0,0.10)', overflow: 'hidden' } }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: isMobile ? 'left' : 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: isMobile ? 'left' : 'right' }}
|
||||||
|
marginThreshold={12}
|
||||||
|
PaperProps={{ sx: { mt: 1, borderRadius: 2.5, border: '1px solid #EEF0F2', boxShadow: '0 8px 28px rgba(0,0,0,0.10)', overflow: 'hidden', maxWidth: 'calc(100vw - 24px)' } }}
|
||||||
>
|
>
|
||||||
<Box sx={{ px: 2, pt: 1.75, pb: 0.5 }}>
|
<Box sx={{ px: 2, pt: 1.75, pb: 0.5 }}>
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9rem', color: '#212529' }}>Select date range</Typography>
|
<Typography sx={{ fontWeight: 700, fontSize: '0.9rem', color: '#212529' }}>Select date range</Typography>
|
||||||
@@ -454,13 +537,30 @@ export default function Dashboard() {
|
|||||||
<CardHeader title="Sorting Progress" subheader="How many parcels we've sorted today" />
|
<CardHeader title="Sorting Progress" subheader="How many parcels we've sorted today" />
|
||||||
<Divider />
|
<Divider />
|
||||||
<CardContent sx={{ pt: 3 }}>
|
<CardContent sx={{ pt: 3 }}>
|
||||||
<Box sx={{ mb: 3.5 }}>
|
{(() => {
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }} gap={1} flexWrap="wrap">
|
// Real sorting progress from the dashboard KPIs: parcels sorted today
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Today's target — 2,000 parcels</Typography>
|
// against the hub's configured capacity (sorting_target).
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 700, whiteSpace: 'nowrap' }}>74% done</Typography>
|
const sorted = Number(kpis?.parcels_sorted ?? 0);
|
||||||
</Stack>
|
const target = Number(kpis?.sorting_target ?? 0);
|
||||||
<LinearProgress variant="determinate" value={74.1} sx={{ height: 10, borderRadius: 2, bgcolor: 'grey.200' }} />
|
const pct = target > 0 ? Math.min(100, Math.round((sorted / target) * 100)) : 0;
|
||||||
</Box>
|
return (
|
||||||
|
<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 }}>
|
||||||
|
{target > 0
|
||||||
|
? `Today's target — ${target.toLocaleString('en-IN')} parcels`
|
||||||
|
: `Sorted today — ${sorted.toLocaleString('en-IN')} parcels`}
|
||||||
|
</Typography>
|
||||||
|
{target > 0 && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||||
|
{sorted.toLocaleString('en-IN')} done · {pct}%
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<LinearProgress variant="determinate" value={pct} sx={{ height: 10, borderRadius: 2, bgcolor: 'grey.200' }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 700 }}>Trucks Arriving</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 700 }}>Trucks Arriving</Typography>
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: 'auto' }}>
|
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: 'auto' }}>
|
||||||
@@ -640,6 +740,22 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={toast.open}
|
||||||
|
autoHideDuration={4000}
|
||||||
|
onClose={() => setToast((t) => ({ ...t, open: false }))}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity={toast.severity}
|
||||||
|
variant="filled"
|
||||||
|
onClose={() => setToast((t) => ({ ...t, open: false }))}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{toast.msg}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export default function Dispatch() {
|
|||||||
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
||||||
return (
|
return (
|
||||||
<Stack spacing={0.75}>
|
<Stack spacing={0.75}>
|
||||||
<Stack direction="row" alignItems="center" gap={0.75} flexWrap="wrap">
|
<Stack direction="row" alignItems="center" sx={{ flexWrap: 'wrap', gap: 0.75 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{m.origin}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{m.origin}</Typography>
|
||||||
<EastRoundedIcon sx={{ fontSize: 16, color: '#ADB5BD' }} />
|
<EastRoundedIcon sx={{ fontSize: 16, color: '#ADB5BD' }} />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: meta.color }}>{m.destination}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: meta.color }}>{m.destination}</Typography>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import QrCodeScannerOutlinedIcon from '@mui/icons-material/QrCodeScannerOutlined
|
|||||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||||
import EastOutlinedIcon from '@mui/icons-material/EastOutlined';
|
import EastOutlinedIcon from '@mui/icons-material/EastOutlined';
|
||||||
import BoltOutlinedIcon from '@mui/icons-material/BoltOutlined';
|
|
||||||
import MoveToInboxOutlinedIcon from '@mui/icons-material/MoveToInboxOutlined';
|
import MoveToInboxOutlinedIcon from '@mui/icons-material/MoveToInboxOutlined';
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined';
|
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined';
|
||||||
@@ -51,19 +50,22 @@ const timeAgo = (iso) => {
|
|||||||
// Map an API inbound record onto the shape this page renders.
|
// Map an API inbound record onto the shape this page renders.
|
||||||
// Real backend uses consignmentid / trackingno / chargeableweight / deliverypincode.
|
// Real backend uses consignmentid / trackingno / chargeableweight / deliverypincode.
|
||||||
const mapInbound = (row, hubName) => {
|
const mapInbound = (row, hubName) => {
|
||||||
|
// Backend GET /hub/inbound/today sends: originname, destinationname, updatedat.
|
||||||
|
// (chargeableweight is NOT sent by this endpoint yet — see the note to backend.)
|
||||||
const w = row.chargeableweight ?? row.deadweight ?? row.weight;
|
const w = row.chargeableweight ?? row.deadweight ?? row.weight;
|
||||||
return {
|
return {
|
||||||
bookingid: row.consignmentid ?? row.bookingid,
|
bookingid: row.consignmentid ?? row.bookingid,
|
||||||
trackingId: row.trackingno || row.trackingnumber || '—',
|
trackingId: row.trackingno || row.trackingnumber || '—',
|
||||||
sender: row.sendername || (row.senderid ? `Sender #${row.senderid}` : '—'),
|
sender: row.sendername || (row.senderid ? `Sender #${row.senderid}` : '—'),
|
||||||
origin: row.origin || (row.originhubid ? `Hub ${row.originhubid}` : '—'),
|
origin: row.originname || row.origin || (row.originhubid ? `Hub ${row.originhubid}` : '—'),
|
||||||
currentLoc: hubName,
|
currentLoc: hubName,
|
||||||
destination: row.destination || row.deliverypincode || (row.destinationhubid ? `Hub ${row.destinationhubid}` : '—'),
|
destination:
|
||||||
|
row.destinationname || row.destination || row.deliverypincode || (row.destinationhubid ? `Hub ${row.destinationhubid}` : '—'),
|
||||||
weight: typeof w === 'string' ? w : w != null ? `${w} kg` : '—',
|
weight: typeof w === 'string' ? w : w != null ? `${w} kg` : '—',
|
||||||
condition: row.condition || 'Good',
|
condition: row.condition || 'Good',
|
||||||
temp: row.temperature || 'N/A',
|
temp: row.temperature || 'N/A',
|
||||||
shelf: row.shelf || 'Zone A (Shelf 1)',
|
shelf: row.shelf || 'Zone A (Shelf 1)',
|
||||||
time: timeAgo(row.inboundedat || row.createdat)
|
time: timeAgo(row.updatedat || row.inboundedat || row.createdat)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,24 +125,6 @@ export default function Inbound() {
|
|||||||
return { received, exceptions, coldChain, pendingSort };
|
return { received, exceptions, coldChain, pendingSort };
|
||||||
}, [inboundLogs]);
|
}, [inboundLogs]);
|
||||||
|
|
||||||
const handleRandomScan = () => {
|
|
||||||
const randomNum = Math.floor(100000 + Math.random() * 900000);
|
|
||||||
setTrackingId(`DM-${randomNum}`);
|
|
||||||
setBookingId(String(Math.floor(1 + Math.random() * 200)));
|
|
||||||
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)]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Local preview of the shelf the backend is likely to recommend (it decides for real).
|
// Local preview of the shelf the backend is likely to recommend (it decides for real).
|
||||||
const recommendShelf = () => {
|
const recommendShelf = () => {
|
||||||
if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) return 'Exception Area';
|
if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) return 'Exception Area';
|
||||||
@@ -189,7 +173,16 @@ export default function Inbound() {
|
|||||||
setBookingId(''); setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp('');
|
setBookingId(''); setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp('');
|
||||||
loadInbound();
|
loadInbound();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setToast({ open: true, msg: err?.message || 'Could not scan this parcel in.', severity: 'error' });
|
// 404 = the booking ID doesn't exist. This screen receives an EXISTING
|
||||||
|
// booking into the hub; it does not create a new parcel. Make that clear.
|
||||||
|
const notFound = err?.status === 404 || /not found/i.test(err?.message || '');
|
||||||
|
setToast({
|
||||||
|
open: true,
|
||||||
|
msg: notFound
|
||||||
|
? `No booking found with ID "${bookingId.trim()}". This screen receives a booking that already exists — enter a Booking ID from the system (e.g. an unassigned pickup).`
|
||||||
|
: err?.message || 'Could not scan this parcel in.',
|
||||||
|
severity: notFound ? 'warning' : 'error'
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -200,7 +193,7 @@ export default function Inbound() {
|
|||||||
// Reusable journey block
|
// Reusable journey block
|
||||||
const Journey = ({ log }) => (
|
const Journey = ({ log }) => (
|
||||||
<Stack spacing={0.5}>
|
<Stack spacing={0.5}>
|
||||||
<Stack direction="row" alignItems="center" gap={0.75} flexWrap="wrap">
|
<Stack direction="row" alignItems="center" sx={{ flexWrap: 'wrap', gap: 0.75 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{log.origin}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{log.origin}</Typography>
|
||||||
<EastOutlinedIcon sx={{ fontSize: 14, color: '#ADB5BD' }} />
|
<EastOutlinedIcon sx={{ fontSize: 14, color: '#ADB5BD' }} />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#C01227' }}>{log.currentLoc}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#C01227' }}>{log.currentLoc}</Typography>
|
||||||
@@ -293,21 +286,13 @@ export default function Inbound() {
|
|||||||
<Stack spacing={2.5}>
|
<Stack spacing={2.5}>
|
||||||
<TextField fullWidth label="Booking ID" placeholder="e.g. 15" value={bookingId}
|
<TextField fullWidth label="Booking ID" placeholder="e.g. 15" value={bookingId}
|
||||||
onChange={(e) => setBookingId(e.target.value)} sx={fieldSx} required
|
onChange={(e) => setBookingId(e.target.value)} sx={fieldSx} required
|
||||||
helperText="Consignment / booking number for this parcel"
|
helperText="Must be an existing booking in the system (this receives it — it doesn't create a new one)"
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start"><Inventory2OutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
|
InputProps={{ startAdornment: <InputAdornment position="start"><Inventory2OutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
|
||||||
|
|
||||||
<TextField fullWidth label="Tracking ID" placeholder="e.g. DM-882204" value={trackingId}
|
<TextField fullWidth label="Tracking ID" placeholder="e.g. DM-882204" value={trackingId}
|
||||||
onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx}
|
onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: <InputAdornment position="start"><QrCodeScannerOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment>,
|
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}
|
<TextField select fullWidth label="Where it came from" value={origin}
|
||||||
|
|||||||
@@ -127,11 +127,11 @@ const moverIcon = (color) => new L.DivIcon({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
// Mock data — every rider has a DELIVERY trip and a PICKUP trip for the day, each
|
// Rider routes — loaded live from GET /hub/rider-routes (see the load effect below).
|
||||||
// with its own ordered stops, timings and distance. Every order stop carries the
|
// Each rider has a PICKUP trip for the day with its own ordered stops and distance;
|
||||||
// full detail shown in the side drawer when clicked.
|
// every order stop carries the detail shown in the side drawer when clicked.
|
||||||
//
|
//
|
||||||
// Delivery: hub → customer drops. Pickup: merchant collections → hub.
|
// Pickup: merchant collections → hub.
|
||||||
// ════════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
// The hub all pickup legs return to. Label comes from the logged-in hub context;
|
// The hub all pickup legs return to. Label comes from the logged-in hub context;
|
||||||
// the coordinate is only the initial map centre (the map auto-fits to the stops).
|
// the coordinate is only the initial map centre (the map auto-fits to the stops).
|
||||||
@@ -144,30 +144,41 @@ const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#0
|
|||||||
// page renders: a `pickup` trip with a list of order stops. Fields the API does
|
// page renders: a `pickup` trip with a list of order stops. Fields the API does
|
||||||
// not provide (customer, weight, COD, slot, instructions) default gracefully.
|
// not provide (customer, weight, COD, slot, instructions) default gracefully.
|
||||||
const mapRoute = (r, i) => {
|
const mapRoute = (r, i) => {
|
||||||
const apiStops = Array.isArray(r.stops) ? r.stops : [];
|
// Be tolerant of backend field-name variants: the stops list, coordinates, ids
|
||||||
const stops = apiStops.map((s) => ({
|
// and distance can arrive under a few different keys. `??` only falls back when
|
||||||
kind: 'order',
|
// the primary key is missing, so the documented shape still works unchanged.
|
||||||
orderId: s.bookingid != null ? `BK-${s.bookingid}` : `Stop ${s.seq}`,
|
const apiStops = [r.stops, r.route, r.pickups, r.orders, r.legs].find(Array.isArray) || [];
|
||||||
bookingid: s.bookingid,
|
const mileruserid = r.mileruserid ?? r.userid ?? r.miler_user_id ?? r.id;
|
||||||
customer: s.customer || '',
|
const stops = apiStops.map((s) => {
|
||||||
phone: s.phone || '',
|
const bookingid = s.bookingid ?? s.consignmentid ?? s.booking_id;
|
||||||
address: s.address || '—',
|
return {
|
||||||
lat: s.lat,
|
kind: 'order',
|
||||||
lng: s.lon,
|
orderId: bookingid != null ? `BK-${bookingid}` : `Stop ${s.seq ?? ''}`,
|
||||||
time: s.eta_minutes != null ? `${s.eta_minutes} min` : '',
|
bookingid,
|
||||||
status: s.status === 'completed' ? 'Picked' : s.status === 'in_progress' ? 'In progress' : 'Pending',
|
customer: s.customer || s.sendername || s.name || '',
|
||||||
items: s.items ?? 0,
|
phone: s.phone || '',
|
||||||
weight: '',
|
address: s.address || '—',
|
||||||
slot: '',
|
lat: s.lat ?? s.latitude,
|
||||||
cod: 0,
|
lng: s.lon ?? s.lng ?? s.longitude,
|
||||||
payment: '',
|
time: s.eta_minutes != null ? `${s.eta_minutes} min` : '',
|
||||||
legKm: 0,
|
status: s.status === 'completed' ? 'Picked' : s.status === 'in_progress' ? 'In progress' : 'Pending',
|
||||||
instructions: ''
|
// Optional fields: keep whatever the API sends; `null`/'' means "unknown" and
|
||||||
}));
|
// the UI hides it rather than showing a fake 0. No placeholder/mock values.
|
||||||
|
items: s.items ?? s.parcels ?? null,
|
||||||
|
weight: s.weight ?? '',
|
||||||
|
// Backend (buildMilerRoute) emits `timeslot` and `legdistance_km`; keep the
|
||||||
|
// other aliases as fallbacks in case the shape ever changes.
|
||||||
|
slot: s.timeslot ?? s.slot ?? s.time_slot ?? '',
|
||||||
|
cod: s.cod ?? s.cod_amount ?? 0,
|
||||||
|
payment: s.payment ?? s.payment_mode ?? '',
|
||||||
|
legKm: s.legdistance_km ?? s.legKm ?? s.leg_km ?? s.distance_km ?? null,
|
||||||
|
instructions: s.instructions ?? s.notes ?? ''
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
id: `RDR-${r.mileruserid}`,
|
id: `RDR-${mileruserid}`,
|
||||||
mileruserid: r.mileruserid,
|
mileruserid,
|
||||||
name: r.milername || `Miler ${r.mileruserid}`,
|
name: r.milername || r.displayname || r.name || `Miler ${mileruserid}`,
|
||||||
color: ROUTE_COLORS[i % ROUTE_COLORS.length],
|
color: ROUTE_COLORS[i % ROUTE_COLORS.length],
|
||||||
vehicle: '—',
|
vehicle: '—',
|
||||||
vehicleNo: '—',
|
vehicleNo: '—',
|
||||||
@@ -175,7 +186,7 @@ const mapRoute = (r, i) => {
|
|||||||
pickup: {
|
pickup: {
|
||||||
startTime: '',
|
startTime: '',
|
||||||
endTime: '',
|
endTime: '',
|
||||||
distanceKm: r.totaldistance_km || 0,
|
distanceKm: r.totaldistance_km ?? r.total_distance_km ?? r.distance_km ?? r.distance ?? 0,
|
||||||
stops
|
stops
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -262,7 +273,7 @@ function OrderDetailPanel({ data, onBack }) {
|
|||||||
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)' } }}>
|
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
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Stack direction="row" alignItems="center" spacing={0.75} mb={1} flexWrap="wrap" useFlexGap>
|
<Stack direction="row" alignItems="center" spacing={0.75} useFlexGap sx={{ flexWrap: 'wrap', mb: 1 }}>
|
||||||
<Chip size="small" icon={(isPickup ? <StorefrontOutlinedIcon /> : <DeliveryDiningRoundedIcon />)} label={isPickup ? 'Pickup' : 'Delivery'}
|
<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' } }} />
|
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}
|
<Chip size="small" icon={<StatusIcon sx={{ color: '#fff !important' }} />} label={meta.label || order.status}
|
||||||
@@ -296,9 +307,9 @@ function OrderDetailPanel({ data, onBack }) {
|
|||||||
<Grid container rowSpacing={2} columnSpacing={1.5}>
|
<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={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={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={Inventory2OutlinedIcon} label="Items" value={order.items != null ? `${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={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={StraightenRoundedIcon} label="Leg distance" value={order.legKm != null ? `${order.legKm} km` : ''} /></Grid>
|
||||||
<Grid item xs={6}><DetailRow icon={PaymentsOutlinedIcon} label="Payment" value={order.payment} /></Grid>
|
<Grid item xs={6}><DetailRow icon={PaymentsOutlinedIcon} label="Payment" value={order.payment} /></Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -323,7 +334,8 @@ function OrderDetailPanel({ data, onBack }) {
|
|||||||
<Stack spacing={1.75}>
|
<Stack spacing={1.75}>
|
||||||
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : WarehouseRoundedIcon} label="Route leg"
|
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : WarehouseRoundedIcon} label="Route leg"
|
||||||
value={isPickup ? `${order.customer} → ${HUB.label}` : `${HUB.label} → ${order.customer}`} />
|
value={isPickup ? `${order.customer} → ${HUB.label}` : `${HUB.label} → ${order.customer}`} />
|
||||||
<DetailRow icon={MyLocationOutlinedIcon} label="Coordinates" value={`${order.lat.toFixed(4)}, ${order.lng.toFixed(4)}`} />
|
<DetailRow icon={MyLocationOutlinedIcon} label="Coordinates"
|
||||||
|
value={order.lat != null && order.lng != null ? `${order.lat.toFixed(4)}, ${order.lng.toFixed(4)}` : ''} />
|
||||||
{order.instructions && <DetailRow icon={NotesOutlinedIcon} label="Instructions" value={order.instructions} valueColor="#5F6368" />}
|
{order.instructions && <DetailRow icon={NotesOutlinedIcon} label="Instructions" value={order.instructions} valueColor="#5F6368" />}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -376,6 +388,7 @@ export default function RiderRoutes() {
|
|||||||
const rafRef = useRef(null);
|
const rafRef = useRef(null);
|
||||||
const lastTsRef = useRef(0);
|
const lastTsRef = useRef(0);
|
||||||
const resolvedRef = useRef({}); // keys we've already fetched/attempted
|
const resolvedRef = useRef({}); // keys we've already fetched/attempted
|
||||||
|
const mapRef = useRef(null); // map column — scrolled into view when playback starts on mobile
|
||||||
|
|
||||||
const modeCfg = MODES[mode];
|
const modeCfg = MODES[mode];
|
||||||
|
|
||||||
@@ -474,6 +487,11 @@ export default function RiderRoutes() {
|
|||||||
if (playing === riderId) { setPlaying(null); return; }
|
if (playing === riderId) { setPlaying(null); return; }
|
||||||
if (playing !== riderId) setProgress(0);
|
if (playing !== riderId) setProgress(0);
|
||||||
setPlaying(riderId);
|
setPlaying(riderId);
|
||||||
|
// On the stacked layout (< lg) the map sits below the list, so the moving
|
||||||
|
// marker would animate off-screen. Bring the map into view when playback starts.
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1199px)').matches) {
|
||||||
|
requestAnimationFrame(() => mapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const resetAnim = () => { setPlaying(null); setProgress(0); };
|
const resetAnim = () => { setPlaying(null); setProgress(0); };
|
||||||
const toggleVisible = (id) => setVisible((v) => ({ ...v, [id]: !v[id] }));
|
const toggleVisible = (id) => setVisible((v) => ({ ...v, [id]: !v[id] }));
|
||||||
@@ -689,12 +707,14 @@ export default function RiderRoutes() {
|
|||||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ flex: 1, minWidth: 0 }}>{s.address}</Typography>
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ flex: 1, minWidth: 0 }}>{s.address}</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* metric chips */}
|
{/* metric chips — only render fields the API actually returned */}
|
||||||
<Stack direction="row" gap={0.75} flexWrap="wrap" sx={{ mt: 1.25 }}>
|
{(s.legKm != null || s.weight || s.items != null) && (
|
||||||
<Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.legKm} km`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
<Stack direction="row" sx={{ flexWrap: 'wrap', gap: 0.75, mt: 1.25 }}>
|
||||||
<Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '13px !important' }} />} label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
{s.legKm != null && <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={<Inventory2OutlinedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
{s.weight && <Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '13px !important' }} />} label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
|
||||||
</Stack>
|
{s.items != null && <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>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -709,7 +729,7 @@ export default function RiderRoutes() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Map ── */}
|
{/* ── Map ── */}
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box ref={mapRef} sx={{ flex: 1, minWidth: 0, scrollMarginTop: 72 }}>
|
||||||
<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)' }}>
|
<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 && (
|
{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)}` }}>
|
<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)}` }}>
|
||||||
@@ -819,6 +839,10 @@ export default function RiderRoutes() {
|
|||||||
open={Boolean(detail)}
|
open={Boolean(detail)}
|
||||||
onClose={closeDetail}
|
onClose={closeDetail}
|
||||||
sx={{
|
sx={{
|
||||||
|
// Temporary drawers render at zIndex.drawer (1200) here, below the app bar
|
||||||
|
// (drawer + 1). On mobile the sheet starts at top:0, so its Close button
|
||||||
|
// would hide under the app bar. Lift the whole modal above it.
|
||||||
|
zIndex: (t) => t.zIndex.modal,
|
||||||
'& .MuiDrawer-paper': {
|
'& .MuiDrawer-paper': {
|
||||||
width: { xs: '100%', sm: 360 },
|
width: { xs: '100%', sm: 360 },
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ const mapMiler = (m, hubName) => {
|
|||||||
hub: hubName,
|
hub: hubName,
|
||||||
zones: Array.isArray(m.zones) ? m.zones : m.currentpincode ? [m.currentpincode] : [],
|
zones: Array.isArray(m.zones) ? m.zones : m.currentpincode ? [m.currentpincode] : [],
|
||||||
vehicle,
|
vehicle,
|
||||||
vehicleNo: m.vehicleid ? `VEH-${m.vehicleid}` : '—',
|
// Real registration plate from the joined vehicles table; fall back to a
|
||||||
|
// placeholder tag if the miler has no vehicle assigned.
|
||||||
|
vehicleNo: m.vehicleno || (m.vehicleid ? `VEH-${m.vehicleid}` : '—'),
|
||||||
status: API_TO_UI_STATUS[m.availabilitystatus] || 'Idle',
|
status: API_TO_UI_STATUS[m.availabilitystatus] || 'Idle',
|
||||||
checkInTime: checkInLabel(m.checkinat),
|
checkInTime: checkInLabel(m.checkinat),
|
||||||
hoursToday: m.hoursactive ?? 0,
|
hoursToday: m.hoursactive ?? 0,
|
||||||
@@ -520,6 +522,10 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
|
|||||||
open={!!rider}
|
open={!!rider}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
sx={{
|
sx={{
|
||||||
|
// This build renders temporary drawers at zIndex.drawer (1200), which sits
|
||||||
|
// BELOW the app bar (drawer + 1). On mobile the sheet starts at top:0, so its
|
||||||
|
// Close button would hide under the app bar. Lift the whole modal above it.
|
||||||
|
zIndex: (t) => t.zIndex.modal,
|
||||||
'& .MuiDrawer-paper': {
|
'& .MuiDrawer-paper': {
|
||||||
width: { xs: '100%', sm: 380 },
|
width: { xs: '100%', sm: 380 },
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -541,7 +547,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, lineHeight: 1.25,fontSize: '1.5rem' }}>{form.name || rider.name}</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 800, lineHeight: 1.25,fontSize: '1.5rem' }}>{form.name || rider.name}</Typography>
|
||||||
<Typography variant="body2" sx={{ opacity: 0.9, fontFamily: 'monospace', mb: 0.25 }}>{rider.id}</Typography>
|
<Typography variant="body2" sx={{ opacity: 0.9, fontFamily: 'monospace', mb: 0.25 }}>{rider.id}</Typography>
|
||||||
<Stack direction="row" alignItems="center" spacing={0.75} mt={2} flexWrap="wrap" useFlexGap>
|
<Stack direction="row" alignItems="center" spacing={0.75} useFlexGap sx={{ flexWrap: 'wrap', mt: 2 }}>
|
||||||
<Chip size="small" label={form.status || rider.status}
|
<Chip size="small" label={form.status || rider.status}
|
||||||
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
|
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
|
||||||
{rider.verified && (
|
{rider.verified && (
|
||||||
@@ -883,7 +889,7 @@ function RiderCard({ rider, onView, onMenu }) {
|
|||||||
direction="row"
|
direction="row"
|
||||||
spacing={1}
|
spacing={1}
|
||||||
useFlexGap
|
useFlexGap
|
||||||
flexWrap="wrap"
|
sx={{ flexWrap: 'wrap' }}
|
||||||
>
|
>
|
||||||
{rider.zones.slice(0, 3).map((z) => (
|
{rider.zones.slice(0, 3).map((z) => (
|
||||||
<Chip
|
<Chip
|
||||||
@@ -1261,7 +1267,7 @@ export default function Riders() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Status Chips */}
|
{/* Status Chips */}
|
||||||
<Stack direction="row" gap={1.25} flexWrap="wrap" rowGap={1.5} mt={4} spacing={1}>
|
<Stack direction="row" sx={{ flexWrap: 'wrap', columnGap: 1.25, rowGap: 1.5, mt: 4 }}>
|
||||||
{STATUS_CHIPS.map(s => {
|
{STATUS_CHIPS.map(s => {
|
||||||
const active = statusFilter === s;
|
const active = statusFilter === s;
|
||||||
const meta = STATUS_META[s] || {};
|
const meta = STATUS_META[s] || {};
|
||||||
@@ -1339,7 +1345,7 @@ export default function Riders() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell><StatusPill status={rider.status} /></TableCell>
|
<TableCell><StatusPill status={rider.status} /></TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Stack direction="row" gap={0.5} flexWrap="wrap">
|
<Stack direction="row" sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{rider.zones.map(z => <Chip key={z} label={z} size="small" variant="outlined" />)}
|
{rider.zones.map(z => <Chip key={z} label={z} size="small" variant="outlined" />)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function Routing() {
|
|||||||
.then((res) =>
|
.then((res) =>
|
||||||
setWaiting(
|
setWaiting(
|
||||||
(res?.data || [])
|
(res?.data || [])
|
||||||
.map((r) => ({ trackingno: r.trackingno || r.trackingnumber, dest: r.destinationname || r.deliverypincode || '—' }))
|
.map((r) => ({ trackingno: r.trackingno || r.trackingnumber, dest: r.destination || r.destinationname || r.deliverypincode || '—' }))
|
||||||
.filter((r) => r.trackingno)
|
.filter((r) => r.trackingno)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ export default function TrackingMap() {
|
|||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
userid: m.userid,
|
userid: m.userid,
|
||||||
displayname: m.displayname,
|
displayname: m.displayname,
|
||||||
lat: m.currentlatitude ?? m.lat,
|
lat: m.currentlat ?? m.currentlatitude ?? m.lat,
|
||||||
lon: m.currentlongitude ?? m.lon,
|
lon: m.currentlon ?? m.currentlongitude ?? m.lon,
|
||||||
status: m.availabilitystatus ?? m.status,
|
status: m.availabilitystatus ?? m.status,
|
||||||
bookingid: m.currentbookingid ?? m.bookingid
|
bookingid: m.currentbookingid ?? m.bookingid
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user