updates on the astryx desing

This commit is contained in:
2026-08-14 16:09:14 +05:30
parent d3dd687a8d
commit a46212f291
106 changed files with 10244 additions and 24738 deletions

View File

@@ -1,4 +1,5 @@
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
MdTwoWheeler,
MdLocationOn,
@@ -7,11 +8,73 @@ import {
MdAccessTime,
MdInventory2
} from 'react-icons/md';
import { List, ListItem } from '@astryxdesign/core/List';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
import { getStatusStyle, getActiveOrder } from './dispatchShared';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { AccentAvatar } from 'themes/dt/primitives';
import { getActiveOrder } from './dispatchShared';
import { OpenToast } from 'components/third-party/OpenToast';
// ==============================|| ACTIVE DELIVERIES (sidebar) ||============================== //
// Rows, not cards. Astryx's design rule is that dense operator data reads as
// edge-to-edge rows — the old `.adcard` bordered cards cost ~16px of chrome per
// delivery, which is a lot of scrolling once a slot has 20+ riders out.
//
// Status comes from <StatusBadge> (themes/dt/status.js), which is now the only
// status-colour table in this folder — dispatchShared's rival copy has been
// deleted and its getStatusStyle reduced to an adapter over the same source.
// Small labelled metric — icon + value, used for the distance/ETA pair.
// The old markup carried a native `title` for the label ("Distance to drop");
// Astryx's BaseProps deliberately omits `title`, so the affordance moves to the
// real <Tooltip> rather than silently disappearing onto a dropped attribute.
const Metric = ({ icon, children, label }) => (
<Tooltip content={label}>
<HStack gap={0.5} vAlign="center">
<Center isInline width={14} height={14} style={{ color: 'var(--color-icon-secondary)' }}>
{icon}
</Center>
<Text size="sm" type="supporting" hasTabularNumbers>
{children}
</Text>
</HStack>
</Tooltip>
);
Metric.propTypes = {
icon: PropTypes.node,
children: PropTypes.node,
label: PropTypes.string
};
// One line of secondary context (rider / drop area / pickup) under the customer.
// `maxLines={1}` turns on Text's own truncate tooltip, which replaces the
// `title` the old spans used to show the full value when clipped.
const ContextLine = ({ icon, children }) => (
<HStack gap={1} vAlign="center">
<Center isInline width={13} height={13} style={{ flexShrink: 0, color: 'var(--color-icon-secondary)' }}>
{icon}
</Center>
<Text size="sm" type="supporting" maxLines={1}>
{children}
</Text>
</HStack>
);
ContextLine.propTypes = {
icon: PropTypes.node,
children: PropTypes.node
};
const ActiveSection = ({
visibleRiders,
riders,
@@ -75,32 +138,27 @@ const ActiveSection = ({
if (isLoading) {
return (
<div className="empty-slot" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: '200px' }}>
<div style={{ marginBottom: 16 }}><LoaderWithImage size={44} imgSize={24} /></div>
<div className="empty-slot-title">Loading active deliveries...</div>
</div>
<VStack gap={2} padding={4} hAlign="center" vAlign="center" minHeight={200}>
<LoaderWithImage size={44} imgSize={24} />
<Text type="supporting">Loading active deliveries…</Text>
</VStack>
);
}
if (activeDeliveries.length === 0) {
return (
<div className="empty-slot">
<div className="empty-slot-icon">
<MdInventory2 />
</div>
<div className="empty-slot-title">No active deliveries</div>
<div className="empty-slot-sub">
No deliveries are currently in progress for this slot
</div>
</div>
<EmptyState
title="No active deliveries"
description="No deliveries are currently in progress for this slot."
icon={<MdInventory2 size={28} />}
/>
);
}
const renderActiveDeliveryCard = (o, i) => {
const renderActiveDelivery = (o) => {
const rid = o.rider_id || o.userid;
const rider = riders.find((r) => String(r.id) === String(rid));
const color = getRiderColor(rid);
const statusStyle = getStatusStyle(o.orderstatus);
const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
@@ -108,7 +166,7 @@ const ActiveSection = ({
const customer = o.deliverycustomer || o.customername || `Order #${o.orderid}`;
const dropArea = o.deliverysuburb || o.deliveryaddress || o.zone_name || '';
const riderName = o.rider_name || o.ridername || 'Unassigned';
const isActive = canFocus && focusedStop && String(focusedStop.orderid) === String(o.orderid);
const isSelected = canFocus && focusedStop && String(focusedStop.orderid) === String(o.orderid);
const initials =
riderName
.split(/\s+/)
@@ -118,82 +176,70 @@ const ActiveSection = ({
.join('')
.toUpperCase() || '•';
const deliveredAt = o.deliverytime
? parseDoormileTimestamp(o.deliverytime).isValid()
? parseDoormileTimestamp(o.deliverytime).format('HH:mm:ss')
: String(o.deliverytime)
: null;
// 20 km/h is the same city-average assumption the old card used.
const etaMin = estMeters !== null ? (estMeters / 1000 / 20) * 60 : null;
return (
<div
<ListItem
key={o.orderid}
className={`adcard${isActive ? ' is-active' : ''}`}
style={{ '--ad-accent': color }}
label={customer}
isSelected={!!isSelected}
onClick={() => {
if (rider) handleRiderFocus(rider);
if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon });
}}
>
<div className="adcard-top">
<div className="adcard-avatar" style={{ background: color }}>{initials}</div>
<div className="adcard-titles">
<div className="adcard-rider" title={riderName}>
<MdTwoWheeler style={{ fontSize: 13, flexShrink: 0 }} />
<span className="adcard-tx">{riderName}</span>
</div>
<div className="adcard-customer" title={customer}>{customer}</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
<span
className="adcard-status"
style={{ background: `${statusStyle.bg}1a`, color: statusStyle.bg }}
title={statusStyle.label}
>
{statusStyle.label}
</span>
{o.deliverytime && (
<span className="adcard-time" style={{ fontSize: '11px', color: '#64748b' }}>
{parseDoormileTimestamp(o.deliverytime).isValid()
? parseDoormileTimestamp(o.deliverytime).format('HH:mm:ss')
: String(o.deliverytime)}
</span>
startContent={
// Rider colour is an arbitrary per-rider hex assigned by the map's
// palette, which no semantic token can express — this is the same
// caller-supplied-accent escape hatch StatCard uses.
<AccentAvatar color={color} selected size={32}>
<Text size="sm" weight="bold" style={{ color: 'inherit' }}>
{initials}
</Text>
</AccentAvatar>
}
description={
<VStack gap={0.5} padding={0}>
<ContextLine icon={<MdTwoWheeler size={13} />}>{riderName}</ContextLine>
{dropArea && <ContextLine icon={<MdLocationOn size={13} />}>{dropArea}</ContextLine>}
{o.pickupcustomer && <ContextLine icon={<MdRestaurant size={13} />}>{o.pickupcustomer}</ContextLine>}
</VStack>
}
endContent={
<VStack gap={1} padding={0} hAlign="end">
<StatusBadge status={o.orderstatus} />
{deliveredAt && (
<Text size="sm" type="supporting" hasTabularNumbers>
{deliveredAt}
</Text>
)}
</div>
</div>
{dropArea && (
<div className="adcard-addr">
<span className="adcard-ic"><MdLocationOn /></span>
<span className="adcard-tx adcard-addr-tx" title={dropArea}>{dropArea}</span>
</div>
)}
<div className="adcard-foot">
{o.pickupcustomer ? (
<span className="adcard-pickup" title={o.pickupcustomer}>
<span className="adcard-ic"><MdRestaurant /></span>
<span className="adcard-tx">{o.pickupcustomer}</span>
</span>
) : (
<span />
)}
<span className="adcard-metrics">
{estMeters !== null && (
<>
<span className="adcard-m adcard-m-eta" title="Distance to drop">
<span className="adcard-ic"><MdMyLocation /></span>
<HStack gap={2} vAlign="center">
<Metric icon={<MdMyLocation size={14} />} label="Distance to drop">
{formatMeters(estMeters)}
</span>
<span className="adcard-m adcard-m-time" title="Estimated time to drop">
<span className="adcard-ic"><MdAccessTime /></span>
{(() => {
const etaMin = estMeters / 1000 / 20 * 60;
return etaMin < 1 ? '< 1 min' : `${Math.ceil(etaMin)} min`;
})()}
</span>
</>
</Metric>
<Metric icon={<MdAccessTime size={14} />} label="Estimated time to drop">
{etaMin < 1 ? '< 1 min' : `${Math.ceil(etaMin)} min`}
</Metric>
</HStack>
)}
</span>
</div>
</div>
</VStack>
}
/>
);
};
return <div className="adcard-list">{activeDeliveries.map(renderActiveDeliveryCard)}</div>;
return (
<List density="balanced" hasDividers>
{activeDeliveries.map(renderActiveDelivery)}
</List>
);
};
export default ActiveSection;

View File

@@ -20,8 +20,23 @@ Dispatch.js defines the canonical batch hour ranges. `deliveries.js` mirrors the
**Gaps are intentional** (8–9 AM, 12 PM–4 PM, after 7 PM). Rows that fall in a gap belong to no batch — *not* to the nearest one.
### Time-field selection (`selectedTimeField`)
Default `'due'` → bucket key is `['expecteddeliverytime']` (the booking's promised delivery slot, `serviceoptions[0].estimateddeliveryat`). `deliveries.js` hardcodes the same key in `BATCH_TIME_KEYS`. If you change one, change both — they read each other's bucketing.
### Time-field selection (`BATCH_TIME_FIELD`)
Fixed at `'created'` → bucket key is `['orderdate']` (the booking's `createdat`). `deliveries.js` hardcodes the same key in `BATCH_TIME_KEYS`. If you change one, change both — they read each other's bucketing.
This is a constant, not state. The operator-facing time-field dropdown and the slot-hour editor have both been **deleted** (they were commented-out dead JSX whose setters nothing called). Don't resurrect either without re-reading the ban below.
**Three fields have been tried here. Two were wrong:**
| Field | Why it failed |
|---|---|
| `assigntime` | Not an assignment time — `api.js` maps it to the booking's `updatedat`, which is re-stamped by any status change. Orders drifted into whichever batch held the current clock time. |
| `expecteddeliverytime` | Stable, but it's the *promised delivery* slot, not the wave. An order created at 12:22 whose SLA put its ETA at 16:30 counted towards Evening, so Evening showed orders nobody had placed yet. |
| `orderdate` (**current**) | Immutable, and matches the operator's model: an order created and assigned at 12:22 belongs to Afternoon. No batch later than the current clock time can show a count for today. |
### Date filtering is client-side
`GET /admin/bookings` takes only `pageno`/`pagesize` — it has no date parameter. `fetchDeliveries` in `api.js` applies the `startdate`/`enddate` from the query key **client-side**, filtering on `orderdate` so it agrees with the bucket field above.
Those two key positions were previously destructured-but-ignored, which meant changing the date refetched and returned an identical row set — every batch count was the same for today, yesterday and last week. If you add a filter to this query, make sure `fetchDeliveries` actually reads it; a query key position that nothing consumes is a silent no-op.
### ⛔ Never bucket on `assigntime`
It is **not** an assignment time. The Doormile bookings feed has no assignment timestamp, so `api.js` maps `assigntime` to the booking's `updatedat` — its last-modified column. Any status change, parcel scan, payment or pickup-complete re-stamps it.
@@ -38,6 +53,39 @@ Displaying it as a "last updated" stamp is fine — that's all the reports use i
---
## 1b. Status colours — one table, and it isn't in this folder
`themes/dt/status.js` (`STATUS_META` / `getStatusMeta()`) is the **only** place a lifecycle status maps to a colour. Render statuses with `<StatusBadge status={...}/>` from `components/nearle_components/StatusBadge`.
This folder used to carry a rival `STATUS_STYLES` table in `dispatchShared.js`. It had drifted far enough to be actively misleading — `picked` and `active` were the *same* `#0ea5e9`, so the map showed a picked-up order and an in-transit order identically, while the Deliveries table showed them purple and teal. It has been deleted.
`dispatchShared.js` still exports **`getStatusStyle`**, but it is now a thin adapter over `getStatusMeta` returning `{ label, bg, fg }`. It exists for one reason: **Leaflet `divIcon` markers are built as HTML template strings**, and you cannot put a React component inside `L.divIcon({ html: '…' })`. That is the only sanctioned use.
- Rendering normal JSX? → `<StatusBadge>`.
- Building a divIcon HTML string, or setting an inline `style` on a marker? → `getStatusStyle`.
- Adding a status colour anywhere else in this folder? → don't; add it to `themes/dt/status.js`.
New backend status enums go in that file's `STATUS_ALIASES`, not in a local map. Unmapped values render as a neutral badge showing the raw string, so a missing alias is visible rather than silent.
---
## 1c. Astryx migration status for this folder
| File | State |
|---|---|
| `ActiveSection.js` | ✅ Astryx (List/ListItem rows, StatusBadge) |
| `dispatchShared.js` | ✅ no status table; exports `onActivate`, `useIsNarrowViewport`, `getStatusStyle` adapter |
| `ProfitabilitySection.js` | ✅ Astryx; its 1,596-line CSS is deleted |
| `CompareDataPanel.js` | ✅ MUI-free (score ring stays hand-rolled CSS — `conic-gradient` has no Astryx equivalent) |
| `Preview.js` | ✅ Astryx shell; **embeds `<Dispatch>`, which is still custom-CSS inside — a sanctioned temporary mix** |
| `Dispatch.js` + `Dispatch.css` | ⏳ **not converted.** Brand colours and dead CSS purged; structure still hand-rolled `<div>`/className |
**The folder no longer imports `@mui/*` anywhere.** Don't reintroduce it.
`Dispatch.js` is the remaining work and must follow the staged order in the plan: pure-refactor extraction of the safe presentational pieces first (header chrome, mode/batch row, KPI strip, sidebar list, GPS modal, popup chrome) with **zero** visual change, then convert each piece to Astryx individually, then purge its CSS region by region. Never extract the marker registry, `AnimatedRiderMarker`, the `MapContainer` tree, or the marker event handlers, and never move the state — only the rendering of already-computed values.
---
## 2. Leaflet integration
Dispatch.js uses `react-leaflet` for declarative tile/marker rendering, BUT a lot of marker behaviour is imperative:

View File

@@ -1,5 +1,4 @@
import React, { useMemo } from 'react';
import { useTheme, useMediaQuery } from '@mui/material';
import {
MdPublic,
MdSwapHoriz,
@@ -21,12 +20,16 @@ import {
MdHourglassBottom
} from 'react-icons/md';
import {
onActivate,
useIsNarrowViewport,
stepColor,
getStatusStyle,
FINAL_STATUSES,
SKIPPED_STATUSES,
ordinal
} from './dispatchShared';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { STATUS } from 'themes/dt/tokens';
// Right-side data panel rendered in Compare mode. Pure presentation +
// memoized derivations: feed it the comparison state from Dispatch and
@@ -65,8 +68,7 @@ function CompareDataPanel({
// scoped media-query block in Dispatch.css uses to stack the side-by-side
// timing/clock columns vertically and let stat chips wrap). No data,
// handler, or derivation behaviour changes on any breakpoint.
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isMobile = useIsNarrowViewport();
// All derivations live in a single useMemo so the cost of re-running
// them is paid only when an upstream input actually changes — not on
@@ -105,7 +107,13 @@ function CompareDataPanel({
(sum.onTime / onTimeForScore) * 25 +
((totalForScore - sum.anomalies) / totalForScore) * 15
);
const scoreColor = score >= 85 ? '#16a34a' : score >= 65 ? '#f59e0b' : '#dc2626';
// A compliance score is a health reading, not a lifecycle status, so it
// must NOT go through StatusBadge — it reads the raw hexes from the STATUS
// palette, which is the sanctioned "arbitrary accent" channel (root
// CLAUDE.md §6.3). Thresholds (85/65) and the ring mechanism below are
// deliberately unchanged; only the three constants are retargeted, so a QA
// A/B comparison should show identical scores and bands.
const scoreColor = score >= 85 ? STATUS.success : score >= 65 ? STATUS.warning : STATUS.error;
const scoreLabel = score >= 85 ? 'Excellent' : score >= 65 ? 'Acceptable' : 'Needs review';
// KPIs derived from delivery timestamps.
@@ -253,7 +261,11 @@ function CompareDataPanel({
className={`cdp-seq-diff${
focusedCompareStep === d.sequenceStep ? ' is-focused' : ''
}${focusable ? '' : ' is-nested'}`}
role="treeitem"
aria-selected={focusedCompareStep === d.sequenceStep}
tabIndex={0}
onClick={() => focusStep(d.sequenceStep)}
onKeyDown={onActivate(() => focusStep(d.sequenceStep))}
>
<span
className="cdp-seq-diff-num"
@@ -433,7 +445,9 @@ function CompareDataPanel({
<div
className="cdp-section-head cdp-section-head-clickable"
onClick={() => setSequenceOpen((v) => !v)}
onKeyDown={onActivate(() => setSequenceOpen((v) => !v))}
role="button"
tabIndex={0}
aria-expanded={sequenceOpen}
title={sequenceOpen ? 'Collapse route sequence' : 'Expand route sequence'}
>
@@ -455,7 +469,7 @@ function CompareDataPanel({
{sequenceOpen && (
<div className="cdp-seq">
{outOfOrderSteps.length > 0 ? (
<ul className="cdp-seq-diffs">
<ul className="cdp-seq-diffs" role="tree" aria-label="Route sequence differences">
{seqRuns.map((run, runIdx) => {
if (run.items.length === 1) {
return renderDiffRow(run.items[0]);
@@ -472,7 +486,11 @@ function CompareDataPanel({
<React.Fragment key={`run-${runIdx}-${first.d.sequenceStep}`}>
<li
className={`cdp-seq-diff is-group${isOpen ? ' is-expanded' : ''}${groupFocused ? ' is-focused' : ''}`}
role="treeitem"
aria-selected={groupFocused}
tabIndex={0}
onClick={() => toggleSeqGroup(runIdx)}
onKeyDown={onActivate(() => toggleSeqGroup(runIdx))}
aria-expanded={isOpen}
>
<span className="cdp-seq-group-num">
@@ -508,8 +526,8 @@ function CompareDataPanel({
</span>
</li>
{isOpen && (
<li className="cdp-seq-group-children-wrap">
<ul className="cdp-seq-group-children">
<li className="cdp-seq-group-children-wrap" role="presentation">
<ul className="cdp-seq-group-children" role="group">
{run.items.map((it) => renderDiffRow(it, false))}
</ul>
</li>
@@ -670,7 +688,9 @@ function CompareDataPanel({
<div
className="cdp-highlight is-best"
onClick={() => focusStep(bestStep.sequenceStep)}
onKeyDown={onActivate(() => focusStep(bestStep.sequenceStep))}
role="button"
tabIndex={0}
title="Focus this step"
>
<span className="cdp-highlight-rail" aria-hidden="true" />
@@ -710,7 +730,9 @@ function CompareDataPanel({
<div
className="cdp-highlight is-worst"
onClick={() => focusStep(worstStep.sequenceStep)}
onKeyDown={onActivate(() => focusStep(worstStep.sequenceStep))}
role="button"
tabIndex={0}
title="Focus this step"
>
<span className="cdp-highlight-rail" aria-hidden="true" />
@@ -810,7 +832,7 @@ function CompareDataPanel({
? 'is-under'
: ''
: '';
const statusStyle = getStatusStyle(focused.orderstatus);
const profit = parseFloat(focused.order?.profit || 0);
return (
<section className="cdp-section">
@@ -845,14 +867,7 @@ function CompareDataPanel({
Order #{focused.orderid}
</div>
</div>
{focused.orderstatus && (
<span
className="compare-delta-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
{focused.orderstatus && <StatusBadge status={focused.orderstatus} />}
</div>
<div className="compare-delta-grid">
<div className={`compare-delta-cell${focused.anomaly ? ' is-anomaly' : ''}`}>
@@ -920,7 +935,7 @@ function CompareDataPanel({
Deviations ({deviations.length})
</span>
</div>
<ul className="cdp-dev-list">
<ul className="cdp-dev-list" role="listbox" aria-label="Step deviations">
{deviations.map((d) => {
const color = stepColor(d.sequenceStep - 1);
const kmSign = d.kmDelta >= 0 ? '+' : '';
@@ -928,7 +943,11 @@ function CompareDataPanel({
<li
key={`dev-${d.sequenceStep}`}
className={`cdp-dev-item${focusedCompareStep === d.sequenceStep ? ' is-focused' : ''}`}
role="option"
aria-selected={focusedCompareStep === d.sequenceStep}
tabIndex={0}
onClick={() => focusStep(d.sequenceStep)}
onKeyDown={onActivate(() => focusStep(d.sequenceStep))}
>
<span className="cdp-dev-num" style={{ background: color }}>
{d.sequenceStep}
@@ -970,7 +989,7 @@ function CompareDataPanel({
{delivered}/{compareDeltas.length} delivered
</span>
</div>
<ul className="cdp-step-list">
<ul className="cdp-step-list" role="listbox" aria-label="Route steps">
{compareDeltas.map((d) => {
const color = stepColor(d.sequenceStep - 1);
const statusLow = String(d.orderstatus || '').toLowerCase();
@@ -978,7 +997,7 @@ function CompareDataPanel({
const isSkipped = SKIPPED_STATUSES.has(statusLow);
const isCorrect = isDelivered && !d.anomaly;
const isFocused = focusedCompareStep === d.sequenceStep;
const statusStyle = getStatusStyle(d.orderstatus);
const profit = parseFloat(d.order?.profit || 0);
const kmSign = d.kmDelta >= 0 ? '+' : '';
const timeCls =
@@ -1001,7 +1020,11 @@ function CompareDataPanel({
<li
key={`step-${d.sequenceStep}`}
className={stepCls}
role="option"
aria-selected={isFocused}
tabIndex={0}
onClick={() => focusStep(d.sequenceStep)}
onKeyDown={onActivate(() => focusStep(d.sequenceStep))}
>
<span className="cdp-step-num" style={{ background: color }}>
{d.sequenceStep}
@@ -1021,14 +1044,7 @@ function CompareDataPanel({
<span className="cdp-step-title">
{d.deliverycustomer || `Step ${d.sequenceStep}`}
</span>
{d.orderstatus && (
<span
className="cdp-step-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
{d.orderstatus && <StatusBadge status={d.orderstatus} />}
</div>
<div className="cdp-step-sub">
{d.pickupcustomer ? `from ${d.pickupcustomer} · ` : ''}

View File

@@ -117,7 +117,7 @@
border-radius: 999px;
background: rgba(123, 31, 162, 0.08);
border: 1px solid rgba(123, 31, 162, 0.25);
color: #7b1fa2;
color: var(--color-accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
@@ -221,12 +221,12 @@
.dispatch-container .logo-city-option.active {
background: rgba(123, 31, 162, 0.1);
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .logo-city-option-icon {
font-size: 14px;
color: #7b1fa2;
color: var(--color-accent);
flex-shrink: 0;
}
@@ -238,7 +238,7 @@
}
.dispatch-container .logo-city-option-check {
color: #7b1fa2;
color: var(--color-accent);
font-weight: 800;
flex-shrink: 0;
}
@@ -975,329 +975,6 @@
flex-shrink: 0;
}
/* Slot-time-field dropdown — picks which timestamp column drives slot
bucketing. Styled to match the location-pill dropdown in the header so
both feel like the same kind of filter control. */
.dispatch-container .time-field-wrap {
position: relative;
display: inline-block;
flex-shrink: 0;
margin-right: 4px;
}
.dispatch-container .time-field-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(123, 31, 162, 0.08);
border: 1px solid rgba(123, 31, 162, 0.25);
color: #7b1fa2;
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.dispatch-container .time-field-btn:hover {
background: rgba(123, 31, 162, 0.14);
border-color: rgba(123, 31, 162, 0.45);
}
.dispatch-container .time-field-btn.open {
background: rgba(123, 31, 162, 0.18);
border-color: rgba(123, 31, 162, 0.55);
box-shadow: 0 4px 12px rgba(123, 31, 162, 0.18);
}
.dispatch-container .time-field-btn svg {
font-size: 13px;
flex-shrink: 0;
}
.dispatch-container .time-field-caret {
font-size: 15px;
transition: transform 0.2s ease;
}
.dispatch-container .time-field-btn.open .time-field-caret {
transform: rotate(180deg);
}
.dispatch-container .time-field-text {
white-space: nowrap;
}
.dispatch-container .time-field-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 180px;
background: #fff;
border: 1px solid rgba(123, 31, 162, 0.18);
border-radius: 12px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16);
padding: 6px;
z-index: 1000;
animation: logo-city-menu-in 0.14s ease-out;
}
.dispatch-container .time-field-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: 0;
background: transparent;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: background 0.12s ease;
}
.dispatch-container .time-field-option:hover {
background: rgba(123, 31, 162, 0.06);
}
.dispatch-container .time-field-option.active {
background: rgba(123, 31, 162, 0.1);
color: #7b1fa2;
}
.dispatch-container .time-field-option-icon {
font-size: 14px;
color: #7b1fa2;
flex-shrink: 0;
}
.dispatch-container .time-field-option-check {
margin-left: auto;
color: #7b1fa2;
font-weight: 800;
}
/* Slot timings editor — popover anchored to a small "Edit slots" button in
the batch row. Lets the operator tweak start/end hours, add new slots,
delete existing ones, or reset to the default 5-slot layout. */
.dispatch-container .slot-edit-wrap {
position: relative;
display: inline-block;
flex-shrink: 0;
margin-right: 4px;
}
.dispatch-container .slot-edit-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.04);
border: 1px dashed rgba(15, 23, 42, 0.18);
color: #475569;
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
font-family: inherit;
}
.dispatch-container .slot-edit-btn:hover {
background: rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.32);
color: #0f172a;
}
.dispatch-container .slot-edit-btn.open {
background: rgba(123, 31, 162, 0.1);
border-color: rgba(123, 31, 162, 0.5);
border-style: solid;
color: #7b1fa2;
}
.dispatch-container .slot-edit-btn svg {
font-size: 13px;
flex-shrink: 0;
}
.dispatch-container .slot-edit-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 340px;
background: #fff;
border: 1px solid rgba(123, 31, 162, 0.18);
border-radius: 14px;
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.2);
padding: 12px;
z-index: 1000;
animation: logo-city-menu-in 0.14s ease-out;
}
.dispatch-container .slot-edit-head {
margin-bottom: 10px;
}
.dispatch-container .slot-edit-title {
font-size: 13px;
font-weight: 800;
color: #0f172a;
}
.dispatch-container .slot-edit-sub {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
.dispatch-container .slot-edit-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 260px;
overflow-y: auto;
padding-right: 2px;
}
.dispatch-container .slot-edit-row {
display: grid;
grid-template-columns: 22px 70px 70px 1fr 28px;
align-items: center;
gap: 8px;
}
.dispatch-container .slot-edit-idx {
width: 22px;
height: 22px;
border-radius: 6px;
background: rgba(123, 31, 162, 0.12);
color: #7b1fa2;
font-size: 11px;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
}
.dispatch-container .slot-edit-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.dispatch-container .slot-edit-field-label {
font-size: 9px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.dispatch-container .slot-edit-field input {
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.16);
border-radius: 8px;
padding: 5px 8px;
font-size: 12px;
font-weight: 700;
color: #0f172a;
font-family: inherit;
background: #fff;
}
.dispatch-container .slot-edit-field input:focus {
outline: none;
border-color: #7b1fa2;
box-shadow: 0 0 0 3px rgba(123, 31, 162, 0.18);
}
.dispatch-container .slot-edit-preview {
font-size: 11px;
color: #475569;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dispatch-container .slot-edit-remove {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid rgba(220, 38, 38, 0.32);
background: rgba(220, 38, 38, 0.06);
color: #dc2626;
font-size: 16px;
font-weight: 800;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.dispatch-container .slot-edit-remove:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.14);
border-color: rgba(220, 38, 38, 0.55);
}
.dispatch-container .slot-edit-remove:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dispatch-container .slot-edit-actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed rgba(15, 23, 42, 0.1);
}
.dispatch-container .slot-edit-add,
.dispatch-container .slot-edit-reset {
flex: 1;
border-radius: 8px;
padding: 7px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
font-family: inherit;
border: 1px solid transparent;
}
.dispatch-container .slot-edit-add {
background: #7b1fa2;
color: #fff;
border-color: #7b1fa2;
}
.dispatch-container .slot-edit-add:hover {
background: #6a1591;
}
.dispatch-container .slot-edit-reset {
background: #fff;
color: #475569;
border-color: rgba(15, 23, 42, 0.16);
}
.dispatch-container .slot-edit-reset:hover {
background: rgba(15, 23, 42, 0.04);
color: #0f172a;
}
.dispatch-container .batch-btn {
display: inline-flex;
@@ -2921,7 +2598,7 @@
/* ---------- Per-order cards inside a focused zone ---------- */
/* Each order is a self-contained card so the list reads as a deck of delivery
tickets rather than a wall of text. All accents use the console's base
purple (#7b1fa2) so the page sits inside the existing design language. */
purple (var(--color-accent)) so the page sits inside the existing design language. */
.dispatch-container .zone-order-grid {
display: flex;
@@ -2948,7 +2625,7 @@
left: 0;
width: 4px;
height: 100%;
background: #7b1fa2;
background: var(--color-accent);
opacity: 0.85;
transition: width 0.18s ease, opacity 0.18s ease;
}
@@ -2969,7 +2646,7 @@
}
.dispatch-container .zone-order-card.active {
border-color: #7b1fa2;
border-color: var(--color-accent);
box-shadow: 0 10px 24px rgba(123, 31, 162, 0.22);
background: linear-gradient(180deg, #ffffff 0%, #f7eaff 100%);
}
@@ -3019,8 +2696,8 @@
font-weight: 800;
font-size: 13px;
flex-shrink: 0;
background: #7b1fa2;
border: 1px solid #7b1fa2;
background: var(--color-accent);
border: 1px solid var(--color-accent);
box-shadow: 0 2px 6px rgba(123, 31, 162, 0.22);
}
@@ -3044,7 +2721,7 @@
.dispatch-container .zone-order-rider {
font-size: 11px;
font-weight: 600;
color: #7b1fa2;
color: var(--color-accent);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
@@ -3085,7 +2762,7 @@
.dispatch-container .zone-order-time svg {
font-size: 14px;
color: #7b1fa2;
color: var(--color-accent);
flex-shrink: 0;
}
@@ -3199,7 +2876,7 @@
.dispatch-container .zone-order-chip.zone-order-trip {
background: rgba(123, 31, 162, 0.1);
border-color: rgba(123, 31, 162, 0.25);
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .zone-order-type {
@@ -4260,17 +3937,6 @@
text-overflow: ellipsis;
}
.dispatch-container .compare-delta-status {
display: inline-flex;
align-items: center;
padding: 4px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
flex-shrink: 0;
}
.dispatch-container .compare-delta-grid {
display: grid;
@@ -5389,7 +5055,7 @@
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #7b1fa2;
color: var(--color-accent);
border-bottom: 1px solid rgba(123, 31, 162, 0.18);
padding-bottom: 4px;
}
@@ -5571,7 +5237,7 @@
/* --- Header: purple gradient with order id + status + rider --- */
.dispatch-container .dispatch-popup .pu-header {
background: linear-gradient(135deg, #7b1fa2 0%, #9c27b0 50%, #ab47bc 100%);
background: var(--color-accent);
padding: 14px 16px 12px;
color: #fff;
border-radius: 12px 12px 0 0;
@@ -5706,13 +5372,13 @@
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .dispatch-popup .pu-header-step-value {
font-size: 15px;
font-weight: 800;
color: #7b1fa2;
color: var(--color-accent);
white-space: nowrap;
}
@@ -5737,7 +5403,7 @@
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7b1fa2;
color: var(--color-accent);
border-bottom: 1px solid rgba(123, 31, 162, 0.18);
}
@@ -5781,7 +5447,7 @@
height: 26px;
border-radius: 7px;
background: rgba(123, 31, 162, 0.12);
color: #7b1fa2;
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -5841,7 +5507,7 @@
.dispatch-container .dispatch-popup .pu-distance-icon {
display: inline-flex;
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .dispatch-popup .pu-distance-icon svg {
@@ -6326,7 +5992,7 @@
height: 64px;
border-radius: 16px;
background: rgba(123, 31, 162, 0.08);
color: #7b1fa2;
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -6384,7 +6050,7 @@
}
.dispatch-container .ri-search-input:focus {
border-color: #7b1fa2;
border-color: var(--color-accent);
background: #fff;
}
@@ -6429,13 +6095,13 @@
}
.dispatch-container .ri-rider-item.active {
border-color: #7b1fa2;
border-color: var(--color-accent);
background: linear-gradient(180deg, #fbf3ff 0%, #f0e0fa 100%);
box-shadow: 0 4px 12px rgba(123, 31, 162, 0.18);
}
.dispatch-container .ri-rider-item.active .ri-rider-name {
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .ri-rider-item.active .ri-rider-arrow {
@@ -6476,7 +6142,7 @@
}
.dispatch-container .ri-rider-arrow {
color: #7b1fa2;
color: var(--color-accent);
font-size: 18px;
font-weight: 800;
opacity: 0.4;
@@ -6577,7 +6243,7 @@
.dispatch-container .ri-live.is-refetching {
background: rgba(123, 31, 162, 0.12);
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .ri-live-dot {
@@ -6590,7 +6256,7 @@
}
.dispatch-container .ri-live.is-refetching .ri-live-dot {
background: #7b1fa2;
background: var(--color-accent);
box-shadow: 0 0 0 0 rgba(123, 31, 162, 0.55);
}
@@ -6648,7 +6314,7 @@
height: 32px;
border-radius: 9px;
background: rgba(123, 31, 162, 0.12);
color: #7b1fa2;
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -6727,7 +6393,7 @@
}
.dispatch-container .ri-coords-label svg {
color: #7b1fa2;
color: var(--color-accent);
font-size: 16px;
}
@@ -6753,7 +6419,7 @@
can read the location without opening the popup. Styled to override the
default leaflet tooltip chrome (rounded chip, brand purple). */
.dispatch-container .ri-map .leaflet-tooltip.ri-area-banner {
background: #7b1fa2;
background: var(--color-accent);
color: #fff;
border: 0;
border-radius: 8px;
@@ -6766,7 +6432,7 @@
}
.dispatch-container .ri-map .leaflet-tooltip.ri-area-banner::before {
border-top-color: #7b1fa2;
border-top-color: var(--color-accent);
}
/* Mobile — collapse the sidebar above the main panel, single-column stats */
@@ -7177,10 +6843,6 @@
font-size: 9px;
}
.dispatch-container .compare-delta-status {
padding: 2px 6px;
font-size: 9px;
}
.dispatch-container .compare-delta-grid {
gap: 6px;
@@ -7395,10 +7057,6 @@
font-size: 9px;
}
.dispatch-container .compare-delta-status {
padding: 2px 6px;
font-size: 9px;
}
/* Delta Grid - cells and labels */
.dispatch-container .compare-delta-grid {
@@ -8134,17 +7792,6 @@
min-width: 0;
}
.dispatch-container .cdp-step-status {
display: inline-flex;
align-items: center;
padding: 3px 9px;
font-size: 11px;
font-weight: 800;
border-radius: 999px;
letter-spacing: 0.05em;
text-transform: uppercase;
flex-shrink: 0;
}
.dispatch-container .cdp-step-sub {
font-size: 13px;
@@ -10791,186 +10438,6 @@
font-size: 10.5px;
}
/* ── ActiveSection Delivery Cards ────────────────────────────── */
.dispatch-container .adcard-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0 16px;
}
@keyframes adcard-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.dispatch-container .adcard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
cursor: pointer;
/* Explicit properties so the CSS animation (adcard-in) isn't interrupted by transition: all */
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background 0.2s cubic-bezier(0.4, 0, 0.2, 1);
animation: adcard-in 0.28s cubic-bezier(0.4, 0, 0.2, 1) both;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
overflow: hidden;
border-left: 4px solid var(--ad-accent, var(--border));
}
.dispatch-container .adcard:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--ad-accent, var(--accent));
}
.dispatch-container .adcard.is-active {
border-color: var(--ad-accent, var(--accent));
box-shadow: 0 0 0 2px rgba(58, 58, 58, 0.15), var(--shadow-lg);
background: var(--accent-soft);
}
.dispatch-container .adcard-top {
display: flex;
align-items: center;
gap: 12px;
}
.dispatch-container .adcard-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 13px;
flex-shrink: 0;
}
.dispatch-container .adcard-titles {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.dispatch-container .adcard-customer {
font-size: 12px;
font-weight: 500;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dispatch-container .adcard-rider {
display: flex;
align-items: center;
gap: 4px;
color: var(--text);
font-size: 14.5px;
font-weight: 700;
}
.dispatch-container .adcard-status {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 6px;
white-space: nowrap;
}
.dispatch-container .adcard-addr {
display: flex;
align-items: flex-start;
gap: 6px;
background: var(--bg-sub);
padding: 8px 10px;
border-radius: 8px;
font-size: 12px;
color: var(--text-muted);
}
.dispatch-container .adcard-ic {
display: flex;
align-items: center;
font-size: 14px;
color: var(--text-muted);
flex-shrink: 0;
margin-top: 1px;
}
.dispatch-container .adcard-tx {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.dispatch-container .adcard-addr-tx {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.4;
}
.dispatch-container .adcard-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 11.5px;
color: var(--text-muted);
font-weight: 500;
margin-top: 2px;
}
.dispatch-container .adcard-pickup {
display: flex;
align-items: center;
gap: 4px;
color: var(--kitchen);
font-weight: 600;
min-width: 0;
}
.dispatch-container .adcard-pickup .adcard-ic {
color: var(--kitchen);
}
.dispatch-container .adcard-metrics {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
flex-shrink: 0;
}
.dispatch-container .adcard-m {
display: flex;
align-items: center;
gap: 3px;
color: var(--text-muted);
}
.dispatch-container .adcard-m-eta {
color: var(--ad-accent, var(--accent));
}
.dispatch-container .adcard-m-time {
color: #10b981;
}
/* =========================================================================
Mobile / app-like layout (≤ 768px). Purely a LAYOUT pass — no behaviour

View File

@@ -22,7 +22,6 @@ import {
MdStraighten,
MdLocationOn,
MdMarkunreadMailbox,
MdMoveToInbox,
MdPlace,
MdTwoWheeler,
MdNotes,
@@ -61,7 +60,7 @@ import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLo
import { getConsignmentLogs } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import {
STATUS_STYLES,
onActivate,
getStatusStyle,
FINAL_STATUSES,
SKIPPED_STATUSES,
@@ -149,22 +148,9 @@ const BATCHES_DEFAULT_RAW = [
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
];
// v9: afternoon batch extended to 12:30 PM. Bumping from v8 wipes the
// cached layouts that still hold the old endHour: 12 value.
const SLOTS_STORAGE_KEY = 'dispatch.slots.v9';
// Every prior storage key. Wiped once on mount so stale layouts
// from earlier code versions can't reappear on the next page load.
const LEGACY_SLOTS_STORAGE_KEYS = [
'dispatch.slots.v1',
'dispatch.slots.v2',
'dispatch.slots.v3',
'dispatch.slots.v4',
'dispatch.slots.v5',
'dispatch.slots.v6',
'dispatch.slots.v7',
'dispatch.slots.v8'
];
// The `dispatch.slots.v1`…`v9` localStorage keys are no longer read or
// written — batch windows are constants now (see BATCHES in the component).
// Any leftover entries in an operator's browser are inert.
// Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a
// fractional startHour (24h, half-hour steps). Mirrors the human-readable
@@ -218,12 +204,21 @@ const getBatchForHour = (h, batches) => {
// with a fallback to expecteddeliverytime so undelivered orders still bucket.
const TIME_FIELDS = [
{ id: 'delivered', label: 'Delivered', keys: ['deliverytime'] },
// The batch-bucketing default. `expecteddeliverytime` is the booking's
// promised delivery slot (serviceoptions[0].estimateddeliveryat) — it is set
// once and not re-stamped as the order progresses, which is exactly what a
// "wave" needs. Do not point this at `assigntime`: that field is the
// booking's last-modified column (see api.js) and drifts through the day.
{ id: 'due', label: 'Due', keys: ['expecteddeliverytime'] },
// The batch-bucketing default. `orderdate` is the booking's `createdat` —
// when the order came in. It is immutable, and it matches the operator's
// mental model: an order created and assigned at 12:22 belongs to the
// Afternoon wave, full stop.
//
// Two fields were tried here first and both were wrong:
// • `assigntime` — api.js maps it to the booking's last-modified column,
// so it is re-stamped by any status change and orders drifted into
// whichever batch held the current clock time.
// • `expecteddeliverytime` — stable, but it is the PROMISED DELIVERY slot,
// not the wave. An order created at 12:22 whose SLA puts its ETA at
// 16:30 counted towards Evening, so Evening showed orders that had not
// been placed yet. Bucketing on creation time also means no future batch
// can ever show a count for today, which is the correct behaviour.
{ id: 'created', label: 'Created', keys: ['orderdate'] },
{ id: 'assigned', label: 'Assigned', keys: ['assigntime'] },
{ id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] },
{ id: 'started', label: 'Started', keys: ['starttime'] },
@@ -232,6 +227,11 @@ const TIME_FIELDS = [
{ id: 'all', label: 'All', keys: ['deliverytime', 'expecteddeliverytime', 'assigntime', 'acceptedtime', 'arrivaltime', 'pickuptime', 'starttime'] }
];
// The one time-field batch bucketing uses. Not operator-selectable — see the
// `created` entry in TIME_FIELDS for why the other candidates were rejected.
// deliveries.js's BATCH_TIME_KEYS must name the same row field.
const BATCH_TIME_FIELD = 'created';
const getTimeFieldValue = (r, fieldId) => {
const field = TIME_FIELDS.find((f) => f.id === fieldId) || TIME_FIELDS[0];
for (const k of field.keys) {
@@ -416,10 +416,13 @@ const getStableRiderColor = (id) => {
return RIDER_COLORS[h % RIDER_COLORS.length];
};
// STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES,
// STEP_PALETTE, stepColor — moved to ./dispatchShared.js so the
// extracted CompareDataPanel component can import them without forcing
// a circular dependency on Dispatch.js.
// getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES, STEP_PALETTE, stepColor —
// moved to ./dispatchShared.js so the extracted CompareDataPanel component can
// import them without forcing a circular dependency on Dispatch.js.
//
// getStatusStyle is now a thin adapter over themes/dt/status.js. The remaining
// call sites in this file are Leaflet divIcon HTML strings, which need a raw
// hex — normal JSX should render <StatusBadge> instead.
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => {
const map = useMap();
@@ -911,7 +914,6 @@ const Dispatch = ({
// Short-lived close timer for the general map order/marker popups.
// Gives the cursor a ~200ms window to travel from the marker onto the popup
// or vice versa without immediately triggering a close.
const activePopupMarkerRef = useRef(null);
const popupHoverTimerRef = useRef(null);
// Order shown in the centered popup overlay. Rendered outside the leaflet
// map (see `dispatch-popup-center` overlay near the bottom of the JSX) so
@@ -941,85 +943,24 @@ const Dispatch = ({
const [locationMenuOpen, setLocationMenuOpen] = useState(false);
const locationMenuRef = useRef(null);
// Which timestamp column drives slot bucketing. Default = 'due'
// (expecteddeliverytime) so orders bucket into Morning/Afternoon/Evening by
// the delivery slot they were promised for. The status-wise time-field
// dropdown is hidden for now (see commented-out block in JSX), so this stays
// fixed at 'due'.
// Which timestamp column drives slot bucketing. Fixed at creation time —
// see TIME_FIELDS. This was a useState with an operator-facing dropdown;
// the dropdown was disabled for a correctness reason (picking "Assigned"
// bucketed on a column the backend re-stamps, so orders visibly jumped
// batches) and its JSX has now been deleted rather than left commented out.
// A constant makes the lock explicit instead of relying on nothing calling
// the setter.
const selectedTimeField = BATCH_TIME_FIELD;
// Batch windows are fixed. They used to be operator-editable and persisted
// to localStorage, but the editor UI was disabled and is now deleted, so the
// state, the persist effect and the legacy-key cleanup all went with it.
//
// Was 'assigned' (assigntime). That looked right — the spec says "bucket by
// assign time" — but on the Doormile backend `assigntime` is mapped to the
// booking's last-modified column (api.js), so it moves whenever the row is
// touched. Today's live orders therefore migrated into whichever batch
// contained the current clock time, and the 15s poll made the counts shift
// on their own. deliveries.js buckets on the same field for the same reason
// — the two pages must agree (see this folder's CLAUDE.md §1).
const [selectedTimeField, setSelectedTimeField] = useState('due');
const [timeFieldMenuOpen, setTimeFieldMenuOpen] = useState(false);
const timeFieldMenuRef = useRef(null);
// Operator-editable slot configuration. Seeded from localStorage so edits
// survive reloads; falls back to BATCHES_DEFAULT otherwise. Each entry has
// id/label/range/startHour/endHour just like the default list — the rest
// of the file reads BATCHES (derived below) without caring whether the
// values came from defaults or from operator edits.
const [slotsConfig, setSlotsConfig] = useState(() => {
if (typeof window === 'undefined') return BATCHES_DEFAULT;
try {
const raw = window.localStorage.getItem(SLOTS_STORAGE_KEY);
if (!raw) return BATCHES_DEFAULT;
const parsed = JSON.parse(raw);
// If the parsed slots length does not match BATCHES_DEFAULT_RAW, the data
// is stale (e.g. written from the older 5-slot layout). Discard it and
// load the default 3-batch layout.
if (!Array.isArray(parsed) || parsed.length !== BATCHES_DEFAULT_RAW.length) return BATCHES_DEFAULT;
// Re-derive label + range from the saved hours so any UI tweaks to the
// formatter (e.g. AM/PM style) flow through to old persisted slots. If
// the saved id matches a default batch, prefer that batch's friendly
// name ("Morning Batch", etc.) over the generated "Slot N · time" label.
return parsed.map((s, i) => {
const id = s.id || `slot-${i + 1}`;
const startHour = Number(s.startHour) || 0;
const endHour = Number(s.endHour) || 24;
const defaultMatch = BATCHES_DEFAULT.find((b) => b.id === id);
return {
id,
startHour,
endHour,
label: defaultMatch?.name || formatSlotLabel(i, startHour),
range: formatSlotRange(startHour, endHour)
};
});
} catch (e) {
return BATCHES_DEFAULT;
}
});
const BATCHES = slotsConfig;
const [slotEditOpen, setSlotEditOpen] = useState(false);
const slotEditRef = useRef(null);
// One-shot housekeeping: drop every prior slot-storage key so a stale
// layout written under an older schema version (e.g. v1/v2/v3 with five
// slots) can never resurface. Runs once per mount because deps are [].
useEffect(() => {
if (typeof window === 'undefined') return;
try {
LEGACY_SLOTS_STORAGE_KEYS.forEach((k) => window.localStorage.removeItem(k));
} catch (e) { /* private-mode / quota — ignore */ }
}, []);
// Persist edits whenever slotsConfig changes (skip the first render — the
// initializer already loaded from storage).
const slotsInitMountedRef = useRef(false);
useEffect(() => {
if (!slotsInitMountedRef.current) { slotsInitMountedRef.current = true; return; }
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(SLOTS_STORAGE_KEY, JSON.stringify(
slotsConfig.map(({ id, startHour, endHour }) => ({ id, startHour, endHour }))
));
} catch (e) { /* quota / private-mode — ignore */ }
}, [slotsConfig]);
// Dropping the localStorage READ is itself a fix: an operator who edited
// their slot hours in an older build kept getting those stale custom hours
// forever, silently disagreeing with deliveries.js, which has always used
// the constants.
const BATCHES = BATCHES_DEFAULT;
// Close the location dropdown on any click outside its wrapper.
useEffect(() => {
@@ -1033,30 +974,6 @@ const Dispatch = ({
return () => document.removeEventListener('mousedown', onDocClick);
}, [locationMenuOpen]);
// Same click-outside behavior for the slot-time-field dropdown.
useEffect(() => {
if (!timeFieldMenuOpen) return;
const onDocClick = (e) => {
if (timeFieldMenuRef.current && !timeFieldMenuRef.current.contains(e.target)) {
setTimeFieldMenuOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [timeFieldMenuOpen]);
// Click-outside dismissal for the slot-edit popover.
useEffect(() => {
if (!slotEditOpen) return;
const onDocClick = (e) => {
if (slotEditRef.current && !slotEditRef.current.contains(e.target)) {
setSlotEditOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [slotEditOpen]);
// Rider Info view — operator picks a rider in the sidebar, the main panel
// shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode
// ('rider-info') so it follows the same toggle pattern as the other modes.
@@ -2020,6 +1937,9 @@ const Dispatch = ({
// _ts on each ping to compute per-step dt, so we run it BEFORE
// stripping _ts on the way out.
const smoothed = kalmanSmoothGps(sorted);
// The unused `_ts` binding IS the mechanism — destructuring it out is
// how the internal timestamp is stripped from each point.
// eslint-disable-next-line no-unused-vars
return smoothed.map(({ _ts, ...p }) => p);
},
enabled: compareOpen && focusedRider != null,
@@ -2742,7 +2662,15 @@ const Dispatch = ({
const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null;
return (
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s`, borderLeft: `3px solid ${r.color}` }}>
<div
key={r.id}
className="rcard"
role="button"
tabIndex={0}
onClick={() => handleRiderFocus(r)}
onKeyDown={onActivate(() => handleRiderFocus(r))}
style={{ animationDelay: `${i * 0.05}s`, borderLeft: `3px solid ${r.color}` }}
>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: '#f1f5f9', borderColor: '#e2e8f0', color: '#64748b' }}><MdTwoWheeler /></div>
<div className="rcard-info">
@@ -2972,7 +2900,7 @@ const Dispatch = ({
? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep]))
: null;
return ordersToRender.map((o, idx) => {
return ordersToRender.map((o) => {
const rid = o.rider_id;
const active = rid ? activeRiders.has(rid) : true;
let color = getRiderColor(rid);
@@ -3650,170 +3578,6 @@ const Dispatch = ({
{shouldFetchLive && viewMode !== 'rider-info' && (
<div id="batch-row">
<span className="batch-label">Batch</span>
{/* Status-wise (time-field) filter is hidden for now per spec —
bucketing is locked to `due` (expecteddeliverytime). Restore this
block to bring back the Delivered/Due/Assigned/... dropdown. Note
that picking "Assigned" there buckets on the booking's
last-modified column, which drifts through the day (see api.js).
<div className="time-field-wrap" ref={timeFieldMenuRef}>
<button
type="button"
className={`time-field-btn ${timeFieldMenuOpen ? 'open' : ''}`}
onClick={() => setTimeFieldMenuOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={timeFieldMenuOpen}
title="Bucket slots by this timestamp"
>
<MdAccessTime />
<span className="time-field-text">{TIME_FIELDS.find((f) => f.id === selectedTimeField)?.label || 'Delivered'}</span>
<MdExpandMore className="time-field-caret" />
</button>
{timeFieldMenuOpen && (
<div className="time-field-menu" role="listbox">
{TIME_FIELDS.map((f) => {
const isActive = f.id === selectedTimeField;
return (
<button
key={f.id}
type="button"
role="option"
aria-selected={isActive}
className={`time-field-option ${isActive ? 'active' : ''}`}
onClick={() => {
setSelectedTimeField(f.id);
setTimeFieldMenuOpen(false);
}}
>
<MdAccessTime className="time-field-option-icon" />
<span>{f.label}</span>
{isActive && <span className="time-field-option-check">✓</span>}
</button>
);
})}
</div>
)}
</div>
*/}
{/* Slot editor (Edit slots button + panel) is hidden for now per
spec — the three batches (Morning / Afternoon / Evening) are
fixed. Restore this block to bring back the operator-editable
start/end hours, add-slot, and reset-to-defaults controls.
<div className="slot-edit-wrap" ref={slotEditRef}>
<button
type="button"
className={`slot-edit-btn ${slotEditOpen ? 'open' : ''}`}
onClick={() => setSlotEditOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={slotEditOpen}
title="Adjust slot timings"
>
<MdAccessTime />
<span>Edit slots</span>
</button>
{slotEditOpen && (
<div className="slot-edit-panel" role="dialog" aria-label="Edit slot timings">
<div className="slot-edit-head">
<div className="slot-edit-title">Slot timings</div>
<div className="slot-edit-sub">Hours are 0–24 (24h clock). Half-hour steps allowed (e.g. 12.5 = 12:30). Start &lt; End.</div>
</div>
<div className="slot-edit-list">
{slotsConfig.map((s, idx) => (
<div key={s.id} className="slot-edit-row">
<span className="slot-edit-idx">{idx + 1}</span>
<label className="slot-edit-field">
<span className="slot-edit-field-label">Start</span>
<input
type="number"
min={0}
max={23.5}
step={0.5}
value={s.startHour}
onChange={(e) => {
const raw = parseFloat(e.target.value);
const snapped = Number.isFinite(raw) ? Math.round(raw * 2) / 2 : 0;
const v = Math.max(0, Math.min(23.5, snapped));
setSlotsConfig((cur) => cur.map((row, i) =>
i === idx
? { ...row, startHour: v, label: formatSlotLabel(i, v), range: formatSlotRange(v, row.endHour) }
: row
));
}}
/>
</label>
<label className="slot-edit-field">
<span className="slot-edit-field-label">End</span>
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={s.endHour}
onChange={(e) => {
const raw = parseFloat(e.target.value);
const snapped = Number.isFinite(raw) ? Math.round(raw * 2) / 2 : 0.5;
const v = Math.max(0.5, Math.min(24, snapped));
setSlotsConfig((cur) => cur.map((row, i) =>
i === idx
? { ...row, endHour: v, range: formatSlotRange(row.startHour, v) }
: row
));
}}
/>
</label>
<span className="slot-edit-preview" title={`${formatSlotLabel(idx, s.startHour)} — ${formatSlotRange(s.startHour, s.endHour)}`}>
{formatSlotRange(s.startHour, s.endHour)}
</span>
<button
type="button"
className="slot-edit-remove"
onClick={() => setSlotsConfig((cur) => cur.filter((_, i) => i !== idx).map((row, i) => ({
...row,
id: `slot-${i + 1}`,
label: formatSlotLabel(i, row.startHour)
})))}
disabled={slotsConfig.length <= 1}
title={slotsConfig.length <= 1 ? 'Keep at least one slot' : 'Remove this slot'}
>
×
</button>
</div>
))}
</div>
<div className="slot-edit-actions">
<button
type="button"
className="slot-edit-add"
onClick={() => setSlotsConfig((cur) => {
const last = cur[cur.length - 1];
const start = Math.min(23, (last?.endHour ?? 0));
const end = Math.min(24, start + 1);
const i = cur.length;
return [
...cur,
{
id: `slot-${i + 1}`,
startHour: start,
endHour: end,
label: formatSlotLabel(i, start),
range: formatSlotRange(start, end)
}
];
})}
>
+ Add slot
</button>
<button
type="button"
className="slot-edit-reset"
onClick={() => setSlotsConfig(BATCHES_DEFAULT)}
>
Reset to defaults
</button>
</div>
</div>
)}
</div>
*/}
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
horizontally when it overflows. */}
<div className="batch-scroll">
@@ -3919,7 +3683,7 @@ const Dispatch = ({
{riderInfoIsError && (
<div className="ri-error">
Couldn't load this rider's log. {riderInfoError?.message || ''}
Couldn’t load this rider’s log. {riderInfoError?.message || ''}
</div>
)}
@@ -4313,12 +4077,7 @@ const Dispatch = ({
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
onKeyDown={canFocus ? onActivate(() => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon })) : undefined}
title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
>
<div className="zone-order-card-head">
@@ -4487,6 +4246,7 @@ const Dispatch = ({
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? onActivate(() => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon })) : undefined}
>
<div className="zone-order-card-head">
<div className="zone-order-num">{o.step || idx + 1}</div>
@@ -4632,6 +4392,7 @@ const Dispatch = ({
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? onActivate(() => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon })) : undefined}
>
<div className="zone-order-card-head">
<div className="zone-order-num">
@@ -4765,7 +4526,15 @@ const Dispatch = ({
const delivered = z.statusCounts.delivered || 0;
const profitNeg = z.totalProfit < 0;
return (
<div key={z.id} className="rcard zone-card" onClick={() => setFocusedZone(z)} style={{ animationDelay: `${i * 0.05}s` }}>
<div
key={z.id}
className="rcard zone-card"
role="button"
tabIndex={0}
onClick={() => setFocusedZone(z)}
onKeyDown={onActivate(() => setFocusedZone(z))}
style={{ animationDelay: `${i * 0.05}s` }}
>
<div className="zone-card-header">
<div className="zone-card-emoji"><MdMap /></div>
<div className="zone-card-titles">
@@ -4844,7 +4613,15 @@ const Dispatch = ({
})
) : viewMode === 'kitchens' ? (
kitchens.map((k, i) => (
<div key={k.id} className="rcard" onClick={() => setFocusedKitchen(k)} style={{ animationDelay: `${i * 0.05}s` }}>
<div
key={k.id}
className="rcard"
role="button"
tabIndex={0}
onClick={() => setFocusedKitchen(k)}
onKeyDown={onActivate(() => setFocusedKitchen(k))}
style={{ animationDelay: `${i * 0.05}s` }}
>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: '#f59e0b18', borderColor: '#f59e0b50', color: '#f59e0b' }}><MdRestaurant /></div>
<div className="rcard-info">
@@ -5203,15 +4980,9 @@ const Dispatch = ({
(o) => o.deliveryid != null && String(o.deliveryid) === String(t.deliveryid)
);
const statusStyle = getStatusStyle(t.orderstatus);
const flagSvg = t.orderstatus
? `<svg class="cmark-flag" viewBox="0 0 18 22" xmlns="http://www.w3.org/2000/svg">
<line x1="1.5" y1="0" x2="1.5" y2="22" stroke="#0f172a" stroke-width="1.6" stroke-linecap="round"/>
<polygon points="2,1 17,1 13.5,5.5 17,10 2,10" fill="${statusStyle.bg}" stroke="#0f172a" stroke-width="0.6" stroke-linejoin="round"/>
${isDelivered ? '<polyline points="5,5.5 7,7.5 11,3.5" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>' : ''}
</svg>`
: '';
// A status flag SVG used to be built here and never rendered —
// this marker draws `dropPinHtml` below instead. Removed with
// the getStatusStyle lookup that only fed it.
const dropClasses = ['compare-step-pin'];
if (isFocusedStep) dropClasses.push('is-focused');
if (isDelivered) dropClasses.push('is-delivered');
@@ -6627,14 +6398,22 @@ const Dispatch = ({
{riderPositionModal && (
<div
className="da-pos-modal-backdrop"
role="dialog"
aria-modal="true"
aria-label={`Last known position of ${riderPositionModal.name}`}
// The dialog role belongs on the card below, not on the backdrop —
// the backdrop is a click-outside-to-dismiss scrim, which is
// presentational. Marking it as the dialog made it a non-interactive
// element carrying a click handler. Dismissal is still available from
// the card's own close button, so no keyboard path is lost here.
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setRiderPositionModal(null);
}}
>
<div className="da-pos-modal-card">
<div
className="da-pos-modal-card"
role="dialog"
aria-modal="true"
aria-label={`Last known position of ${riderPositionModal.name}`}
>
<div className="da-pos-modal-head">
<div className="da-pos-modal-title-wrap">
<div

View File

@@ -1,33 +1,28 @@
import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useLocation, useNavigate } from 'react-router-dom';
import logger from '../../../utils/logger';
import {
Autocomplete,
Backdrop,
Box,
Button,
Card,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Stack,
Tab,
Tabs,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { useMutation, useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { HiOutlineArrowLeft } from 'react-icons/hi';
import { IoReload } from 'react-icons/io5';
import { MdTwoWheeler, MdSwapHoriz, MdWarning } from 'react-icons/md';
import { MdTwoWheeler, MdSwapHoriz, MdWarning, MdArrowBack } from 'react-icons/md';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Heading } from '@astryxdesign/core/Heading';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Badge } from '@astryxdesign/core/Badge';
import { Banner } from '@astryxdesign/core/Banner';
import { Selector } from '@astryxdesign/core/Selector';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { TabList, Tab } from '@astryxdesign/core/TabList';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import {
buildMilerLookup,
@@ -43,7 +38,66 @@ import { OpenToast } from 'components/third-party/OpenToast';
import CSVExport from 'components/third-party/ReactTable';
import CircularLoader from 'components/CircularLoader';
import Dispatch from './Dispatch';
import { stepColor } from './dispatchShared';
import { stepColor, useIsNarrowViewport } from './dispatchShared';
import { STATUS } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives';
// Numbered route-step marker. Kept as a small custom component rather than an
// Astryx primitive: it is a sequence marker carrying a per-step palette colour
// plus an unverified-rider warning overlay, which no Astryx component models.
// `isUnverified` is tinted from the STATUS palette rather than a raw hex.
function StepChip({ order, stepNum, isUnverified, onClick }) {
const color = isUnverified ? STATUS.error : stepColor(Number(stepNum) - 1);
const hint = isUnverified ? 'Rider not recognized — click to assign a real rider' : 'Click to change rider';
return (
<Tooltip content={`Order #${order.orderid} · ${order.deliveryaddress || order.deliverysuburb || ''} — ${hint}`}>
{/* A real <button>, not a styled Center: this is an interactive control,
so the native element gives keyboard activation and focus for free.
Astryx's Center has no `as` prop, and its own Button can't express a
36px circular sequence marker. */}
<button
type="button"
onClick={onClick}
aria-label={`Step ${stepNum}, order ${order.orderid}. ${hint}`}
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
padding: 0,
borderRadius: '50%',
border: 'none',
backgroundColor: color,
color: '#fff',
fontWeight: 800,
fontSize: 14,
cursor: 'pointer',
boxShadow: isUnverified
? `0 0 0 2px #fff, 0 0 0 4px ${STATUS.error}`
: '0 0 0 2px rgba(255,255,255,0.6)'
}}
>
{stepNum}
{isUnverified && (
<MdWarning
size={14}
style={{ position: 'absolute', top: -5, right: -5, color: STATUS.error, background: '#fff', borderRadius: '50%' }}
/>
)}
</button>
</Tooltip>
);
}
StepChip.propTypes = {
order: PropTypes.object.isRequired,
stepNum: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
isUnverified: PropTypes.bool,
onClick: PropTypes.func
};
const tuningTypes = [
{ tuneid: 1, type: 'Balanced', value: 'balanced' },
@@ -300,8 +354,7 @@ const Preview = () => {
const navigate = useNavigate();
const location = useLocation();
const stateData = location.state || {};
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isMobile = useIsNarrowViewport();
// SINGLE SOURCE OF TRUTH: every Change Rider / Reconcile / Re-Assign goes
// through this state. The Dispatch tab renders from it, the Reconcile tab
@@ -363,6 +416,21 @@ const Preview = () => {
staleTime: 5 * 60 * 1000
});
// Options for the Change Rider picker. Astryx's Selector is value-based
// (string in, string out) where MUI's Autocomplete handed back the whole
// option object, so each entry keeps a reference to its raw rider — that is
// what `confirmChangeRider` reads (`.userid`, `.label`, `.firstname`,
// `.lastname`), and that function is frozen logic for this conversion.
const riderOptions = useMemo(
() =>
(ridersList || []).map((r) => ({
value: String(r.userid),
label: r.label || `${r.firstname || ''} ${r.lastname || ''}`.trim() || `Rider ${r.userid}`,
rider: r
})),
[ridersList]
);
// Derived: rider list for the Reconcile tab. Recomputes whenever the cache
// (dispatchPreviewData) changes — so Change Rider / Reconcile both reflect
// here without a separate state.
@@ -668,106 +736,100 @@ const Preview = () => {
// embeds that same component below) — MainLayout's `.main-content-area`
// has 24px left/right/bottom padding and no padding-top (AppTopNav sits
// above it in normal flow), and exposes its own measured height via the
// --appshell-header-height CSS var. A bare `height: '100vh'` here (the
// previous value) doesn't subtract the top nav's height or cancel the
// surrounding padding, so the page's real content was taller than the
// visible viewport by roughly the nav height + padding — turning the
// whole page (not just the intended internal panels) into a scroll
// container, which dragged the rider sidebar along with it instead of
// it staying in place.
<Box
sx={{
display: 'flex',
flexDirection: 'column',
// --appshell-header-height CSS var. A bare `height: '100vh'` here doesn't
// subtract the top nav's height or cancel the surrounding padding, so the
// page's real content was taller than the visible viewport by roughly the
// nav height + padding — turning the whole page (not just the intended
// internal panels) into a scroll container, which dragged the rider
// sidebar along with it instead of it staying in place.
<VStack
gap={0}
padding={0}
style={{
height: 'calc(100vh - var(--appshell-header-height, 64px))',
margin: '0 -24px -24px -24px',
overflow: 'hidden',
position: 'relative'
}}
>
<Backdrop
sx={{ position: 'absolute', color: '#fff', zIndex: (theme) => theme.zIndex.modal + 1 }}
open={isLoading}
>
<CircularLoader color="inherit" />
</Backdrop>
<Box sx={{ py: 1.25, px: 2, borderBottom: '1px solid #eef2f6' }}>
<Stack
direction={{ xs: 'column', md: 'row' }}
alignItems={{ xs: 'stretch', md: 'center' }}
justifyContent="space-between"
spacing={1.25}
{isLoading && (
<Center
style={{
position: 'absolute',
inset: 0,
zIndex: 1400,
backgroundColor: 'var(--color-overlay)'
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<Tooltip title="Back to orders" placement="top">
<IconButton
onClick={() => navigate('/doormile/orders')}
sx={{ bgcolor: 'action.hover', '&:hover': { bgcolor: 'action.selected' } }}
>
<HiOutlineArrowLeft size={20} />
</IconButton>
</Tooltip>
<Typography variant="h3" fontWeight={600}>
Assign Orders
</Typography>
</Stack>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'stretch', sm: 'center' }}
spacing={1}
sx={{ width: { xs: '100%', md: 'auto' } }}
>
<Autocomplete
options={tuningTypes || []}
getOptionLabel={(option) => option.type}
sx={{ minWidth: { xs: 0, sm: 250 }, maxWidth: 600, flex: 1, width: { xs: '100%', sm: 'auto' } }}
renderInput={(params) => <TextField {...params} label="Hyper Tuning" />}
onChange={(e, val, reason) => {
if (reason === 'clear') handleCreateDelivery(null);
else handleCreateDelivery(val.value);
}}
<CircularLoader color="inherit" />
</Center>
)}
<VStack
gap={0}
paddingInline={2}
paddingBlock={1.5}
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<HStack justify="between" vAlign="center" wrap="wrap" gap={1.5}>
<HStack gap={1} vAlign="center">
<IconButton
label="Back to orders"
tooltip="Back to orders"
variant="ghost"
icon={<HiOutlineArrowLeft size={20} />}
onClick={() => navigate('/doormile/orders')}
/>
<Heading level={2}>Assign Orders</Heading>
</HStack>
<HStack gap={1} vAlign="center" wrap="wrap">
<div style={{ minWidth: 220 }}>
<Selector
label="Hyper Tuning"
isLabelHidden
placeholder="Hyper Tuning"
options={tuningTypes.map((t) => ({ value: t.value, label: t.type }))}
onChange={(value) => handleCreateDelivery(value || null)}
/>
</div>
<Button
variant="contained"
color="primary"
startIcon={<IoReload />}
fullWidth={isMobile}
label="Re-Assign"
variant="primary"
icon={<IoReload />}
width={isMobile ? '100%' : undefined}
onClick={() => {
setIsLoading(true);
handleCreateDelivery('reshuffle');
}}
>
Re-Assign
</Button>
/>
<CSVExport
data={csvExportData}
filename={`Orders_Detail_${dayjs().format('YYYY-MM-DD_HHmmss')}.csv`}
label=" CSV"
style={{ m: 1 }}
/>
</Stack>
</Stack>
</Box>
</HStack>
</HStack>
</VStack>
<Box sx={{ px: 2, borderBottom: '1px solid #eef2f6' }}>
<Tabs
value={tabValue}
onChange={(e, v) => setTabValue(v)}
variant={isMobile ? 'fullWidth' : 'standard'}
sx={{ minHeight: 40 }}
<VStack gap={0} paddingInline={2} padding={0}>
<TabList
value={String(tabValue)}
onChange={(v) => setTabValue(Number(v))}
layout={isMobile ? 'fill' : 'hug'}
hasDivider
>
<Tab label="Dispatch" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} />
<Tab label="Reconcile" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} />
</Tabs>
</Box>
<Tab value="0" label="Dispatch" />
<Tab value="1" label="Reconcile" />
</TabList>
</VStack>
{/* minHeight: 0 overrides the flex-item default of min-height: auto —
{/* minHeight 0 overrides the flex-item default of min-height: auto —
without it, this flex:1 child can grow past its allotted space to
fit its content's natural height instead of clipping to it, which
is what let the embedded Dispatch view (and its internal
overflow-y:auto sidebar) overflow into page-level scroll. */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<VStack gap={0} padding={0} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{tabValue === 0 && dispatchPreviewData && (
<Dispatch
// The key forces a full re-mount when the cache reference changes
@@ -781,232 +843,167 @@ const Preview = () => {
onChangeRider={(order, focusedRider) => openChangeRider(focusedRider, order)}
/>
)}
{tabValue === 1 && (
<Box sx={{ flex: 1, overflow: 'auto', p: 2, bgcolor: '#f8fafc' }}>
<VStack gap={2} padding={2} isScrollable style={{ flex: 1 }}>
{reconcileRiders.length === 0 ? (
<Typography sx={{ color: '#94a3b8', textAlign: 'center', mt: 4 }}>
No rider data available to reconcile.
</Typography>
<EmptyState
title="Nothing to reconcile"
description="No rider data available to reconcile."
icon={<MdSwapHoriz size={28} />}
/>
) : (
<Stack spacing={1.75}>
<Box
sx={{
bgcolor: hasReconciled ? '#ecfdf5' : '#fffbeb',
border: `1px solid ${hasReconciled ? '#a7f3d0' : '#fde68a'}`,
color: hasReconciled ? '#065f46' : '#92400e',
borderRadius: '10px',
px: 1.5,
py: 1,
fontSize: 13
}}
>
{hasReconciled
? 'Steps have been reconciled. The Dispatch tab and Assign payload are updated.'
: 'Click a numbered step to change its rider. Hit Reconcile to verify the corrected steps with the server.'}
</Box>
<>
<Banner
status={hasReconciled ? 'success' : 'warning'}
title={
hasReconciled
? 'Steps have been reconciled. The Dispatch tab and Assign payload are updated.'
: 'Click a numbered step to change its rider. Hit Reconcile to verify the corrected steps with the server.'
}
/>
{reconcileRiders.map((r) => {
const totalKms = r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
return (
<Card key={r.rider_id} sx={{ p: { xs: 1.5, md: 2 }, borderRadius: '12px', boxShadow: '0 1px 3px rgba(15,23,42,0.06)' }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{ mb: 1.25, flexWrap: 'wrap', gap: 1 }}
>
<Stack direction="row" alignItems="center" gap={1.25}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: '8px',
bgcolor: '#eef2ff',
color: '#4f46e5',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Card key={r.rider_id} padding={3}>
<HStack justify="between" vAlign="center" wrap="wrap" gap={1}>
<HStack gap={1.5} vAlign="center">
<AccentAvatar color={STATUS.accepted} size={32}>
<MdTwoWheeler size={18} />
</Box>
<Box>
<Typography sx={{ fontWeight: 700, fontSize: 14, color: '#1e293b' }}>
{r.rider_name}
</Typography>
<Typography sx={{ fontSize: 11.5, color: '#64748b' }}>
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="bold">{r.rider_name}</Text>
<Text size="sm" type="supporting">
ID: {r.rider_id}
</Typography>
</Box>
</Stack>
<Stack direction="row" gap={1}>
<Chip size="small" label={`${r.orders.length} stops`} sx={{ fontWeight: 600 }} />
<Chip size="small" label={`${totalKms.toFixed(1)} km`} variant="outlined" />
</Stack>
</Stack>
</Text>
</VStack>
</HStack>
<HStack gap={1} vAlign="center">
<Badge variant="neutral" label={`${r.orders.length} stops`} />
<Badge variant="neutral" label={`${totalKms.toFixed(1)} km`} />
</HStack>
</HStack>
<Stack direction="row" gap={1.25} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
{r.orders.map((o, idx) => {
const stepNum = o.step ?? idx + 1;
const color = stepColor(Number(stepNum) - 1);
const isUnverified = unverifiedOrderIds.has(String(o.orderid));
return (
<Tooltip
key={`${o.orderid}-${idx}`}
title={
<Box>
<div>Order #{o.orderid}</div>
<div>{o.deliveryaddress || o.deliverysuburb || ''}</div>
<div style={{ marginTop: 4, opacity: 0.8 }}>
{isUnverified ? 'Rider not recognized — click to assign a real rider' : 'Click to change rider'}
</div>
</Box>
}
>
<Box
onClick={() => openChangeRider(r, o)}
sx={{
position: 'relative',
width: 36,
height: 36,
borderRadius: '50%',
bgcolor: isUnverified ? '#ef4444' : color,
color: '#fff',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 800,
fontSize: 14,
cursor: 'pointer',
boxShadow: isUnverified
? '0 0 0 2px #fff, 0 0 0 4px #ef4444, 0 1px 3px rgba(15,23,42,0.15)'
: '0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)',
transition: 'transform 0.15s',
'&:hover': { transform: 'scale(1.08)' }
}}
>
{stepNum}
{isUnverified && (
<MdWarning
size={14}
style={{
position: 'absolute',
top: -5,
right: -5,
color: '#ef4444',
background: '#fff',
borderRadius: '50%'
}}
/>
)}
</Box>
</Tooltip>
);
})}
</Stack>
<HStack gap={1.5} wrap="wrap" vAlign="center" style={{ marginTop: 12 }}>
{r.orders.map((o, idx) => (
<StepChip
key={`${o.orderid}-${idx}`}
order={o}
stepNum={o.step ?? idx + 1}
isUnverified={unverifiedOrderIds.has(String(o.orderid))}
onClick={() => openChangeRider(r, o)}
/>
))}
</HStack>
</Card>
);
})}
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 2 }}>
<Center style={{ paddingTop: 12, paddingBottom: 16 }}>
<Button
variant="contained"
color="primary"
size="large"
startIcon={<MdSwapHoriz />}
label={
reconcileLoading
? 'Reconciling…'
: dirtyRiderIds.size === 0
? 'Reconcile'
: `Reconcile (${dirtyRiderIds.size})`
}
variant="primary"
size="lg"
icon={<MdSwapHoriz />}
isLoading={reconcileLoading}
isDisabled={reconcileLoading || dirtyRiderIds.size === 0}
width={isMobile ? '100%' : 220}
onClick={handleReconcile}
disabled={reconcileLoading || dirtyRiderIds.size === 0}
fullWidth={isMobile}
sx={{ minWidth: { xs: 0, sm: 220 }, borderRadius: '10px', textTransform: 'none', fontWeight: 700 }}
>
{reconcileLoading
? 'Reconciling...'
: dirtyRiderIds.size === 0
? 'Reconcile'
: `Reconcile (${dirtyRiderIds.size})`}
</Button>
</Box>
</Stack>
/>
</Center>
</>
)}
</Box>
</VStack>
)}
</Box>
</VStack>
<Box sx={{ px: 2, py: 1.25, borderTop: '1px solid #eef2f6' }}>
<Stack
direction={{ xs: 'column-reverse', sm: 'row' }}
gap={{ xs: 1, sm: 2 }}
alignItems={{ xs: 'stretch', sm: 'center' }}
justifyContent="end"
>
<VStack
gap={0}
paddingInline={2}
paddingBlock={1.5}
style={{ borderTop: '1px solid var(--color-border)' }}
>
<HStack gap={2} vAlign="center" justify="end" wrap="wrap">
<Button
variant="contained"
color="secondary"
startIcon={<ArrowBackIcon />}
fullWidth={isMobile}
label="Back"
variant="secondary"
icon={<MdArrowBack size={16} />}
width={isMobile ? '100%' : undefined}
onClick={() => navigate(-1)}
>
Back
</Button>
/>
{/* Assign stays disabled until every edited rider has been
reconciled and no order is left on an unrecognized rider. This
gating is the UI half of the reconcile-before-assign rule (see
this folder's CLAUDE.md §3) — do not relax it. Button uses
aria-disabled when a tooltip is present, so it stays focusable
and the reason remains discoverable. */}
<Tooltip
title={
content={
dirtyRiderIds.size > 0
? `Reconcile ${dirtyRiderIds.size} edited rider(s) first`
: unverifiedOrderIds.size > 0
? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first`
: ''
: 'Commit these assignments'
}
>
<span style={isMobile ? { width: '100%' } : undefined}>
<Button
variant="contained"
fullWidth={isMobile}
disabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
onClick={handleFinalCreateDelivery}
>
Assign Orders
</Button>
</span>
<Button
label="Assign Orders"
variant="primary"
width={isMobile ? '100%' : undefined}
isDisabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
onClick={handleFinalCreateDelivery}
/>
</Tooltip>
</Stack>
</Box>
</HStack>
</VStack>
<Dialog
open={changeDialogOpen}
onClose={() => setChangeDialogOpen(false)}
maxWidth="xs"
fullWidth
fullScreen={isMobile}
PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }}
isOpen={changeDialogOpen}
onOpenChange={(next) => !next && setChangeDialogOpen(false)}
width={420}
purpose="form"
>
<DialogTitle sx={{ fontWeight: 700 }}>Change Rider</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 2, fontSize: 13, color: 'text.secondary' }}>
<DialogHeader title="Change Rider" />
<VStack gap={3} padding={4}>
<Text type="supporting">
Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to:
</Typography>
<Autocomplete
options={ridersList || []}
getOptionLabel={(o) =>
o?.label || `${o?.firstname || ''} ${o?.lastname || ''}`.trim() || ''
}
value={selectedNewRider}
onChange={(e, val) => setSelectedNewRider(val)}
renderInput={(params) => <TextField {...params} label="New rider" placeholder="Pick a rider" />}
</Text>
<Selector
label="New rider"
placeholder="Pick a rider"
options={riderOptions}
value={selectedNewRider ? String(selectedNewRider.userid) : null}
onChange={(value) => setSelectedNewRider(riderOptions.find((o) => o.value === value)?.rider ?? null)}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, flexDirection: { xs: 'column-reverse', sm: 'row' }, gap: { xs: 1, sm: 0 } }}>
<Button fullWidth={isMobile} onClick={() => setChangeDialogOpen(false)}>
Cancel
</Button>
<Button variant="contained" fullWidth={isMobile} disabled={!selectedNewRider} onClick={confirmChangeRider}>
Change Rider
</Button>
</DialogActions>
<HStack gap={2} justify="end" wrap="wrap">
<Button
label="Cancel"
variant="secondary"
width={isMobile ? '100%' : undefined}
onClick={() => setChangeDialogOpen(false)}
/>
<Button
label="Change Rider"
variant="primary"
width={isMobile ? '100%' : undefined}
isDisabled={!selectedNewRider}
onClick={confirmChangeRider}
/>
</HStack>
</VStack>
</Dialog>
</Box>
</VStack>
);
};
// Mirrors the orders.js deliveryamt recalc — applied at render-time so the
// Assign payload always reflects the current cache without a useEffect.
function computeDeliveryAmounts(list) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,35 @@
import React, { useState, useMemo, useCallback } from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import {
MdTrendingUp,
MdTrendingDown,
MdExpandMore,
MdReceipt,
MdPayments,
MdRoute,
MdLocationOn,
MdBarChart,
MdPeopleAlt
MdPeopleAlt,
MdStraighten,
MdAccountBalanceWallet,
MdOutlineSavings
} from 'react-icons/md';
import './ProfitabilitySection.css';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Heading } from '@astryxdesign/core/Heading';
import { Collapsible } from '@astryxdesign/core/Collapsible';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import StatCard from 'components/nearle_components/StatCard';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { STATUS } from 'themes/dt/tokens';
import { AccentAvatar, TableScroll } from 'themes/dt/primitives';
// ─────────────────────────────────────────────────────────────
// Constants
@@ -34,31 +50,26 @@ export const FIXED_COST_PER_SLOT = 500 / 3;
/** Variable fuel / wear cost per km. */
export const VARIABLE_RATE_KM = 2.5;
/** Status display config keyed by normalised status string. */
const STATUS_MAP = {
delivered: { label: 'Delivered', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' },
active: { label: 'Active', color: '#0ea5e9', bg: '#f0f9ff', border: '#bae6fd' },
picked: { label: 'Picked up', color: '#8b5cf6', bg: '#f5f3ff', border: '#ddd6fe' },
assigned: { label: 'Assigned', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' },
cancelled: { label: 'Cancelled', color: '#ef4444', bg: '#fef2f2', border: '#fca5a5' },
skipped: { label: 'Skipped', color: '#f97316', bg: '#fff7ed', border: '#ffedd5' }
};
const DEFAULT_STATUS = STATUS_MAP.assigned;
// Status colours previously lived in a local STATUS_MAP here — the third rival
// status table in this folder. It disagreed with both of the others (it painted
// `active` sky-blue and had an `assigned` state the canonical table calls
// `accepted`), so the same order could render three different colours depending
// on which panel drew it. Statuses now render via <StatusBadge>, which reads
// themes/dt/status.js. See this folder's CLAUDE.md §1b.
// ─────────────────────────────────────────────────────────────
// Pure helpers
// ─────────────────────────────────────────────────────────────
function getStatusConfig(raw) {
return STATUS_MAP[String(raw ?? '').toLowerCase()] ?? DEFAULT_STATUS;
}
function orderRevenue(order) {
const km = parseFloat(order.riderkms || 0);
return km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM;
}
// NOTE: a fourth copy of the batch windows. Dispatch.js (BATCHES_DEFAULT_RAW),
// deliveries.js and this file must agree — see the dispatch folder's CLAUDE.md
// §1. reports/profitability.js imports this one, which is why it stays exported
// here rather than moving wholesale into dispatchShared.
export const BATCHES_DEFAULT = [
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
@@ -72,16 +83,35 @@ const getBatchForHour = (h, batches = BATCHES_DEFAULT) => {
return null;
};
// The row field a slot is derived from. Must match Dispatch.js's
// BATCH_TIME_FIELD ('created' → `orderdate`) and deliveries.js's
// BATCH_TIME_KEYS.
//
// This used to read `assigntime || deliverydate` with a bare `dayjs()`. Both
// halves were wrong: `assigntime` is mapped to the booking's last-modified
// column (api.js), so it is re-stamped by any status change and a rider's slot
// count drifted through the day; and bare `dayjs()` mis-parses the false
// trailing Z some Doormile timestamps carry, shifting a row +5:30 into a
// different slot. Slot count feeds the FIXED cost, so both bugs moved money.
const getRowBatch = (r, batches = BATCHES_DEFAULT) => {
const t = r?.assigntime || r?.deliverydate;
const t = r?.orderdate;
if (!t) return null;
const str = String(t).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
const d = dayjs(t);
const d = parseDoormileTimestamp(t);
if (!d.isValid()) return null;
return getBatchForHour(d.hour() + d.minute() / 60, batches);
};
// The calendar day a row belongs to — same field as the bucketing above, and
// the same field api.js's fetchDeliveries filters its date range on.
const rowDay = (o) => {
const t = o?.orderdate;
if (!t) return null;
const d = parseDoormileTimestamp(t);
return d.isValid() ? d.format('YYYY-MM-DD') : null;
};
// Orders in these statuses never delivered, so they earn no revenue and
// shouldn't be charged fuel/slot cost either — including them here made
// every rider's profit/margin KPI count cancelled and skipped stops as if
@@ -94,15 +124,7 @@ function calcRiderMetrics(rider, selectedDate, batches) {
// context. Only the money/km math below excludes them.
const orders = (rider.orders ?? []).filter((o) => {
if (!selectedDate) return true;
// parseDoormileTimestamp (not bare dayjs()) avoids the false-trailing-Z
// parse bug (utils/doormileTimestamp.js) — near a midnight boundary an
// unstripped Z could shift a row +5:30 out of selectedDate entirely.
const dateStr = o.assigntime
? parseDoormileTimestamp(o.assigntime).format('YYYY-MM-DD')
: o.deliverydate
? parseDoormileTimestamp(o.deliverydate).format('YYYY-MM-DD')
: 'unknown';
return dateStr === selectedDate;
return rowDay(o) === selectedDate;
});
const billableOrders = orders.filter((o) => {
const status = String(o.orderstatus ?? o.status ?? '').toLowerCase();
@@ -119,11 +141,7 @@ function calcRiderMetrics(rider, selectedDate, batches) {
const slot = getRowBatch(o, batches);
if (!slot) continue;
const dateStr = o.assigntime
? parseDoormileTimestamp(o.assigntime).format('YYYY-MM-DD')
: o.deliverydate
? parseDoormileTimestamp(o.deliverydate).format('YYYY-MM-DD')
: null;
const dateStr = rowDay(o);
if (!dateStr) continue;
if (!slotsByDate[dateStr]) {
@@ -150,9 +168,9 @@ function calcRiderMetrics(rider, selectedDate, batches) {
function formatDistance(km) {
const value = Number.isFinite(km) ? km : 0;
if (value > 0 && value < 1) {
return { value: String(Math.round(value * 1000)), unit: 'm' };
return `${Math.round(value * 1000)} m`;
}
return { value: value.toFixed(1), unit: 'km' };
return `${value.toFixed(1)} km`;
}
function rupees(v, decimals = 0) {
@@ -177,21 +195,15 @@ function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// Money is not a lifecycle status, so it must NOT go through StatusBadge —
// it reads the raw hexes from the STATUS palette instead, which is the
// sanctioned "arbitrary accent" channel (root CLAUDE.md §6.3).
const moneyColor = (isProfit) => (isProfit ? STATUS.success : STATUS.error);
// ─────────────────────────────────────────────────────────────
// Sub-components
// ─────────────────────────────────────────────────────────────
/** Coloured pill with a status dot. */
function OrderStatusPill({ status }) {
const cfg = getStatusConfig(status);
return (
<span className="order-status-pill" style={{ background: cfg.bg, color: cfg.color, borderColor: cfg.border }}>
<span className="order-status-dot" aria-hidden="true" />
{cfg.label}
</span>
);
}
/** Customer name + phone + location stacked cell. */
function CustomerCell({ order }) {
const name = order.customername ?? order.customer_name ?? order.deliverycustomer ?? order.pickupcustomer ?? 'Unknown customer';
@@ -199,18 +211,26 @@ function CustomerCell({ order }) {
const location = order.locationname ?? order.kitchenname ?? order.kitchenName ?? '';
return (
<div>
<div className="customer-name" title={name}>
<VStack gap={0.5} padding={0}>
<Text weight="bold" maxLines={1}>
{name}
</div>
{phone && <div className="customer-phone">{phone}</div>}
{location && (
<div className="customer-location">
<MdLocationOn size={12} aria-hidden="true" />
{location}
</div>
</Text>
{phone && (
<Text size="sm" type="supporting">
{phone}
</Text>
)}
</div>
{location && (
<HStack gap={0.5} vAlign="center">
<Center isInline width={12} height={12} style={{ color: 'var(--color-icon-secondary)' }}>
<MdLocationOn size={12} />
</Center>
<Text size="sm" type="supporting" maxLines={1}>
{location}
</Text>
</HStack>
)}
</VStack>
);
}
@@ -218,64 +238,79 @@ function CustomerCell({ order }) {
function OrdersBreakdownTable({ orders, getRevenue }) {
if (!orders?.length) {
return (
<div className="orders-empty-state" role="status">
<MdReceipt size={24} aria-hidden="true" />
<p className="orders-empty-message">No orders assigned to this rider yet.</p>
</div>
<EmptyState
title="No orders yet"
description="No orders assigned to this rider yet."
icon={<MdReceipt size={24} />}
/>
);
}
return (
<div className="orders-table-container">
<table className="orders-table" role="table">
<thead>
<tr>
<th scope="col">Customer</th>
<th scope="col">Status</th>
<th scope="col">Planned KMs</th>
<th scope="col">Actual KMs</th>
<th scope="col">Trip KMs</th>
<th scope="col" style={{ textAlign: 'right' }}>
Revenue
</th>
</tr>
</thead>
<tbody>
{orders.map((order, idx) => {
const km = parseFloat(order.riderkms ?? 0);
const dist = formatDistance(km);
const plannedDist = formatDistance(parseFloat(order.kms ?? 0));
const actualDist = formatDistance(parseFloat(order.actualkms ?? 0));
const rev = getRevenue(order);
return (
<tr key={`${order.orderid ?? 'order'}-${idx}`}>
<td>
<CustomerCell order={order} />
</td>
<td>
<OrderStatusPill status={order.orderstatus ?? order.status} />
</td>
<td>
<span className="distance-value">{plannedDist.value}</span>
<span className="distance-unit">{plannedDist.unit}</span>
</td>
<td>
<span className="distance-value">{actualDist.value}</span>
<span className="distance-unit">{actualDist.unit}</span>
</td>
<td>
<span className="distance-value">{dist.value}</span>
<span className="distance-unit">{dist.unit}</span>
</td>
<td style={{ textAlign: 'right' }}>
<span className="revenue-amount">{rupees(rev)}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TableScroll minWidth={720} maxHeight={360}>
<Table density="compact" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>Customer</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Planned KMs</TableHeaderCell>
<TableHeaderCell>Actual KMs</TableHeaderCell>
<TableHeaderCell>Trip KMs</TableHeaderCell>
<TableHeaderCell>Revenue</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order, idx) => (
<TableRow key={`${order.orderid ?? 'order'}-${idx}`}>
<TableCell>
<CustomerCell order={order} />
</TableCell>
<TableCell>
<StatusBadge status={order.orderstatus ?? order.status} />
</TableCell>
<TableCell>
<Text hasTabularNumbers>{formatDistance(parseFloat(order.kms ?? 0))}</Text>
</TableCell>
<TableCell>
<Text hasTabularNumbers>{formatDistance(parseFloat(order.actualkms ?? 0))}</Text>
</TableCell>
<TableCell>
<Text hasTabularNumbers>{formatDistance(parseFloat(order.riderkms ?? 0))}</Text>
</TableCell>
<TableCell>
<Text weight="bold" hasTabularNumbers style={{ textAlign: 'right' }}>
{rupees(getRevenue(order))}
</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableScroll>
);
}
/** One cost tile inside the expanded panel. */
function CostTile({ icon, color, label, amount, footnote }) {
return (
<Card padding={3}>
<HStack gap={2} vAlign="center">
<Center width={34} height={34} style={{ flexShrink: 0, borderRadius: 'var(--radius-element)', backgroundColor: `${color}14`, color }}>
{icon}
</Center>
<VStack gap={0} padding={0}>
<Text size="sm" type="supporting">
{label}
</Text>
<Text weight="bold" hasTabularNumbers style={{ color }}>
{amount}
</Text>
<Text size="sm" type="supporting">
{footnote}
</Text>
</VStack>
</HStack>
</Card>
);
}
@@ -283,160 +318,128 @@ function OrdersBreakdownTable({ orders, getRevenue }) {
function RiderDetailPanel({ metrics }) {
const { varCost, fixedCost, kms, net, margin } = metrics;
const isProfit = net >= 0;
const costTiles = [
{
icon: <MdPayments size={16} />,
iconClass: 'cost-tile-icon--fixed',
label: 'Fixed cost',
amount: rupees(fixedCost, 2),
amountClass: '',
footnote: 'Salary (per slot)'
},
{
icon: <MdRoute size={16} />,
iconClass: 'cost-tile-icon--variable',
label: 'Variable cost',
amount: rupees(varCost, 2),
amountClass: '',
footnote: `${kms.toFixed(1)} km × ₹${VARIABLE_RATE_KM}/km`
},
{
icon: isProfit ? <MdTrendingUp size={16} /> : <MdTrendingDown size={16} />,
iconClass: isProfit ? 'cost-tile-icon--profit' : 'cost-tile-icon--loss',
label: isProfit ? 'Net profit' : 'Net loss',
amount: `${isProfit ? '+' : ''}${rupees(net, 2)}`,
amountClass: isProfit ? 'cost-tile-amount--profit' : 'cost-tile-amount--loss',
footnote: `${Math.abs(margin).toFixed(0)}% margin`
}
];
const orderCount = metrics.orders?.length ?? 0;
return (
<div className="rider-detail-panel" aria-label="Rider cost breakdown">
<div className="rider-detail-inner">
{/* Cost breakdown tiles */}
<div className="cost-breakdown-grid">
{costTiles.map((tile) => (
<div className="cost-tile" key={tile.label}>
<div className={`cost-tile-icon ${tile.iconClass}`} aria-hidden="true">
{tile.icon}
</div>
<div className="cost-tile-content">
<span className="cost-tile-label">{tile.label}</span>
<span className={`cost-tile-amount ${tile.amountClass}`}>{tile.amount}</span>
<span className="cost-tile-footnote">{tile.footnote}</span>
</div>
</div>
))}
</div>
<VStack gap={3} padding={3}>
<Grid columns={{ minWidth: 200 }} gap={3}>
<CostTile
icon={<MdPayments size={16} />}
color={STATUS.info}
label="Fixed cost"
amount={rupees(fixedCost, 2)}
footnote="Salary (per slot)"
/>
<CostTile
icon={<MdRoute size={16} />}
color={STATUS.picked}
label="Variable cost"
amount={rupees(varCost, 2)}
footnote={`${kms.toFixed(1)} km × ₹${VARIABLE_RATE_KM}/km`}
/>
<CostTile
icon={isProfit ? <MdTrendingUp size={16} /> : <MdTrendingDown size={16} />}
color={moneyColor(isProfit)}
label={isProfit ? 'Net profit' : 'Net loss'}
amount={`${isProfit ? '+' : ''}${rupees(net, 2)}`}
footnote={`${Math.abs(margin).toFixed(0)}% margin`}
/>
</Grid>
{/* Revenue breakdown table */}
<div className="revenue-breakdown">
<div className="revenue-breakdown-header">
<div className="revenue-breakdown-title">
<MdReceipt size={14} aria-hidden="true" />
Revenue Breakdown
</div>
<span className="revenue-breakdown-count">
{metrics.orders?.length ?? 0} order{(metrics.orders?.length ?? 0) !== 1 ? 's' : ''}
</span>
</div>
<OrdersBreakdownTable orders={metrics.orders} getRevenue={orderRevenue} />
</div>
</div>
</div>
<VStack gap={2} padding={0}>
<HStack justify="between" vAlign="center">
<HStack gap={1} vAlign="center">
<Center isInline width={14} height={14} style={{ color: 'var(--color-icon-secondary)' }}>
<MdReceipt size={14} />
</Center>
<Text weight="bold">Revenue breakdown</Text>
</HStack>
<Text size="sm" type="supporting">
{orderCount} order{orderCount !== 1 ? 's' : ''}
</Text>
</HStack>
<OrdersBreakdownTable orders={metrics.orders} getRevenue={orderRevenue} />
</VStack>
</VStack>
);
}
/** Single expandable rider profitability card. */
/** Single expandable rider profitability row. */
function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggle, onFocus }) {
const { revenue, kms, totalCost, net, margin, orders } = metrics;
const isProfit = net >= 0;
const marginBarWidth = `${clamp(Math.abs(margin), 0, 100)}%`;
const orderCount = orders?.length ?? 0;
const name = rider.riderName ?? rider.username ?? `Rider #${rider.id}`;
const cardClasses = [
'rider-profitability-card',
isProfit ? 'rider-profitability-card--profitable' : 'rider-profitability-card--unprofitable',
isFocused ? 'rider-profitability-card--selected' : ''
]
.filter(Boolean)
.join(' ');
function handleActivate() {
const handleOpenChange = () => {
onToggle(rider.id);
if (!isFocused && onFocus) onFocus(rider);
}
};
return (
<div className={cardClasses} aria-label={`Rider: ${name}`}>
{/* Clickable header row */}
<div
className="rider-card-header"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={handleActivate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleActivate();
}
}}
<Card padding={0} elevation="none" style={isFocused ? { outline: '2px solid var(--color-accent)' } : undefined}>
<Collapsible
isOpen={isExpanded}
onOpenChange={handleOpenChange}
trigger={
<HStack gap={3} vAlign="center" wrap="wrap" padding={3}>
<AccentAvatar color={moneyColor(isProfit)} selected size={36}>
<Text size="sm" weight="bold" style={{ color: 'inherit' }}>
{riderInitials(name)}
</Text>
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="bold" maxLines={1}>
{name}
</Text>
<Text size="sm" type="supporting">
{orderCount} order{orderCount !== 1 ? 's' : ''} · {kms.toFixed(1)} km total
</Text>
</VStack>
<HStack gap={4} vAlign="center" wrap="wrap" style={{ marginInlineStart: 'auto' }}>
<VStack gap={0} padding={0} hAlign="end">
<Text size="sm" type="supporting">
Revenue
</Text>
<Text weight="bold" hasTabularNumbers>
{rupees(revenue)}
</Text>
</VStack>
<VStack gap={0} padding={0} hAlign="end">
<Text size="sm" type="supporting">
Cost
</Text>
<Text weight="bold" hasTabularNumbers>
{rupees(totalCost)}
</Text>
</VStack>
<VStack gap={0.5} padding={0} hAlign="end" width={120}>
<Text size="sm" type="supporting">
Net
</Text>
<Text weight="bold" hasTabularNumbers style={{ color: moneyColor(isProfit) }}>
{isProfit ? '+' : ''}
{rupees(net)}
</Text>
{/* Margin bar — a plain magnitude readout, so the value is the
absolute margin clamped to 100 exactly as the old CSS bar was. */}
<ProgressBar
value={clamp(Math.abs(margin), 0, 100)}
max={100}
variant={isProfit ? 'success' : 'error'}
label={`${Math.abs(margin).toFixed(0)}% margin`}
isLabelHidden
/>
</VStack>
</HStack>
</HStack>
}
>
{/* Rider identity */}
<div className="rider-avatar" aria-hidden="true">
{riderInitials(name)}
</div>
<div className="rider-identity">
<div className="rider-name" title={name}>
{name}
</div>
<div className="rider-order-summary">
{orderCount} order{orderCount !== 1 ? 's' : ''}
<span className="rider-order-summary-separator">·</span>
{kms.toFixed(1)} km total
</div>
</div>
{/* Summary metrics — hidden on mobile via CSS */}
<div className="rider-metrics-row" aria-hidden="true">
<div className="rider-metric">
<span className="rider-metric-label">Revenue</span>
<span className="rider-metric-amount rider-metric-amount--revenue">{rupees(revenue)}</span>
</div>
<div className="rider-metric">
<span className="rider-metric-label">Cost</span>
<span className="rider-metric-amount rider-metric-amount--cost">{rupees(totalCost)}</span>
</div>
<div className="rider-net-profit-column">
<div className="rider-metric">
<span className="rider-metric-label">Net</span>
<span className={`rider-metric-amount ${isProfit ? 'rider-metric-amount--net-profit' : 'rider-metric-amount--net-loss'}`}>
{isProfit ? '+' : ''}
{rupees(net)}
</span>
</div>
<div className="rider-margin-bar">
<div
className={`rider-margin-bar-fill ${isProfit ? 'rider-margin-bar-fill--profit' : 'rider-margin-bar-fill--loss'}`}
style={{ width: marginBarWidth }}
role="presentation"
/>
</div>
</div>
</div>
{/* Expand chevron */}
<div className={`rider-expand-toggle ${isExpanded ? 'rider-expand-toggle--open' : ''}`} aria-hidden="true">
<MdExpandMore size={22} />
</div>
</div>
{/* Expanded detail panel */}
{isExpanded && <RiderDetailPanel rider={rider} metrics={metrics} />}
</div>
<RiderDetailPanel metrics={metrics} />
</Collapsible>
</Card>
);
}
@@ -462,7 +465,6 @@ export default function ProfitabilitySection({
batches
}) {
const [expanded, setExpanded] = useState({});
const sortMode = 'profit-asc'; // 'profit-asc', 'profit-desc', 'name-asc', 'orders-desc'
const toggleRider = useCallback((id) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
@@ -475,10 +477,7 @@ export default function ProfitabilitySection({
);
// Filter out inactive riders (those with 0 orders in the selected date/slot) to ensure accurate KPI aggregations
const activeEnriched = useMemo(
() => enriched.filter((r) => r._m.orders.length > 0),
[enriched]
);
const activeEnriched = useMemo(() => enriched.filter((r) => r._m.orders.length > 0), [enriched]);
// Aggregate overall slot totals (pre-filtered for BI consistency)
const slotRevenue = activeEnriched.reduce((s, r) => s + r._m.revenue, 0);
@@ -487,137 +486,84 @@ export default function ProfitabilitySection({
const slotKms = activeEnriched.reduce((s, r) => s + r._m.kms, 0);
const profitCount = activeEnriched.filter((r) => r._m.net >= 0).length;
const lossCount = activeEnriched.length - profitCount;
const totalOrders = enriched.reduce((s, r) => s + (r._m.orders?.length ?? 0), 0);
const dailyIsProfit = totalDailyProfit >= 0;
const slotIsProfit = slotNet >= 0;
const slotMargin = slotRevenue > 0 ? (slotNet / slotRevenue) * 100 : 0;
// Filter riders (filtering by search and status tabs removed)
const filtered = activeEnriched;
// Sort riders based on selected sortMode
const sortedAndFiltered = useMemo(() => {
const list = [...filtered];
if (sortMode === 'profit-asc') {
list.sort((a, b) => a._m.net - b._m.net);
} else if (sortMode === 'profit-desc') {
list.sort((a, b) => b._m.net - a._m.net);
} else if (sortMode === 'name-asc') {
list.sort((a, b) => {
const nameA = (a.riderName ?? a.username ?? `Rider #${a.id}`).toLowerCase();
const nameB = (b.riderName ?? b.username ?? `Rider #${b.id}`).toLowerCase();
return nameA.localeCompare(nameB);
});
} else if (sortMode === 'orders-desc') {
list.sort((a, b) => (b._m.orders?.length ?? 0) - (a._m.orders?.length ?? 0));
}
return list;
}, [filtered, sortMode]);
// Sorted loss-first so the riders needing attention are at the top.
const sorted = useMemo(() => [...activeEnriched].sort((a, b) => a._m.net - b._m.net), [activeEnriched]);
return (
<div className="profitability-dashboard" role="region" aria-label="Profitability overview">
{/* ── Header ── */}
<header className="profitability-header">
<div className="profitability-header-left">
<div className="profitability-header-icon" aria-hidden="true">
<MdBarChart size={18} />
</div>
<div>
<h2 className="profitability-header-title">Profitability Overview</h2>
<p className="profitability-header-subtitle">
<span>
{activeEnriched.length} rider{activeEnriched.length !== 1 ? 's' : ''}
</span>
<span className="profitability-header-dot" />
<span style={{ color: 'var(--profit-green)' }}>{profitCount} profitable</span>
<span className="profitability-header-dot" />
<span style={{ color: 'var(--loss-red)' }}>{lossCount} at loss</span>
</p>
</div>
</div>
<VStack gap={3} padding={3}>
<HStack justify="between" vAlign="center" wrap="wrap" gap={2}>
<VStack gap={0.5} padding={0}>
<Heading level={2}>Profitability overview</Heading>
<Text type="supporting">
{activeEnriched.length} rider{activeEnriched.length !== 1 ? 's' : ''} · {profitCount} profitable · {lossCount} at loss
</Text>
</VStack>
</HStack>
<div className="profitability-kpi-group">
{/* Daily profit / loss */}
<div className={`profitability-kpi-chip ${dailyIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}>
<div className="profitability-kpi-chip-icon" aria-hidden="true">
{dailyIsProfit ? <MdTrendingUp /> : <MdTrendingDown />}
</div>
<div className="profitability-kpi-chip-content">
<span className="profitability-kpi-chip-label">Daily {dailyIsProfit ? 'profit' : 'loss'}</span>
<span className="profitability-kpi-chip-amount">{rupees(Math.abs(totalDailyProfit))}</span>
</div>
</div>
<Grid columns={{ minWidth: 200 }} gap={3}>
<StatCard
title={`Daily ${dailyIsProfit ? 'profit' : 'loss'}`}
value={rupees(Math.abs(totalDailyProfit))}
icon={dailyIsProfit ? <MdTrendingUp size={20} /> : <MdTrendingDown size={20} />}
color={moneyColor(dailyIsProfit)}
caption="All slots today"
/>
<StatCard
title="Riders active"
value={activeEnriched.length}
icon={<MdPeopleAlt size={20} />}
color={STATUS.info}
caption={`${profitCount} in profit · ${lossCount} at loss`}
/>
<StatCard
title="Total distance"
value={`${slotKms.toFixed(1)} km`}
icon={<MdStraighten size={20} />}
color={STATUS.picked}
caption="Cumulative travel distance"
/>
<StatCard
title="Slot revenue"
value={rupees(slotRevenue)}
icon={<MdAccountBalanceWallet size={20} />}
color={STATUS.success}
caption={`From ${totalOrders} order${totalOrders !== 1 ? 's' : ''}`}
/>
<StatCard
title="Slot cost"
value={rupees(slotCost)}
icon={<MdPayments size={20} />}
color={STATUS.error}
caption="Fixed + variable"
/>
<StatCard
title="Slot net"
value={`${slotIsProfit ? '+' : ''}${rupees(slotNet)}`}
icon={<MdOutlineSavings size={20} />}
color={moneyColor(slotIsProfit)}
caption={`${slotMargin >= 0 ? '+' : ''}${slotMargin.toFixed(0)}% margin`}
/>
</Grid>
{/* Slot profit / loss */}
<div className={`profitability-kpi-chip ${slotIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}>
<div className="profitability-kpi-chip-icon" aria-hidden="true">
{slotIsProfit ? <MdTrendingUp /> : <MdTrendingDown />}
</div>
<div className="profitability-kpi-chip-content">
<span className="profitability-kpi-chip-label">Slot {slotIsProfit ? 'profit' : 'loss'}</span>
<span className="profitability-kpi-chip-amount">{rupees(Math.abs(slotNet))}</span>
</div>
</div>
</div>
</header>
{/* ── Summary statistics row ── */}
<div className="profitability-summary-row" role="group" aria-label="Slot summary">
<div className="profitability-summary-card profitability-summary-card--primary">
<span className="profitability-summary-label">Riders Active</span>
<span className="profitability-summary-value">{activeEnriched.length}</span>
<span className="profitability-summary-detail">
{profitCount} in profit · {lossCount} at loss
</span>
</div>
<div className="profitability-summary-card profitability-summary-card--primary">
<span className="profitability-summary-label">Total Distance</span>
<span className="profitability-summary-value">{slotKms.toFixed(1)} km</span>
<span className="profitability-summary-detail">Cumulative travel distance</span>
</div>
<div className="profitability-summary-card profitability-summary-card--primary">
<span className="profitability-summary-label">Slot Revenue</span>
<span className="profitability-summary-value">{rupees(slotRevenue)}</span>
<span className="profitability-summary-detail">From {enriched.reduce((s, r) => s + (r._m.orders?.length ?? 0), 0)} orders</span>
</div>
<div className="profitability-summary-card profitability-summary-card--loss">
<span className="profitability-summary-label">Slot Cost</span>
<span className="profitability-summary-value profitability-summary-value--loss">{rupees(slotCost)}</span>
<span className="profitability-summary-detail">Fixed + variable</span>
</div>
<div className={`profitability-summary-card ${slotIsProfit ? 'card-profit' : 'card-loss'}`}>
<span className="profitability-summary-label">Slot Net</span>
<span className={`profitability-summary-value ${slotIsProfit ? 'net-positive' : 'net-negative'}`}>
{slotIsProfit ? '+' : ''}
{rupees(slotNet)}
</span>
<span
className={`profitability-summary-detail ${slotRevenue > 0 ? (slotNet / slotRevenue >= 0 ? 'margin-positive' : 'margin-negative') : ''
}`}
>
{slotRevenue > 0
? `${slotNet / slotRevenue >= 0 ? '+' : ''}${((slotNet / slotRevenue) * 100).toFixed(0)}% margin`
: '0% margin'}
</span>
</div>
</div>
{/* ── Controls (Search) (removed) ── */}
{/* ── Rider feed ── */}
<div className="rider-profitability-feed" role="list" aria-label="Rider cards">
<div className="rider-feed-heading" aria-hidden="true">
Riders List
</div>
{sortedAndFiltered.length === 0 ? (
<div className="profitability-empty-state" role="status">
<MdPeopleAlt size={32} aria-hidden="true" />
<p className="profitability-empty-message">No riders matching the filters.</p>
</div>
<VStack gap={2} padding={0}>
<Text weight="bold">Riders list</Text>
{sorted.length === 0 ? (
<EmptyState
title="No riders to show"
description="No riders have orders in this slot."
icon={<MdPeopleAlt size={28} />}
/>
) : (
sortedAndFiltered.map((rider) => (
<div key={rider.id} role="listitem">
<VStack gap={2} padding={0}>
{sorted.map((rider) => (
<RiderProfitabilityCard
key={rider.id}
rider={rider}
metrics={rider._m}
isExpanded={!!expanded[rider.id]}
@@ -625,18 +571,14 @@ export default function ProfitabilitySection({
onToggle={toggleRider}
onFocus={handleRiderFocus}
/>
</div>
))
))}
</VStack>
)}
</div>
</div>
</VStack>
</VStack>
);
}
OrderStatusPill.propTypes = {
status: PropTypes.string
};
CustomerCell.propTypes = {
order: PropTypes.object.isRequired
};
@@ -646,14 +588,22 @@ OrdersBreakdownTable.propTypes = {
getRevenue: PropTypes.func.isRequired
};
CostTile.propTypes = {
icon: PropTypes.node,
color: PropTypes.string,
label: PropTypes.node,
amount: PropTypes.node,
footnote: PropTypes.node
};
RiderDetailPanel.propTypes = {
rider: PropTypes.object.isRequired,
metrics: PropTypes.shape({
varCost: PropTypes.number.isRequired,
fixedCost: PropTypes.number.isRequired,
kms: PropTypes.number.isRequired,
net: PropTypes.number.isRequired,
margin: PropTypes.number.isRequired
margin: PropTypes.number.isRequired,
orders: PropTypes.array
}).isRequired
};
@@ -664,7 +614,8 @@ RiderProfitabilityCard.propTypes = {
kms: PropTypes.number.isRequired,
totalCost: PropTypes.number.isRequired,
net: PropTypes.number.isRequired,
margin: PropTypes.number.isRequired
margin: PropTypes.number.isRequired,
orders: PropTypes.array
}).isRequired,
isExpanded: PropTypes.bool.isRequired,
isFocused: PropTypes.bool,

View File

@@ -3,26 +3,80 @@
// Dispatch.js so we don't create a circular import between the host
// component and the child views.
// Status palette — single source of truth for the status pill colors
// rendered on rider cards, order rows, step lists, and tooltips.
export const STATUS_STYLES = {
created: { label: 'Created', bg: '#3b82f6', fg: '#fff' },
pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' },
accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' },
arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' },
picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' },
active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' },
delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' },
skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' },
cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' }
import { useState, useEffect } from 'react';
import { getStatusMeta } from 'themes/dt/status';
// Replaces MUI's `useMediaQuery(theme.breakpoints.down('md'))`, which was the
// last MUI dependency in Preview.js and CompareDataPanel.js. MUI's `md`
// breakpoint is 900px and `down()` is exclusive, so this is the identical
// `max-width: 899.95px` query it generated — the same width the scoped media
// blocks in Dispatch.css already key off.
const NARROW_VIEWPORT_QUERY = '(max-width: 899.95px)';
export function useIsNarrowViewport() {
const [isNarrow, setIsNarrow] = useState(
() => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(NARROW_VIEWPORT_QUERY).matches
);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
const mql = window.matchMedia(NARROW_VIEWPORT_QUERY);
const onChange = (e) => setIsNarrow(e.matches);
// Re-sync on mount in case the viewport changed between the lazy initial
// state and the effect running.
setIsNarrow(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return isNarrow;
}
// Enter/Space activation for div/li surfaces that carry an onClick.
//
// The dispatch UI is built from clickable cards and list rows that wrap
// headings, badges and bars, so they can't become real <button>s (interactive
// content nested in a button is invalid markup, and it would fight the card
// CSS). `role="button"` + `tabIndex={0}` + this handler is the sanctioned
// shape. Pair all three — a role without a tabIndex is not reachable, and a
// click without a key handler is mouse-only.
export const onActivate = (handler) => (e) => {
if (!handler) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handler(e);
}
};
export const getStatusStyle = (status) =>
STATUS_STYLES[String(status || '').toLowerCase()] || {
label: status || 'Unknown',
bg: '#64748b',
fg: '#fff'
};
// Status colour for the places that need a RAW HEX rather than a rendered
// badge — Leaflet divIcon HTML built as a template string, and the `style`
// attribute on a few inline pills. Anything that renders normal JSX should use
// <StatusBadge status={...}/> instead; this exists only because you cannot put
// a React component inside an `L.divIcon({ html: '...' })` string.
//
// This used to be a private STATUS_STYLES table, and it had drifted badly from
// the canonical one in themes/dt/status.js:
//
// picked #0ea5e9 → #8b5cf6 (was identical to `active`, so two
// active #0ea5e9 → #14b8a6 different states looked the same)
// accepted #8b5cf6 → #6366f1 (was identical to the new `picked`)
// arrived #ea580c → #06b6d4
// delivered #22c55e → #10b981
// skipped #94a3b8 → #f97316 (was the same grey as "unknown")
//
// So the dispatch map painted a picked-up order and an in-transit order the
// same blue, while the deliveries table painted them purple and teal. Now both
// read from one table. `getStatusMeta` also resolves the raw backend enums
// (miler_assigned, converted_to_consignment, …), which this never did — those
// previously fell through to the grey "unknown" branch.
//
// The `{ label, bg, fg }` shape is kept so the existing call sites are
// unchanged; `fg` is always white, as it was in every entry of the old table.
export const getStatusStyle = (status) => {
const meta = getStatusMeta(status);
return { label: meta.label, bg: meta.color, fg: '#fff' };
};
// Order-status sets used for completion / skipped decisions across the
// rider list, the planned-route renderer, and the compare data panel.