updates on the astryx desing
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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} · ` : ''}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 < 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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user