wired the api for rest mock data

This commit is contained in:
2026-07-07 17:35:00 +05:30
parent e6ad37b20f
commit 903c7ea74c
14 changed files with 672 additions and 173 deletions

106
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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`, {});
}

View File

@@ -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 }) {
</Box>
<Tooltip title="Messages">
<IconButton color="inherit" onClick={(e) => setMsgAnchor(e.currentTarget)}>
<Badge badgeContent={MESSAGES.length} color="error">
<IconButton color="inherit" onClick={openMessages}>
<Badge badgeContent={unreadMessages} color="error">
<ChatIcon />
</Badge>
</IconButton>
@@ -354,8 +400,13 @@ export default function Header({ onToggle }) {
Messages
</Typography>
<Divider />
{MESSAGES.map((m) => (
<MenuItem key={m.id} onClick={() => onMessageClick(m.id)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
{conversations.length === 0 && (
<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 }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
{m.initials}
@@ -363,13 +414,18 @@ export default function Header({ onToggle }) {
</ListItemIcon>
<ListItemText
primary={m.name}
secondary={m.text}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 700 }}
secondaryTypographyProps={{ fontSize: '0.8rem' }}
secondary={m.lastMessage || 'No messages yet'}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: m.unread > 0 ? 700 : 600 }}
secondaryTypographyProps={{ fontSize: '0.8rem', noWrap: true }}
/>
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, mt: 0.5, flexShrink: 0 }}>
{m.time}
</Typography>
<Stack alignItems="flex-end" sx={{ ml: 1, flexShrink: 0 }}>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
{m.time}
</Typography>
{m.unread > 0 && (
<Badge badgeContent={m.unread} color="error" sx={{ mt: 1, mr: 0.75 }} />
)}
</Stack>
</MenuItem>
))}
</Menu>
@@ -439,21 +495,31 @@ export default function Header({ onToggle }) {
</Dialog>
{/* High Fidelity Chat Message Dialog */}
<Dialog open={Boolean(selectedChatId)} onClose={() => setSelectedChatId(null)} fullWidth maxWidth="xs">
{selectedChatId && chats[selectedChatId] && (
<Dialog open={Boolean(activeChat)} onClose={closeChat} fullWidth maxWidth="xs">
{activeChat && (
<>
<DialogTitle sx={{ fontWeight: 700, py: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Avatar sx={{ bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, width: 34, height: 34 }}>
{chats[selectedChatId].initials}
{activeChat.initials}
</Avatar>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{chats[selectedChatId].name}
{activeChat.name}
</Typography>
</DialogTitle>
<Divider />
<DialogContent sx={{ p: 2, bgcolor: 'grey.50', height: 280, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<Stack spacing={2} sx={{ overflowY: 'auto', pr: 0.5, flexGrow: 1, mb: 2 }}>
{chats[selectedChatId].chat.map((msg, idx) => (
{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
key={idx}
sx={{
@@ -503,7 +569,7 @@ export default function Header({ onToggle }) {
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={handleSendMessage} size="small" color="primary">
<IconButton onClick={handleSendMessage} size="small" color="primary" disabled={sending || !typedMessage.trim()}>
<SendIcon fontSize="small" />
</IconButton>
</InputAdornment>

115
src/lib/hubReport.js Normal file
View 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
View 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;

View File

@@ -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 */}
<IconButton
@@ -347,7 +400,9 @@ export default function Dashboard() {
borderColor: invalidRange ? '#EF4444' : '#E2E8F0',
bgcolor: '#ffffff',
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) }
}}
>
@@ -357,6 +412,31 @@ export default function Dashboard() {
<Box component="span">{dayjs(range.to).format('DD MMM YYYY')}</Box>
</Stack>
</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>
@@ -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)' } }}
>
<Box sx={{ px: 2, pt: 1.75, pb: 0.5 }}>
<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" />
<Divider />
<CardContent sx={{ pt: 3 }}>
<Box sx={{ mb: 3.5 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }} gap={1} flexWrap="wrap">
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Today's target — 2,000 parcels</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 700, whiteSpace: 'nowrap' }}>74% done</Typography>
</Stack>
<LinearProgress variant="determinate" value={74.1} sx={{ height: 10, borderRadius: 2, bgcolor: 'grey.200' }} />
</Box>
{(() => {
// 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 (
<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>
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: 'auto' }}>
@@ -640,6 +740,22 @@ export default function Dashboard() {
</Card>
</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>
);
}

View File

@@ -201,7 +201,7 @@ export default function Dispatch() {
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
return (
<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>
<EastRoundedIcon sx={{ fontSize: 16, color: '#ADB5BD' }} />
<Typography variant="body2" sx={{ fontWeight: 700, color: meta.color }}>{m.destination}</Typography>

View File

@@ -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 }) => (
<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>
<EastOutlinedIcon sx={{ fontSize: 14, color: '#ADB5BD' }} />
<Typography variant="body2" sx={{ fontWeight: 700, color: '#C01227' }}>{log.currentLoc}</Typography>
@@ -293,21 +286,13 @@ export default function Inbound() {
<Stack spacing={2.5}>
<TextField fullWidth label="Booking ID" placeholder="e.g. 15" value={bookingId}
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> }} />
<TextField fullWidth label="Tracking ID" placeholder="e.g. DM-882204" value={trackingId}
onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx}
InputProps={{
startAdornment: <InputAdornment position="start"><QrCodeScannerOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment>,
endAdornment: (
<InputAdornment position="end">
<Button size="small" variant="contained" startIcon={<BoltOutlinedIcon sx={{ fontSize: '16px !important' }} />} onClick={handleRandomScan}
sx={{ mr: -0.75, borderRadius: 2, bgcolor: '#1A1A2E', boxShadow: 'none', whiteSpace: 'nowrap', '&:hover': { bgcolor: '#000' } }}>
Auto
</Button>
</InputAdornment>
)
startAdornment: <InputAdornment position="start"><QrCodeScannerOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment>
}} />
<TextField select fullWidth label="Where it came from" value={origin}

View File

@@ -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
// 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
</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'}
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}
@@ -296,9 +307,9 @@ function OrderDetailPanel({ data, onBack }) {
<Grid container rowSpacing={2} columnSpacing={1.5}>
<Grid item xs={6}><DetailRow icon={AccessTimeOutlinedIcon} label="Picked at" value={order.time} /></Grid>
<Grid item xs={6}><DetailRow icon={ScheduleOutlinedIcon} label="Time slot" value={order.slot} /></Grid>
<Grid item xs={6}><DetailRow icon={Inventory2OutlinedIcon} label="Items" value={`${order.items} ${order.items === 1 ? 'parcel' : 'parcels'}`} /></Grid>
<Grid item xs={6}><DetailRow icon={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={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>
</Paper>
@@ -323,7 +334,8 @@ function OrderDetailPanel({ data, onBack }) {
<Stack spacing={1.75}>
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : WarehouseRoundedIcon} label="Route leg"
value={isPickup ? `${order.customer}${HUB.label}` : `${HUB.label}${order.customer}`} />
<DetailRow icon={MyLocationOutlinedIcon} label="Coordinates" value={`${order.lat.toFixed(4)}, ${order.lng.toFixed(4)}`} />
<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" />}
</Stack>
</Paper>
@@ -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() {
<Typography variant="caption" color="text.secondary" noWrap sx={{ flex: 1, minWidth: 0 }}>{s.address}</Typography>
</Stack>
{/* metric chips */}
<Stack direction="row" gap={0.75} flexWrap="wrap" sx={{ mt: 1.25 }}>
<Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.legKm} km`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />
<Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '13px !important' }} />} label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />
</Stack>
{/* metric chips — only render fields the API actually returned */}
{(s.legKm != null || s.weight || s.items != null) && (
<Stack direction="row" sx={{ flexWrap: 'wrap', gap: 0.75, mt: 1.25 }}>
{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' }} />}
{s.weight && <Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '13px !important' }} />} label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
{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>
);
})}
@@ -709,7 +729,7 @@ export default function RiderRoutes() {
</Box>
{/* ── 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)' }}>
{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)}` }}>
@@ -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%',

View File

@@ -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 }) {
</Button>
<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>
<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}
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
{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) => (
<Chip
@@ -1261,7 +1267,7 @@ export default function Riders() {
</Stack>
{/* 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 => {
const active = statusFilter === s;
const meta = STATUS_META[s] || {};
@@ -1339,7 +1345,7 @@ export default function Riders() {
</TableCell>
<TableCell><StatusPill status={rider.status} /></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" />)}
</Stack>
</TableCell>

View File

@@ -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)
)
)

View File

@@ -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
}))