diff --git a/package-lock.json b/package-lock.json index af33834..7eb4ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-leaflet": "^4.2.1", - "react-router-dom": "^6.30.4" + "react-router-dom": "^6.30.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^9.8.0", @@ -1891,6 +1892,15 @@ "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": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2255,6 +2265,19 @@ ], "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": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2281,6 +2304,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2330,6 +2362,18 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3111,6 +3155,15 @@ "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": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4943,6 +4996,18 @@ "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": { "version": "1.1.0", "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" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5426,6 +5509,27 @@ "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 7a70a8b..b187278 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-leaflet": "^4.2.1", - "react-router-dom": "^6.30.4" + "react-router-dom": "^6.30.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^9.8.0", diff --git a/src/api/client.js b/src/api/client.js index bae3a03..8cb763a 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -8,6 +8,7 @@ // ───────────────────────────────────────────────────────────────────────────── import { getToken, clearSession } from '@/auth/session'; +import { log } from '@/lib/logger'; // 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). @@ -61,12 +62,14 @@ export async function request(path, { method = 'GET', body, auth = true, interna let res; try { 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); } // 401 → session is dead. Clear and send to login before anyone reads the body. if (res.status === 401 && !internal) { + log.warn(`${method} ${path} — 401, session expired; redirecting to login`); redirectToLogin(); 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 ? 'Something went wrong on our side. Please try again.' : `Request failed (${res.status}).`); + log.error(`${method} ${path} — ${res.status}`, message, data ?? ''); throw new ApiError(message, res.status, data); } + log.debug(`${method} ${path} — ${res.status}`); return data; } diff --git a/src/api/hub.js b/src/api/hub.js index 7e265fb..6eb3011 100644 --- a/src/api/hub.js +++ b/src/api/hub.js @@ -20,6 +20,15 @@ export function getDashboard() { 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 ───────────────────────────────────────────── export function getInboundToday() { return http.get(`${V1}/hub/inbound/today`); @@ -145,3 +154,27 @@ export function getNotifications() { export function markNotificationRead(id) { 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`, {}); +} diff --git a/src/layout/MainLayout/Header.jsx b/src/layout/MainLayout/Header.jsx index 38e24f8..8c41744 100644 --- a/src/layout/MainLayout/Header.jsx +++ b/src/layout/MainLayout/Header.jsx @@ -40,7 +40,14 @@ import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; import Logo from '@/components/Logo'; 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'; @@ -63,42 +70,27 @@ const toInitials = (name) => .join('') .toUpperCase() || 'HB'; -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 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' } - ] - } +// Normalise a conversation summary from GET /hub/messages. Be tolerant of a few +// backend field-name variants so the list still renders if a key is named differently. +const toConversation = (c) => { + const name = c.name || c.milername || c.displayname || `Miler ${c.mileruserid ?? c.id}`; + return { + id: c.id ?? c.conversationid ?? c.mileruserid, + name, + initials: toInitials(name), + lastMessage: c.lastmessage ?? c.last_message ?? c.preview ?? '', + time: c.time ?? c.lasttime ?? c.updatedat ?? '', + unread: Number(c.unread ?? c.unreadcount ?? 0) || 0 + }; }; +// 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 }) { const navigate = useNavigate(); const staff = getStaff(); @@ -117,10 +109,14 @@ export default function Header({ onToggle }) { // Dialog State const [selectedNotif, setSelectedNotif] = useState(null); - const [selectedChatId, setSelectedChatId] = useState(null); - const [chats, setChats] = useState(INITIAL_CHATS); + const [conversations, setConversations] = useState([]); + const [activeChat, setActiveChat] = useState(null); // { id, name, initials, messages: [] } + const [chatLoading, setChatLoading] = useState(false); + const [sending, setSending] = useState(false); const [typedMessage, setTypedMessage] = useState(''); + const unreadMessages = conversations.reduce((sum, c) => sum + c.unread, 0); + const [notifications, setNotifications] = useState([]); const [search, setSearch] = useState(''); @@ -152,9 +148,28 @@ export default function Header({ onToggle }) { return () => clearInterval(t); }, [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 closeNotif = () => setNotifAnchor(null); + const openMessages = (e) => { setMsgAnchor(e.currentTarget); loadConversations(); }; + const markAllRead = async () => { const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id); 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); - 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 = () => { - if (!typedMessage.trim() || !selectedChatId) return; + const closeChat = () => { setActiveChat(null); setTypedMessage(''); }; + + 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 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 } - ] - } - }; - }); + setActiveChat((c) => ({ ...c, messages: [...c.messages, { sender: 'me', text, time: timeStr }] })); 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) => { @@ -258,8 +304,8 @@ export default function Header({ onToggle }) { - setMsgAnchor(e.currentTarget)}> - + + @@ -354,8 +400,13 @@ export default function Header({ onToggle }) { Messages - {MESSAGES.map((m) => ( - onMessageClick(m.id)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}> + {conversations.length === 0 && ( + + + + )} + {conversations.map((m) => ( + onMessageClick(m)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}> {m.initials} @@ -363,13 +414,18 @@ export default function Header({ onToggle }) { 0 ? 700 : 600 }} + secondaryTypographyProps={{ fontSize: '0.8rem', noWrap: true }} /> - - {m.time} - + + + {m.time} + + {m.unread > 0 && ( + + )} + ))} @@ -439,21 +495,31 @@ export default function Header({ onToggle }) { {/* High Fidelity Chat Message Dialog */} - setSelectedChatId(null)} fullWidth maxWidth="xs"> - {selectedChatId && chats[selectedChatId] && ( + + {activeChat && ( <> - {chats[selectedChatId].initials} + {activeChat.initials} - {chats[selectedChatId].name} + {activeChat.name} - {chats[selectedChatId].chat.map((msg, idx) => ( + {chatLoading && activeChat.messages.length === 0 && ( + + Loading… + + )} + {!chatLoading && activeChat.messages.length === 0 && ( + + No messages yet. Say hello. + + )} + {activeChat.messages.map((msg, idx) => ( - + diff --git a/src/lib/hubReport.js b/src/lib/hubReport.js new file mode 100644 index 0000000..029c79e --- /dev/null +++ b/src/lib/hubReport.js @@ -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()); +} diff --git a/src/lib/logger.js b/src/lib/logger.js new file mode 100644 index 0000000..bf9a001 --- /dev/null +++ b/src/lib/logger.js @@ -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; diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 7e19583..5c4a79a 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -22,7 +22,10 @@ import { Divider, Popover, Alert, - Skeleton + Skeleton, + useMediaQuery, + Snackbar, + CircularProgress } from '@mui/material'; import dayjs from 'dayjs'; 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 InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined'; +import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'; import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded'; import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; 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'; // 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 RANGE_KEY = 'hub_dashboard_range'; + 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 }); + // 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 isMobile = useMediaQuery('(max-width:600px)'); + + useEffect(() => { + localStorage.setItem(RANGE_KEY, JSON.stringify(range)); + }, [range]); const hub = getHubContext(); const [kpis, setKpis] = useState(null); @@ -191,6 +211,8 @@ export default function Dashboard() { const [incomingVehicles, setIncomingVehicles] = useState([]); const [recentActivity, setRecentActivity] = useState([]); const [activeRoutes, setActiveRoutes] = useState([]); + const [exporting, setExporting] = useState(false); + const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' }); const loadDashboard = useCallback(async () => { setLoading(true); @@ -235,6 +257,36 @@ export default function Dashboard() { 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) => ({ ...d, value: kpis && kpis[d.key] != null ? Number(kpis[d.key]).toLocaleString('en-IN') : '—' @@ -282,9 +334,10 @@ export default function Dashboard() { direction="row" spacing={1} alignItems="center" - flexWrap="wrap" 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 */} @@ -357,6 +412,31 @@ export default function Dashboard() { {dayjs(range.to).format('DD MMM YYYY')} + + {/* Export report (Excel) for the selected date range */} + @@ -365,9 +445,12 @@ export default function Dashboard() { open={Boolean(calAnchor)} anchorEl={calAnchor} onClose={() => setCalAnchor(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }} - PaperProps={{ sx: { mt: 1, borderRadius: 2.5, border: '1px solid #EEF0F2', boxShadow: '0 8px 28px rgba(0,0,0,0.10)', overflow: 'hidden' } }} + // On mobile the trigger sits on the left, so a right-aligned popover ran off + // the screen edge. Left-align on mobile and keep MUI clamping it into view. + 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)' } }} > Select date range @@ -454,13 +537,30 @@ export default function Dashboard() { - - - Today's target — 2,000 parcels - 74% done - - - + {(() => { + // Real sorting progress from the dashboard KPIs: parcels sorted today + // against the hub's configured capacity (sorting_target). + const sorted = Number(kpis?.parcels_sorted ?? 0); + const target = Number(kpis?.sorting_target ?? 0); + const pct = target > 0 ? Math.min(100, Math.round((sorted / target) * 100)) : 0; + return ( + + + + {target > 0 + ? `Today's target — ${target.toLocaleString('en-IN')} parcels` + : `Sorted today — ${sorted.toLocaleString('en-IN')} parcels`} + + {target > 0 && ( + + {sorted.toLocaleString('en-IN')} done · {pct}% + + )} + + + + ); + })()} Trucks Arriving @@ -640,6 +740,22 @@ export default function Dashboard() { + + setToast((t) => ({ ...t, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setToast((t) => ({ ...t, open: false }))} + sx={{ borderRadius: 2 }} + > + {toast.msg} + + ); } diff --git a/src/pages/operations/Dispatch.jsx b/src/pages/operations/Dispatch.jsx index 0a0b7a9..f118923 100644 --- a/src/pages/operations/Dispatch.jsx +++ b/src/pages/operations/Dispatch.jsx @@ -201,7 +201,7 @@ export default function Dispatch() { const meta = STATUS_META[m.status] || STATUS_META.Preparing; return ( - + {m.origin} {m.destination} diff --git a/src/pages/operations/Inbound.jsx b/src/pages/operations/Inbound.jsx index dfaa459..ee9f2e4 100644 --- a/src/pages/operations/Inbound.jsx +++ b/src/pages/operations/Inbound.jsx @@ -10,7 +10,6 @@ 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'; @@ -51,19 +50,22 @@ const timeAgo = (iso) => { // Map an API inbound record onto the shape this page renders. // Real backend uses consignmentid / trackingno / chargeableweight / deliverypincode. 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; return { bookingid: row.consignmentid ?? row.bookingid, trackingId: row.trackingno || row.trackingnumber || '—', 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, - 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` : '—', condition: row.condition || 'Good', temp: row.temperature || 'N/A', 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 }; }, [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). const recommendShelf = () => { 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(''); loadInbound(); } 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 { setSubmitting(false); } @@ -200,7 +193,7 @@ export default function Inbound() { // Reusable journey block const Journey = ({ log }) => ( - + {log.origin} {log.currentLoc} @@ -293,21 +286,13 @@ export default function Inbound() { 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: }} /> setTrackingId(e.target.value)} sx={fieldSx} InputProps={{ - startAdornment: , - endAdornment: ( - - - - ) + startAdornment: }} /> new L.DivIcon({ }); // ════════════════════════════════════════════════════════════════════════════════ -// 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. +// Rider routes — loaded live from GET /hub/rider-routes (see the load effect below). +// Each rider has a PICKUP trip for the day with its own ordered stops and distance; +// 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 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 // not provide (customer, weight, COD, slot, instructions) default gracefully. const mapRoute = (r, i) => { - const apiStops = Array.isArray(r.stops) ? r.stops : []; - const stops = apiStops.map((s) => ({ - kind: 'order', - orderId: s.bookingid != null ? `BK-${s.bookingid}` : `Stop ${s.seq}`, - bookingid: s.bookingid, - customer: s.customer || '', - phone: s.phone || '', - address: s.address || '—', - lat: s.lat, - lng: s.lon, - time: s.eta_minutes != null ? `${s.eta_minutes} min` : '', - status: s.status === 'completed' ? 'Picked' : s.status === 'in_progress' ? 'In progress' : 'Pending', - items: s.items ?? 0, - weight: '', - slot: '', - cod: 0, - payment: '', - legKm: 0, - instructions: '' - })); + // Be tolerant of backend field-name variants: the stops list, coordinates, ids + // and distance can arrive under a few different keys. `??` only falls back when + // the primary key is missing, so the documented shape still works unchanged. + const apiStops = [r.stops, r.route, r.pickups, r.orders, r.legs].find(Array.isArray) || []; + const mileruserid = r.mileruserid ?? r.userid ?? r.miler_user_id ?? r.id; + const stops = apiStops.map((s) => { + const bookingid = s.bookingid ?? s.consignmentid ?? s.booking_id; + return { + kind: 'order', + orderId: bookingid != null ? `BK-${bookingid}` : `Stop ${s.seq ?? ''}`, + bookingid, + customer: s.customer || s.sendername || s.name || '', + phone: s.phone || '', + address: s.address || '—', + lat: s.lat ?? s.latitude, + lng: s.lon ?? s.lng ?? s.longitude, + time: s.eta_minutes != null ? `${s.eta_minutes} min` : '', + status: s.status === 'completed' ? 'Picked' : s.status === 'in_progress' ? 'In progress' : 'Pending', + // 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 { - id: `RDR-${r.mileruserid}`, - mileruserid: r.mileruserid, - name: r.milername || `Miler ${r.mileruserid}`, + id: `RDR-${mileruserid}`, + mileruserid, + name: r.milername || r.displayname || r.name || `Miler ${mileruserid}`, color: ROUTE_COLORS[i % ROUTE_COLORS.length], vehicle: '—', vehicleNo: '—', @@ -175,7 +186,7 @@ const mapRoute = (r, i) => { pickup: { startTime: '', endTime: '', - distanceKm: r.totaldistance_km || 0, + distanceKm: r.totaldistance_km ?? r.total_distance_km ?? r.distance_km ?? r.distance ?? 0, 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)' } }}> Close - + : )} label={isPickup ? 'Pickup' : 'Delivery'} sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700, '& .MuiChip-icon': { color: '#fff' } }} /> } label={meta.label || order.status} @@ -296,9 +307,9 @@ function OrderDetailPanel({ data, onBack }) { - + - + @@ -323,7 +334,8 @@ function OrderDetailPanel({ data, onBack }) { - + {order.instructions && } @@ -376,6 +388,7 @@ export default function RiderRoutes() { const rafRef = useRef(null); const lastTsRef = useRef(0); 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]; @@ -474,6 +487,11 @@ export default function RiderRoutes() { if (playing === riderId) { setPlaying(null); return; } if (playing !== riderId) setProgress(0); 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 toggleVisible = (id) => setVisible((v) => ({ ...v, [id]: !v[id] })); @@ -689,12 +707,14 @@ export default function RiderRoutes() { {s.address} - {/* metric chips */} - - } label={`${s.legKm} km`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} /> - } label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} /> - } label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} /> - + {/* metric chips — only render fields the API actually returned */} + {(s.legKm != null || s.weight || s.items != null) && ( + + {s.legKm != null && } label={`${s.legKm} km`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />} + {s.weight && } label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />} + {s.items != null && } label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />} + + )} ); })} @@ -709,7 +729,7 @@ export default function RiderRoutes() { {/* ── Map ── */} - + {playState && ( @@ -819,6 +839,10 @@ export default function RiderRoutes() { open={Boolean(detail)} onClose={closeDetail} 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': { width: { xs: '100%', sm: 360 }, maxWidth: '100%', diff --git a/src/pages/operations/Riders.jsx b/src/pages/operations/Riders.jsx index 2b8a296..5d6f7f8 100644 --- a/src/pages/operations/Riders.jsx +++ b/src/pages/operations/Riders.jsx @@ -115,7 +115,9 @@ const mapMiler = (m, hubName) => { hub: hubName, zones: Array.isArray(m.zones) ? m.zones : m.currentpincode ? [m.currentpincode] : [], 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', checkInTime: checkInLabel(m.checkinat), hoursToday: m.hoursactive ?? 0, @@ -520,6 +522,10 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) { open={!!rider} onClose={onClose} 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': { width: { xs: '100%', sm: 380 }, maxWidth: '100%', @@ -541,7 +547,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) { {form.name || rider.name} {rider.id} - + {rider.verified && ( @@ -883,7 +889,7 @@ function RiderCard({ rider, onView, onMenu }) { direction="row" spacing={1} useFlexGap - flexWrap="wrap" + sx={{ flexWrap: 'wrap' }} > {rider.zones.slice(0, 3).map((z) => ( {/* Status Chips */} - + {STATUS_CHIPS.map(s => { const active = statusFilter === s; const meta = STATUS_META[s] || {}; @@ -1339,7 +1345,7 @@ export default function Riders() { - + {rider.zones.map(z => )} diff --git a/src/pages/operations/Routing.jsx b/src/pages/operations/Routing.jsx index 544454b..99fc10e 100644 --- a/src/pages/operations/Routing.jsx +++ b/src/pages/operations/Routing.jsx @@ -37,7 +37,7 @@ export default function Routing() { .then((res) => setWaiting( (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) ) ) diff --git a/src/pages/operations/TrackingMap.jsx b/src/pages/operations/TrackingMap.jsx index 006d1e2..ccca772 100644 --- a/src/pages/operations/TrackingMap.jsx +++ b/src/pages/operations/TrackingMap.jsx @@ -113,8 +113,8 @@ export default function TrackingMap() { .map((m) => ({ userid: m.userid, displayname: m.displayname, - lat: m.currentlatitude ?? m.lat, - lon: m.currentlongitude ?? m.lon, + lat: m.currentlat ?? m.currentlatitude ?? m.lat, + lon: m.currentlon ?? m.currentlongitude ?? m.lon, status: m.availabilitystatus ?? m.status, bookingid: m.currentbookingid ?? m.bookingid }))